From f3b3fab59aca06ffa223b3a6dcf67e65ba1f9cec Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 15 Oct 2026 07:43:44 +0200 Subject: [PATCH 1/5] new tool for train search --- website/components/AssistantChat.tsx | 136 +- website/locales/de.ts | 16 + website/locales/en.ts | 16 + website/prompts/tool-contract.md | 3 +- website/test/AssistantChat.test.tsx | 153 +- website/test/fixtures/transitous/geocode.json | 554 ++++++ website/test/fixtures/transitous/plan.json | 1757 +++++++++++++++++ .../test/fixtures/transitous/stoptimes.json | 830 ++++++++ website/test/prompts.test.ts | 1 + website/test/transitous.test.ts | 207 ++ website/tools/transitous.ts | 310 +++ 11 files changed, 3980 insertions(+), 3 deletions(-) create mode 100644 website/test/fixtures/transitous/geocode.json create mode 100644 website/test/fixtures/transitous/plan.json create mode 100644 website/test/fixtures/transitous/stoptimes.json create mode 100644 website/test/transitous.test.ts create mode 100644 website/tools/transitous.ts diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 66efb4398..be7f99662 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -36,6 +36,18 @@ import { fetchFailed, type BundestaktResult, } from "../tools/bundestakt"; +import { + searchStopTool, + planJourneyTool, + getDeparturesTool, + fetchGeocode, + fetchStoptimes, + fetchPlan, + selectStops, + selectBoard, + selectJourneys, + type TransitousResult, +} from "../tools/transitous"; import { getAnalyticsTool, fetchStats, @@ -95,7 +107,7 @@ import { PaymentError, readChargedTotal } from "../utils/x402PaidFetch"; * requirement; analytics is about honesty — a figure about this site should say it was looked up * rather than read as something the model knew. */ -type ToolSource = "bundestakt" | "analytics" | "brave"; +type ToolSource = "bundestakt" | "transitous" | "analytics" | "brave"; /** * Everything offered to the model, with what the chat loop needs to know about a tool besides its @@ -161,6 +173,41 @@ export const TOOL_REGISTRY = [ paid: false, source: "bundestakt", }, + // Transitous, the same UX decision as the Bundestakt pair above: three tools and schemas + // the model calls separately, one shared row for the visitor (labelled by the group key's + // locale value, "Mit Zug & Bus"/"Train & bus" — modes, not the project name, which says + // nothing to a visitor), collapsed by `group`. The row's description comes from the first + // member (see `toolSelectorOptions`), so `searchStopTool`'s pair below stands for all three. + { + tool: searchStopTool, + label: "assistent.toolTransitSearch", + description: "assistent.toolTransitSearchDesc", + group: "assistent.capTransitous", + ownerScope: null, + defaultAgentOnly: false, + paid: false, + source: "transitous", + }, + { + tool: planJourneyTool, + label: "assistent.toolTransitPlan", + description: "assistent.toolTransitPlanDesc", + group: "assistent.capTransitous", + ownerScope: null, + defaultAgentOnly: false, + paid: false, + source: "transitous", + }, + { + tool: getDeparturesTool, + label: "assistent.toolTransitDepartures", + description: "assistent.toolTransitDeparturesDesc", + group: "assistent.capTransitous", + ownerScope: null, + defaultAgentOnly: false, + paid: false, + source: "transitous", + }, { tool: getPageTool, label: "assistent.toolSiteContent", @@ -466,9 +513,12 @@ export function AssistantChat() { const bundestaktSourceLabel = useLocale({ label: "assistent.bundestaktSource" }); const analyticsSourceLabel = useLocale({ label: "assistent.analyticsSource" }); const braveSourceLabel = useLocale({ label: "assistent.braveSource" }); + const transitousSourceLabel = useLocale({ label: "assistent.transitousSource" }); // Here rather than at module scope because the labels are translated per render. const sourceLinks: Record = { bundestakt: { href: "https://www.bundestakt.de", label: bundestaktSourceLabel }, + // Transitous's usage policy asks for visible attribution — this citation line is it. + transitous: { href: "https://transitous.org", label: transitousSourceLabel }, analytics: { href: "/analytics", label: analyticsSourceLabel }, brave: { href: "https://search.brave.com", label: braveSourceLabel }, }; @@ -812,6 +862,87 @@ export function AssistantChat() { } } + /** + * Fetches and projects one Transitous endpoint (stop search, departure board, routing). + * `tools/transitous.ts` is stateless, so the caching policy lives here: geocode answers + * change rarely (24 h), a board goes stale within a minute (60 s), routing answers are + * keyed by the exact query (5 min) — politeness, not latency, since Transitous answers in + * well under a second. Unlike Bundestakt's `not_found`, a stop search without hits is + * still `status: "ok"` (empty list), so the tool stays on offer for a corrected spelling. + */ + /** + * A tool argument the model sent as a non-string (or omitted) coerces to the empty string + * rather than to "[object Object]" — the empty-argument guards below turn that into a + * correctable bad_query answer instead of a request for a nonsense name. + */ + const argString = (v: unknown): string => (typeof v === "string" ? v : ""); + + async function loadTransitous(kind: "geocode" | "stoptimes" | "plan", args: Record): Promise { + // An empty required argument is an answer the model can act on, not a malfunction: fetching + // anyway would 4xx at the API, `fetch_failed` would withdraw the tool for the rest of the + // turn, and with it the one thing that fixes the mistake — a corrected query, a real stop + // id from search_stop. `recoverable` keeps the tool on offer for the retry. + if (kind === "geocode" && !argString(args.query)) { + return { + result: { status: "bad_query", reason: "search_stop needs a non-empty stop or station name to search for." }, + recoverable: true, + }; + } + if (kind === "stoptimes" && !argString(args.stop_id)) { + return { + result: { status: "bad_query", reason: "get_departures needs a stop id from search_stop." }, + recoverable: true, + }; + } + if (kind === "plan" && (!argString(args.from) || !argString(args.to))) { + return { + result: { status: "bad_query", reason: "plan_journey needs from and to stop ids from search_stop." }, + recoverable: true, + }; + } + try { + let result: TransitousResult; + if (kind === "geocode") { + const query = argString(args.query); + const raw = await queryClient.fetchQuery({ + queryKey: ["transitous", "geocode", query], + queryFn: () => fetchGeocode(query), + staleTime: 24 * 60 * 60_000, + retry: 0, + }); + result = selectStops(raw); + } else if (kind === "stoptimes") { + const stopId = argString(args.stop_id); + const time = typeof args.time === "string" ? args.time : undefined; + const raw = await queryClient.fetchQuery({ + queryKey: ["transitous", "stoptimes", stopId, time ?? null], + queryFn: () => fetchStoptimes(stopId, undefined, time), + staleTime: 60_000, + retry: 0, + }); + result = selectBoard(raw); + } else { + const from = argString(args.from); + const to = argString(args.to); + const departure = typeof args.departure === "string" ? args.departure : undefined; + const arriveBy = args.arrive_by === true; + const railOnly = args.rail_only === true; + const raw = await queryClient.fetchQuery({ + queryKey: ["transitous", "plan", from, to, departure ?? null, arriveBy, railOnly], + queryFn: () => + fetchPlan({ from, to, time: departure, arriveBy, transitModes: railOnly ? "RAIL" : undefined }), + staleTime: 5 * 60_000, + retry: 0, + }); + result = selectJourneys(raw); + } + return { result }; + } catch (err) { + // fetchQuery rethrows the fetcher's error; the module owns the wire-level failure shape. + return { result: fetchFailed(err) }; + } + } + /** * Reads the site's own traffic figures. `GET /stats` takes no range and always returns the * trailing year, so it is fetched once and every `range` is a local slice of that one payload. @@ -987,6 +1118,9 @@ export function AssistantChat() { toolRunners[generateImageTool.function.name] = runImageToolHere; toolRunners[getSitzungenTool.function.name] = (args) => loadBundestakt("sitzungen", args); toolRunners[searchClaimsTool.function.name] = (args) => loadBundestakt("claims", args); + toolRunners[searchStopTool.function.name] = (args) => loadTransitous("geocode", args); + toolRunners[planJourneyTool.function.name] = (args) => loadTransitous("plan", args); + toolRunners[getDeparturesTool.function.name] = (args) => loadTransitous("stoptimes", args); toolRunners[getPageTool.function.name] = loadPage; toolRunners[searchWebTool.function.name] = loadSearch; toolRunners[fetchUrlTool.function.name] = loadFetch; diff --git a/website/locales/de.ts b/website/locales/de.ts index 4e46bba65..da1d61866 100644 --- a/website/locales/de.ts +++ b/website/locales/de.ts @@ -98,6 +98,9 @@ export default { toolWebSearch: "Im Web suchen", toolFetchUrl: "Links öffnen", toolSiteAnalytics: "Website-Statistik", + toolTransitSearch: "Haltestellen suchen", + toolTransitPlan: "Verbindungen planen", + toolTransitDepartures: "Abfahrten", // Eine Zeile pro Fähigkeit — der Anlass war ein Test, in dem jemand nicht wusste, was // "Bundestagssitzungen" bedeutet. Kurz und konkret, keine Fachbegriffe ohne Erklärung. toolImageGenerationDesc: "Erstellt ein Bild aus deiner Beschreibung.", @@ -110,10 +113,22 @@ export default { toolWebSearchDesc: "Sucht aktuelle Informationen im Internet.", toolFetchUrlDesc: "Liest eine Seite, die du verlinkst.", toolSiteAnalyticsDesc: "Wie oft diese Website besucht wird.", + // Die hier ist die, die tatsächlich angezeigt wird: die zusammengefasste Fahrplan-Zeile + // in ToolSelector kommt vom ersten Eintrag der Gruppe (siehe TOOL_REGISTRY), und das + // ist dieser — sie beschreibt deshalb die ganze Fähigkeit und nennt die Verkehrsmittel, + // weil das Zeilenlabel ("Fahrplan") bewusst gattungsneutral bleibt. + toolTransitSearchDesc: "Züge, Busse und Fähren in ganz Europa – Verbindungen und Abfahrten, mit Echtzeit, wo Betreiber sie liefern.", + toolTransitPlanDesc: "Plant eine Verbindung von Tür zu Tür – Züge, Busse, Fähren.", + toolTransitDeparturesDesc: "Die nächsten Abfahrten einer Haltestelle.", // Zeilentitel für die zusammengefasste Zeile in ToolSelector (get_sitzungen + // search_claims). Eigener Name statt "Bundestagssitzungen": kurz, ein Eigenname, den // niemand nachschlagen muss — die Beschreibung darunter erklärt, was es tut. capBundestag: "Bundestag", + // Zeilentitel für die zusammengefasste Zeile (search_stop + plan_journey + + // get_departures). Bewusst kein Eigenname wie bei capBundestag ("Bundestag" kennt jeder, + // "Transitous" niemand) und auch nicht "Fahrplan" — ein Zeitplan ist auch das Konzert- + // Programm; "Mit Zug & Bus" nennt die Verkehrsmittel, die Fähre nennt die Beschreibung. + capTransitous: "Mit Zug & Bus", emptyState: "Starte eine Unterhaltung, indem du unten eine Nachricht eingibst.", doorIntro: "Hallo — ich bin der KI-Assistent dieser Website.", doorCan: @@ -161,6 +176,7 @@ export default { bundestaktSource: "Quelle: Bundestakt", analyticsSource: "Quelle: Analytics", braveSource: "Quelle: Brave Search", + transitousSource: "Quelle: Transitous", errorPrefix: "Fehler:", unknownError: "Unbekannter Fehler", loading: "Lädt...", diff --git a/website/locales/en.ts b/website/locales/en.ts index 451a7ba63..a5303751a 100644 --- a/website/locales/en.ts +++ b/website/locales/en.ts @@ -99,6 +99,9 @@ export default { toolWebSearch: "Web search", toolFetchUrl: "Fetch URL", toolSiteAnalytics: "Site analytics", + toolTransitSearch: "Find stops", + toolTransitPlan: "Plan journeys", + toolTransitDepartures: "Departures", toolImageGenerationDesc: "Creates a picture from your description.", // The one actually shown: the grouped Bundestag row's description comes from the first // entry in that group (see TOOL_REGISTRY), and that is this one. @@ -108,7 +111,19 @@ export default { toolWebSearchDesc: "Looks up current information on the web.", toolFetchUrlDesc: "Reads a page you link.", toolSiteAnalyticsDesc: "How often this website is visited.", + // The one actually shown: the grouped timetable row's description comes from the first + // entry in that group (see TOOL_REGISTRY), so this describes the whole capability and + // names the modes — the row label stays deliberately generic. + toolTransitSearchDesc: "Trains, buses and ferries across Europe — journeys and departures, with live times where operators publish them.", + toolTransitPlanDesc: "Plans a door-to-door journey — trains, buses, ferries.", + toolTransitDeparturesDesc: "The next departures from a stop.", capBundestag: "Bundestag", + // Row title for the collapsed timetable row (search_stop + plan_journey + + // get_departures). Deliberately not a proper name like capBundestag ("Bundestag" needs + // no lookup, "Transitous" needs one for everyone) and not "Timetables" either — a + // schedule is also a concert programme; "Train & bus" names the modes, the ferry is + // named by the description. The citation names the data source. + capTransitous: "Train & bus", emptyState: "Start a conversation by typing a message below.", // The greeting a first-time visitor sees from the default agent: what this is, what it // assumes and costs, where it can be wrong (IDENTITY.md → welcoming, honest). @@ -152,6 +167,7 @@ export default { bundestaktSource: "Source: Bundestakt", analyticsSource: "Source: Analytics", braveSource: "Source: Brave Search", + transitousSource: "Source: Transitous", errorPrefix: "Error:", unknownError: "Unknown error", loading: "Loading...", diff --git a/website/prompts/tool-contract.md b/website/prompts/tool-contract.md index 1095513a6..db23a56c4 100644 --- a/website/prompts/tool-contract.md +++ b/website/prompts/tool-contract.md @@ -1,6 +1,6 @@ --- name: tool-contract -description: The assistant's base system prompt — the tool contract. Routing rules for the Bundestakt, analytics, site-content and web-research tools, the fetch_url injection defence, and date-relative questions. +description: The assistant's base system prompt — the tool contract. Routing rules for the Bundestakt, transit, analytics, site-content and web-research tools, the fetch_url injection defence, and date-relative questions. --- You are a helpful assistant. You answer from live tool data wherever a tool exists for it, and you say so plainly whenever it does not. @@ -8,6 +8,7 @@ You are a helpful assistant. You answer from live tool data wherever a tool exis ## Tool routing - **Bundestag sessions, speaking time, fact-checks of MP statements** — use `get_sitzungen` and `search_claims`. Call `get_sitzungen` without a slug first to find the right session, then again with its slug for the details. That data comes from bundestakt.de, an AI-assisted analysis of official transcripts and not an official record itself. Always link the `url` from the tool result in your answer. +- **Journeys, departures and connections in Europe** — use `search_stop` first to resolve a named place to a stop id, then `plan_journey` or `get_departures` with that id. If several stops match, take the obvious hub or ask the user. All times come back in UTC — convert them to the user's local timezone before presenting. When a result has `realTime=false`, present its times as scheduled and say so. That data comes from transitous.org, schedules only: prices and booking are out of scope. - **How this site or one of its posts is doing** — visitors, most-read pages, trends — use `get_analytics`. When its result sets `hasHistoric`, the window reaches into figures backfilled from a different tool that counted differently; say so instead of comparing the two eras as one number. - **What this site itself says** — a blog post, a lecture, a project page — use `get_page`. Call it with the url when you already know it, such as /blog/36/, and otherwise without arguments first to list the pages. If that result sets `truncated`, call once more with a heading from its outline. - **Anything current or off this site** — news, other people's writing, documentation — use `search_web`, and link the url of every result you rely on. To read one of those results in full, or a link the user gives you, use `fetch_url`. diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 252fc6314..5eb1beefc 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -22,6 +22,9 @@ const mockGenerateImage = vi.fn(); const { mockFetchSitzungen, mockFetchClaims, + mockFetchGeocode, + mockFetchStoptimes, + mockFetchPlan, mockFetchStats, mockFetchContentIndex, mockFetchPageHtml, @@ -31,6 +34,9 @@ const { } = vi.hoisted(() => ({ mockFetchSitzungen: vi.fn(), mockFetchClaims: vi.fn(), + mockFetchGeocode: vi.fn(), + mockFetchStoptimes: vi.fn(), + mockFetchPlan: vi.fn(), mockFetchStats: vi.fn(), mockFetchContentIndex: vi.fn(), mockFetchPageHtml: vi.fn(), @@ -125,6 +131,17 @@ vi.mock("../tools/bundestakt", async (importOriginal) => { return { ...actual, fetchSitzungen: mockFetchSitzungen, fetchClaims: mockFetchClaims }; }); +// Same for transitous: real selectors, stubbed fetchers. +vi.mock("../tools/transitous", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchGeocode: mockFetchGeocode, + fetchStoptimes: mockFetchStoptimes, + fetchPlan: mockFetchPlan, + }; +}); + // Same treatment for analytics: real selector, stubbed fetcher. vi.mock("../tools/analytics", async (importOriginal) => { const actual = await importOriginal(); @@ -156,6 +173,9 @@ import { useWalletConnection } from "../hooks/useWalletConnection"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; import sitzungenFixture from "./fixtures/bundestakt/sitzungen.json"; import claimsFixture from "./fixtures/bundestakt/claims.json"; +import transitousGeocodeFixture from "./fixtures/transitous/geocode.json"; +import transitousStoptimesFixture from "./fixtures/transitous/stoptimes.json"; +import transitousPlanFixture from "./fixtures/transitous/plan.json"; import { OWNER_SCOPES } from "../utils/getChain"; import { PaymentError } from "../utils/x402PaidFetch"; import { MAX_HOPS } from "../utils/toolLoop"; @@ -222,6 +242,9 @@ describe("AssistantChat", () => { mockSwitchImageIfNeeded.mockResolvedValue(true); mockFetchSitzungen.mockResolvedValue(sitzungenFixture); mockFetchClaims.mockResolvedValue(claimsFixture); + mockFetchGeocode.mockResolvedValue(transitousGeocodeFixture); + mockFetchStoptimes.mockResolvedValue(transitousStoptimesFixture); + mockFetchPlan.mockResolvedValue(transitousPlanFixture); mockFetchStats.mockResolvedValue(statsFixture()); mockFetchContentIndex.mockResolvedValue([{ url: "/blog/36/", title: "My static site got a tool loop" }]); mockFetchSearch.mockResolvedValue({ @@ -777,6 +800,126 @@ describe("AssistantChat", () => { expect(mockFetchSitzungen).toHaveBeenCalledTimes(1); }); + it("runs the Transitous search-then-plan chain silently and feeds the projection back", async () => { + // Free and read-only, like the Bundestakt lookups: no confirm card, no wallet gate. + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("search_stop", { query: "Heidelberg Hbf" })) + .mockResolvedValueOnce( + toolCallResponse("plan_journey", { from: "de-DELFI_de:08221:1160", to: "it-trenitalia_01700:station" }), + ) + .mockResolvedValueOnce(textResponse("The direct EC to Milano leaves at 20:07.")); + + renderWithQuery(); + sendUserMessage("How do I get from Heidelberg Hbf to Milano Centrale tomorrow?"); + + await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(3)); + expect(screen.queryByRole("button", { name: /assistent\.toolConfirmGenerate/ })).not.toBeInTheDocument(); + expect(mockFetchGeocode).toHaveBeenCalledTimes(1); + expect(mockFetchPlan).toHaveBeenCalledTimes(1); + + // The tool turns carry the projected answers, not MOTIS's raw multi-hundred-KB reply. + const thirdConvo = mockSendMessage.mock.calls[2][0] as { role: string; content: string }[]; + const toolResults = thirdConvo.filter((m) => m.role === "tool"); + expect(toolResults).toHaveLength(2); + const stops = JSON.parse(toolResults[0].content) as { status: string; stops: { id: string }[] }; + expect(stops.status).toBe("ok"); + expect(stops.stops.length).toBeGreaterThan(0); + const journeys = JSON.parse(toolResults[1].content) as { + status: string; + itineraries: { legs: Record[] }[]; + }; + expect(journeys.status).toBe("ok"); + expect(journeys.itineraries.length).toBeGreaterThan(0); + for (const leg of journeys.itineraries.flatMap((it) => it.legs)) { + expect(leg.legGeometry).toBeUndefined(); + } + }); + + it("withdraws a failed Transitous tool but keeps its siblings on offer", async () => { + mockFetchPlan.mockRejectedValue(new Error("Failed to fetch")); + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("plan_journey", { from: "a", to: "b" })) + .mockResolvedValueOnce(textResponse("I could not reach the timetable.")); + + renderWithQuery(); + sendUserMessage("How do I get to Milano?"); + + await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2)); + const offered = (mockSendMessage.mock.calls[1][1] as { tools: { function: { name: string } }[] }).tools; + const names = offered.map((t) => t.function.name); + expect(names).not.toContain("plan_journey"); + expect(names).toContain("search_stop"); + expect(names).toContain("get_departures"); + }); + + it("answers an empty Transitous argument with a recoverable bad_query, not a withdrawal", async () => { + // An empty required argument is the model's mistake to correct, not a malfunction of the + // tool: withdrawing search_stop would take away the one thing that fixes the next call. + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("search_stop", {})) + .mockResolvedValueOnce(textResponse("Which station did you mean?")); + + renderWithQuery(); + sendUserMessage("When does the next train leave?"); + + await waitFor(() => expect(mockSendMessage).toHaveBeenCalledTimes(2)); + expect(mockFetchGeocode).not.toHaveBeenCalled(); + + const secondConvo = mockSendMessage.mock.calls[1][0] as { role: string; content: string }[]; + const toolResult = secondConvo.find((m) => m.role === "tool"); + expect(JSON.parse(toolResult!.content)).toMatchObject({ status: "bad_query" }); + + const offered = (mockSendMessage.mock.calls[1][1] as { tools: { function: { name: string } }[] }).tools; + expect(offered.map((t) => t.function.name)).toContain("search_stop"); + }); + + it("credits Transitous after a successful lookup, but not after a failed one", async () => { + // Transitous's usage policy asks for visible attribution, so this line is an obligation, + // and equally one not to show when nothing was actually retrieved. + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("search_stop", { query: "Heidelberg Hbf" })) + .mockResolvedValueOnce(textResponse("Heidelberg Hbf is Heidelberg's main station.")); + + const { unmount } = renderWithQuery(); + sendUserMessage("Where is Heidelberg Hbf?"); + await waitFor(() => expect(screen.getByText("assistent.transitousSource")).toBeInTheDocument()); + unmount(); + + vi.clearAllMocks(); + mockSwitchIfNeeded.mockResolvedValue(true); + mockFetchGeocode.mockRejectedValue(new Error("Failed to fetch")); + mockSendMessage + .mockResolvedValueOnce(toolCallResponse("search_stop", { query: "Heidelberg Hbf" })) + .mockResolvedValueOnce(textResponse("I could not reach the timetable.")); + + renderWithQuery(); + sendUserMessage("Where is Heidelberg Hbf?"); + + await waitFor(() => expect(screen.getByText("I could not reach the timetable.")).toBeInTheDocument()); + expect(screen.queryByText("assistent.transitousSource")).not.toBeInTheDocument(); + }); + + it("switching the Transitous row off persists and reaches the next request", async () => { + renderWithQuery(); + + // search_stop, plan_journey and get_departures present as one merged "Train & bus" row + // (TOOL_REGISTRY's `group`, whose locale value is the row label), so one click + // switches off all three wire names at once. + fireEvent.click(screen.getAllByLabelText("Train & bus")[0]); + expect(window.localStorage.getItem("x402-chat-disabled-tools")).toBe( + "search_stop,plan_journey,get_departures", + ); + + sendUserMessage("Hi"); + await waitFor(() => expect(mockSendMessage).toHaveBeenCalledOnce()); + const names = (mockSendMessage.mock.calls[0][1] as { tools: { function: { name: string } }[] }).tools.map( + (t) => t.function.name, + ); + expect(names).not.toContain("search_stop"); + expect(names).not.toContain("plan_journey"); + expect(names).not.toContain("get_departures"); + }); + it("keeps the image tool on offer when a Bundestakt lookup fails", async () => { // The regression this guards: a single shared `toolFailed` flag would have disabled // generate_image for the rest of the turn because an unrelated, free lookup failed. @@ -801,13 +944,18 @@ describe("AssistantChat", () => { // be sent as `tools: []`. "Nothing left to offer" has to mean the key is absent. mockFetchSitzungen.mockRejectedValue(new Error("down")); mockFetchClaims.mockRejectedValue(new Error("down")); + mockFetchGeocode.mockRejectedValue(new Error("down")); + mockFetchStoptimes.mockRejectedValue(new Error("down")); + mockFetchPlan.mockRejectedValue(new Error("down")); // get_page, search_web and fetch_url are switched off rather than failed: every one of their // failures is recoverable by design — a different url, a different query, a different section // — so they are never withdrawn, and "everything on offer has failed" can only be reached // with them off the table to begin with. window.localStorage.setItem("x402-chat-disabled-tools", "get_page,search_web,fetch_url"); - // All three in one hop: the two lookups fail on their own, the image tool via a cancel. + // All six in one hop: the five lookups fail on their own, the image tool via a cancel. + // The Transitous calls carry well-formed arguments, because an empty one would be a + // recoverable bad_query answer, not a failure — and the tool would stay on offer. mockSendMessage.mockResolvedValue({ choices: [ { @@ -818,6 +966,9 @@ describe("AssistantChat", () => { { id: "a", type: "function", function: { name: "get_sitzungen", arguments: "{}" } }, { id: "b", type: "function", function: { name: "search_claims", arguments: "{}" } }, { id: "c", type: "function", function: { name: "generate_image", arguments: "{}" } }, + { id: "d", type: "function", function: { name: "search_stop", arguments: '{"query":"x"}' } }, + { id: "e", type: "function", function: { name: "plan_journey", arguments: '{"from":"a","to":"b"}' } }, + { id: "f", type: "function", function: { name: "get_departures", arguments: '{"stop_id":"x"}' } }, ], }, finish_reason: "tool_calls", diff --git a/website/test/fixtures/transitous/geocode.json b/website/test/fixtures/transitous/geocode.json new file mode 100644 index 000000000..c9b4bb187 --- /dev/null +++ b/website/test/fixtures/transitous/geocode.json @@ -0,0 +1,554 @@ +[ + { + "type": "STOP", + "name": "Heidelberg Hbf", + "id": "de-DELFI_de:08221:1160", + "lat": 49.40357, + "lon": 8.675442000000002, + "country": "DE", + "zip": "69115", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Karlsruhe", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Heidelberg", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Weststadt", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -32.124732971191406, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ], + "importance": 0.003857618197798729 + }, + { + "type": "PLACE", + "category": "hotel_16", + "name": "ibis Heidelberg Hauptbahnhof", + "id": "way/[87645977]", + "lat": 49.4036051, + "lon": 8.6767446, + "country": "DE", + "zip": "69115", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Karlsruhe", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Heidelberg", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Weststadt", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -27.5 + }, + { + "type": "STOP", + "name": "Bahnstadt, Hauptbahnhof Süd", + "id": "de-DELFI_de:08221:4272:1:TrRiO", + "lat": 49.40181, + "lon": 8.674195, + "country": "DE", + "zip": "69115", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Karlsruhe", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Heidelberg", + "adminLevel": 6, + "matched": true, + "unique": true, + "default": true + }, + { + "name": "Bahnstadt", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -26.631120681762695, + "modes": [ + "TRAM", + "BUS" + ], + "importance": 0.000045373457396635786 + }, + { + "type": "PLACE", + "category": "hotel_16", + "name": "Meininger Heidelberg Hauptbahnhof", + "id": "way/[753509904]", + "lat": 49.3987481, + "lon": 8.677719, + "country": "DE", + "zip": "69115", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Karlsruhe", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Heidelberg", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Weststadt", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -26.25 + }, + { + "type": "PLACE", + "category": "none", + "name": "Heidelberg Hauptbahnhof Ost", + "id": "node/[5795035409]", + "lat": 49.4044187, + "lon": 8.6780409, + "country": "DE", + "zip": "69115", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Karlsruhe", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Heidelberg", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Bergheim", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -24.75 + }, + { + "type": "STOP", + "name": "Hauptbahnhof Süd", + "id": "at-Railway-Current-Reference-Data-2026_de:09162:100:11:11", + "lat": 48.13988, + "lon": 11.557491, + "country": "DE", + "zip": "80335", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Bayern", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Oberbayern", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "München", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Ludwigsvorstadt-Isarvorstadt", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -18.81806755065918, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "SUBWAY", + "TRAM", + "BUS" + ], + "importance": 0.021757638081908226 + }, + { + "type": "STOP", + "name": "Hauptbahnhof/ZOB", + "id": "at-Railway-Current-Reference-Data-2026_de:02000:10950:11:1", + "lat": 53.552475, + "lon": 10.008094999999999, + "level": -1, + "country": "DE", + "zip": "20099", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Hamburg", + "adminLevel": 4, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Hamburg-Mitte", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "St. Georg", + "adminLevel": 10, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -18.813016891479492, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "SUBWAY", + "BUS" + ], + "importance": 0.021419377997517586 + }, + { + "type": "STOP", + "name": "Hauptbahnhof (tief)", + "id": "at-Railway-Current-Reference-Data-2026_de:08111:6115:1:1", + "lat": 48.784748, + "lon": 9.183216999999999, + "country": "DE", + "zip": "70173", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Stuttgart", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Stuttgart", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Stuttgart-Mitte", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Hauptbahnhof", + "adminLevel": 10, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -18.504125595092773, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "SUBWAY", + "TRAM", + "BUS" + ], + "importance": 0.019253002479672432 + }, + { + "type": "STOP", + "name": "Wien Hauptbahnhof", + "id": "at-Railway-Current-Reference-Data-2026_Pat:49:1349", + "lat": 48.185190000000006, + "lon": 16.376413, + "level": 1, + "country": "AT", + "zip": "1100", + "tz": "Europe/Vienna", + "areas": [ + { + "name": "Österreich", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Wien", + "adminLevel": 4, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Favoriten", + "adminLevel": 9, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Katastralgemeinde Favoriten", + "adminLevel": 10, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -18.438060760498047, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "SUBWAY", + "TRAM", + "BUS" + ], + "importance": 0.017061332240700722 + }, + { + "type": "STOP", + "name": "Ulm Hauptbahnhof", + "id": "at-Railway-Current-Reference-Data-2026_de:08421:1008:31:1", + "lat": 48.399345000000004, + "lon": 9.982933000000001, + "country": "DE", + "zip": "89073", + "tz": "Europe/Berlin", + "areas": [ + { + "name": "Deutschland", + "adminLevel": 2, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Baden-Württemberg", + "adminLevel": 4, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Regierungsbezirk Tübingen", + "adminLevel": 5, + "matched": false, + "unique": false, + "default": false + }, + { + "name": "Ulm", + "adminLevel": 6, + "matched": false, + "unique": true, + "default": true + }, + { + "name": "Mitte", + "adminLevel": 10, + "matched": false, + "unique": false, + "default": false + } + ], + "score": -18.104284286499023, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "TRAM", + "BUS" + ], + "importance": 0.005886611994355917 + } +] \ No newline at end of file diff --git a/website/test/fixtures/transitous/plan.json b/website/test/fixtures/transitous/plan.json new file mode 100644 index 000000000..d109ec472 --- /dev/null +++ b/website/test/fixtures/transitous/plan.json @@ -0,0 +1,1757 @@ +{ + "requestParameters": {}, + "from": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160", + "importance": 0.003857618197798729, + "lat": 49.40357, + "lon": 8.675442000000002, + "level": 0, + "tz": "Europe/Berlin", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "to": { + "name": "MILANO CENTRALE", + "stopId": "it-trenitalia_01700:station", + "importance": 0.010967501439154148, + "lat": 45.486340999999996, + "lon": 9.204544, + "level": 2, + "tz": "Europe/Rome", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "direct": [], + "itineraries": [ + { + "duration": 27420, + "startTime": "2026-10-15T05:13:00Z", + "endTime": "2026-10-15T12:50:00Z", + "transfers": 2, + "id": "Cq4BCgJTMxIiMjAyNjEwMTVfMDY6MjFfZGUtREVMRklfMzQ0MDQ3ODQyMhoaZGUtREVMRklfZGU6MDgyMjE6MTE2MDozOjMh+NueILGzSEAp3pIcsKtZIUAyGmRlLURFTEZJX2RlOjA4MjIyOjI0MTc6MToxOT3QCgxZvUhAQeMBZVOu8CBASNzBwdYGUJzJwdYGWghTVUJVUkJBTmABaQAAAAAAAPC/cQAAAAAAAAAACoIBGhpkZS1ERUxGSV9kZTowODIyMjoyNDE3OjE6MSE90AoMWb1IQCnjAWVTrvAgQDIaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQ5NSbEXFK9SEBBcQM+P4zwIEBIlMrB1gZQjMvB1gZaBFdBTEtgAWkAAAAAAAAAAHEAAAAAAAAAAAq3AQoHSUNFIDI3NRIiMjAyNjEwMTVfMDY6NTBfZGUtREVMRklfMzQ0MDY3OTgyMxoaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQhNSbEXFK9SEApcQM+P4zwIEAyGGRlLURFTEZJX2NoOjE6c2xvaWQ6MzAwMDmKCP8iaLBHQEFB0NGqlhQhQEj8zMHWBlDErcLWBloOSElHSFNQRUVEX1JBSUxgAWkAAAAAAAAAAHEAAAAAAAAAAAp9GhhkZS1ERUxGSV9jaDoxOnNsb2lkOjMwMDAhigj/ImiwR0ApQdDRqpYUIUAyF2l0LXRyZW5pdGFsaWFfODUwMDAzMDAwOT4HliNksEdAQRSuR+F6FCFASJCswtYGULyuwtYGWgRXQUxLYAFpAAAAAAAAAABxAAAAAAAAAAAK0gEKAkVDEksyMDI2MTAxNV8xMTozM19pdC10cmVuaXRhbGlhXzAwODNfNjAwODNfMF92XzYtMTktNEEyRS0wMDgzX3ZfNi0xOS00QTJFLTAwODMaF2l0LXRyZW5pdGFsaWFfODUwMDAzMDAwIT4HliNksEdAKRSuR+F6FCFAMhNpdC10cmVuaXRhbGlhXzAxNzAwORraAGxAvkZAQaEvvf25aCJASMy7wtYGUPiXw9YGWg1MT05HX0RJU1RBTkNFYAFpAAAAAAAAAABxAAAAAAAAAEA=", + "legs": [ + { + "mode": "METRO", + "from": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:3:3", + "parentId": "de-DELFI_de:08221:1160_G", + "importance": 0.003857618197798729, + "lat": 49.403842999999995, + "lon": 8.675138, + "level": -1, + "tz": "Europe/Berlin", + "departure": "2026-10-15T05:13:00Z", + "scheduledDeparture": "2026-10-15T05:13:00Z", + "scheduledTrack": "3", + "track": "3", + "description": "Gleis 2, Gleis 3", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "to": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:1:1", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479279999999996, + "lon": 8.470080000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:31:00Z", + "scheduledArrival": "2026-10-15T05:29:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "duration": 1080, + "startTime": "2026-10-15T05:13:00Z", + "endTime": "2026-10-15T05:31:00Z", + "scheduledStartTime": "2026-10-15T05:13:00Z", + "scheduledEndTime": "2026-10-15T05:29:00Z", + "realTime": true, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "Mannheim Hbf", + "tripFrom": { + "name": "Karlsruhe Hauptbahnhof", + "stopId": "de-DELFI_de:08212:90:4:6", + "parentId": "de-DELFI_de:08212:90", + "importance": 0.010275337845087051, + "lat": 48.993366, + "lon": 8.401277999999998, + "level": 0, + "tz": "Europe/Berlin", + "departure": "2026-10-15T04:21:00Z", + "scheduledDeparture": "2026-10-15T04:21:00Z", + "scheduledTrack": "6", + "track": "6", + "description": "Gleis 5+6", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "tripTo": { + "name": "Germersheim", + "stopId": "de-DELFI_de:07334:32637:4:5", + "importance": 0.0008799014030955732, + "lat": 49.225640000000006, + "lon": 8.364806000000002, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T06:12:00Z", + "scheduledArrival": "2026-10-15T06:12:00Z", + "scheduledTrack": "5", + "track": "5", + "description": "Gleis 4+5", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "REGIONAL_RAIL", + "SUBURBAN", + "BUS" + ] + }, + "routeId": "de-DELFI_de:rlp:s3:_109", + "routeUrl": "", + "directionId": "1", + "routeColor": "fddd04", + "routeTextColor": "231f20", + "routeType": 109, + "agencyName": "DB Regio AG Mitte Region Südwest", + "agencyUrl": "", + "agencyFareUrl": "", + "agencyId": "10449", + "tripId": "20261015_06:21_de-DELFI_3440478422", + "routeShortName": "S3", + "routeLongName": "", + "tripShortName": "038310", + "displayName": "S3", + "cancelled": false, + "source": "de_DELFI.gtfs.zip/stop_times.txt:40919793:40919824", + "intermediateStops": [ + { + "name": "HD-Pfaffengrund/Wieblingen", + "stopId": "de-DELFI_de:08221:1454:1:1", + "parentId": "de-DELFI_de:08221:1454", + "importance": 0.000958796648774296, + "lat": 49.41197, + "lon": 8.641532999999999, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:16:00Z", + "departure": "2026-10-15T05:17:00Z", + "scheduledArrival": "2026-10-15T05:16:00Z", + "scheduledDeparture": "2026-10-15T05:17:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "REGIONAL_RAIL", + "SUBURBAN", + "BUS", + "ODM" + ] + }, + { + "name": "MA-Friedrichsfeld, Südbahnhof", + "stopId": "de-DELFI_de:08222:2334:1:1", + "parentId": "de-DELFI_de:08222:2334", + "importance": 0.000713150657247752, + "lat": 49.438103000000005, + "lon": 8.572569000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:21:00Z", + "departure": "2026-10-15T05:22:00Z", + "scheduledArrival": "2026-10-15T05:21:00Z", + "scheduledDeparture": "2026-10-15T05:22:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Zug->Mannheim", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "SUBURBAN", + "BUS", + "ODM" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + }, + { + "mode": "WALK", + "from": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:1:1", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479279999999996, + "lon": 8.470080000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:31:00Z", + "departure": "2026-10-15T05:31:00Z", + "scheduledArrival": "2026-10-15T05:29:00Z", + "scheduledDeparture": "2026-10-15T05:31:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1", + "vertexType": "TRANSIT", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "to": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:5:4", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479076, + "lon": 8.46982, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:33:00Z", + "scheduledArrival": "2026-10-15T05:33:00Z", + "scheduledTrack": "4", + "track": "4", + "description": "Gleis 4, Gleis 5", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "duration": 120, + "startTime": "2026-10-15T05:31:00Z", + "endTime": "2026-10-15T05:33:00Z", + "scheduledStartTime": "2026-10-15T05:31:00Z", + "scheduledEndTime": "2026-10-15T05:33:00Z", + "realTime": false, + "scheduled": false, + "distance": 77, + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "steps": [], + "intermediateStops": [] + }, + { + "mode": "HIGHSPEED_RAIL", + "from": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:5:4", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479076, + "lon": 8.46982, + "level": 0, + "tz": "Europe/Berlin", + "departure": "2026-10-15T05:37:00Z", + "scheduledDeparture": "2026-10-15T05:37:00Z", + "scheduledTrack": "4", + "track": "4", + "description": "Gleis 4, Gleis 5", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "to": { + "name": "Zürich HB", + "stopId": "de-DELFI_ch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378178000000005, + "lon": 8.540211999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:00:00Z", + "scheduledArrival": "2026-10-15T09:03:00Z", + "track": "10", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "duration": 12180, + "startTime": "2026-10-15T05:37:00Z", + "endTime": "2026-10-15T09:00:00Z", + "scheduledStartTime": "2026-10-15T05:37:00Z", + "scheduledEndTime": "2026-10-15T09:03:00Z", + "realTime": true, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "Zürich HB", + "tripFrom": { + "name": "Basel SBB", + "stopId": "de-DELFI_ch:1:sloid:10", + "importance": 0.03557715564966202, + "lat": 47.547413, + "lon": 7.589563000000001, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T08:07:00Z", + "scheduledDeparture": "2026-10-15T08:07:00Z", + "track": "4", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "tripTo": { + "name": "Basel SBB", + "stopId": "de-DELFI_ch:1:sloid:10", + "importance": 0.03557715564966202, + "lat": 47.547413, + "lon": 7.589563000000001, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T07:47:00Z", + "scheduledArrival": "2026-10-15T07:48:00Z", + "track": "4", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "routeId": "de-DELFI_162290_101", + "routeUrl": "", + "directionId": "1", + "routeType": 101, + "agencyName": "DB Fernverkehr AG", + "agencyUrl": "", + "agencyFareUrl": "", + "agencyId": "12681", + "tripId": "20261015_06:50_de-DELFI_3440679823", + "routeShortName": "ICE 275", + "routeLongName": "", + "tripShortName": "ICE 275", + "displayName": "ICE 275", + "cancelled": false, + "source": "de_DELFI.gtfs.zip/stop_times.txt:39893110:39893116", + "intermediateStops": [ + { + "name": "Karlsruhe Hauptbahnhof", + "stopId": "de-DELFI_de:08212:90:2:2", + "parentId": "de-DELFI_de:08212:90", + "importance": 0.010275337845087051, + "lat": 48.99373200000001, + "lon": 8.401070999999998, + "level": 1, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:59:00Z", + "departure": "2026-10-15T06:01:00Z", + "scheduledArrival": "2026-10-15T05:59:00Z", + "scheduledDeparture": "2026-10-15T06:01:00Z", + "scheduledTrack": "2", + "track": "2", + "description": "Gleis 1+2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + { + "name": "Offenburg Bahnhof", + "stopId": "de-DELFI_de:08317:14506:1:1", + "parentId": "de-DELFI_de:08317:14506", + "importance": 0.002785325516015291, + "lat": 48.47649, + "lon": 7.946129000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T06:29:00Z", + "departure": "2026-10-15T06:30:00Z", + "scheduledArrival": "2026-10-15T06:29:00Z", + "scheduledDeparture": "2026-10-15T06:30:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1+2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "BUS" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + }, + { + "mode": "WALK", + "from": { + "name": "Zürich HB", + "stopId": "de-DELFI_ch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378178000000005, + "lon": 8.540211999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:00:00Z", + "departure": "2026-10-15T09:00:00Z", + "scheduledArrival": "2026-10-15T09:03:00Z", + "scheduledDeparture": "2026-10-15T09:00:00Z", + "track": "10", + "vertexType": "TRANSIT", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "to": { + "name": "ZUERICH HB", + "stopId": "it-trenitalia_850003000", + "parentId": "it-trenitalia_850003000:station", + "importance": 0.07083380967378616, + "lat": 47.378056, + "lon": 8.54, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:05:00Z", + "scheduledArrival": "2026-10-15T09:05:00Z", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "duration": 300, + "startTime": "2026-10-15T09:00:00Z", + "endTime": "2026-10-15T09:05:00Z", + "scheduledStartTime": "2026-10-15T09:00:00Z", + "scheduledEndTime": "2026-10-15T09:05:00Z", + "realTime": false, + "scheduled": false, + "distance": 117, + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "steps": [], + "intermediateStops": [] + }, + { + "mode": "LONG_DISTANCE", + "from": { + "name": "ZUERICH HB", + "stopId": "it-trenitalia_850003000", + "parentId": "it-trenitalia_850003000:station", + "importance": 0.07083380967378616, + "lat": 47.378056, + "lon": 8.54, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T09:33:00Z", + "scheduledDeparture": "2026-10-15T09:33:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "to": { + "name": "MILANO CENTRALE", + "stopId": "it-trenitalia_01700", + "parentId": "it-trenitalia_01700:station", + "importance": 0.010967501439154148, + "lat": 45.486340999999996, + "lon": 9.204544, + "level": 2, + "tz": "Europe/Rome", + "arrival": "2026-10-15T12:50:00Z", + "scheduledArrival": "2026-10-15T12:50:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "duration": 11820, + "startTime": "2026-10-15T09:33:00Z", + "endTime": "2026-10-15T12:50:00Z", + "scheduledStartTime": "2026-10-15T09:33:00Z", + "scheduledEndTime": "2026-10-15T12:50:00Z", + "realTime": false, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "MILANO CENTRALE", + "tripFrom": { + "name": "ZUERICH HB", + "stopId": "it-trenitalia_850003000", + "parentId": "it-trenitalia_850003000:station", + "importance": 0.07083380967378616, + "lat": 47.378056, + "lon": 8.54, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T09:33:00Z", + "scheduledDeparture": "2026-10-15T09:33:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "tripTo": { + "name": "MILANO CENTRALE", + "stopId": "it-trenitalia_01700", + "parentId": "it-trenitalia_01700:station", + "importance": 0.010967501439154148, + "lat": 45.486340999999996, + "lon": 9.204544, + "level": 2, + "tz": "Europe/Rome", + "arrival": "2026-10-15T12:50:00Z", + "scheduledArrival": "2026-10-15T12:50:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "routeId": "it-trenitalia_60083_7cf9622d2ca9dab7", + "routeUrl": "", + "directionId": "1", + "routeColor": "1f6fb2", + "routeTextColor": "ffffff", + "routeType": 102, + "agencyName": "TRENITALIA", + "agencyUrl": "http://www.trenitalia.com", + "agencyFareUrl": "", + "agencyId": "TRENITALIA", + "tripId": "20261015_11:33_it-trenitalia_0083_60083_0_v_6-19-4A2E-0083_v_6-19-4A2E-0083", + "routeShortName": "EC", + "routeLongName": "Eurocity", + "tripShortName": "19", + "displayName": "EC", + "cancelled": false, + "source": "it_trenitalia.gtfs.zip/stop_times.txt:116621:116628", + "intermediateStops": [ + { + "name": "ZUG(CH)", + "stopId": "it-trenitalia_850002204", + "parentId": "it-trenitalia_850002204:station", + "importance": 0.0008505891892127693, + "lat": 47.173611, + "lon": 8.515277999999999, + "level": 1, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:59:00Z", + "departure": "2026-10-15T10:00:00Z", + "scheduledArrival": "2026-10-15T09:59:00Z", + "scheduledDeparture": "2026-10-15T10:00:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "REGIONAL_RAIL" + ] + }, + { + "name": "ARTH-GOLDAU", + "stopId": "it-trenitalia_850005004", + "parentId": "it-trenitalia_850005004:station", + "importance": 0.010756864212453365, + "lat": 47.048888999999996, + "lon": 8.549444000000001, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T10:16:00Z", + "departure": "2026-10-15T10:18:00Z", + "scheduledArrival": "2026-10-15T10:16:00Z", + "scheduledDeparture": "2026-10-15T10:18:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "BUS", + "FUNICULAR" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + } + ] + }, + { + "duration": 25440, + "startTime": "2026-10-15T05:13:00Z", + "endTime": "2026-10-15T12:17:00Z", + "transfers": 3, + "id": "Cq4BCgJTMxIiMjAyNjEwMTVfMDY6MjFfZGUtREVMRklfMzQ0MDQ3ODQyMhoaZGUtREVMRklfZGU6MDgyMjE6MTE2MDozOjMh+NueILGzSEAp3pIcsKtZIUAyGmRlLURFTEZJX2RlOjA4MjIyOjI0MTc6MToxOT3QCgxZvUhAQeMBZVOu8CBASNzBwdYGUJzJwdYGWghTVUJVUkJBTmABaQAAAAAAAPC/cQAAAAAAAAAACoIBGhpkZS1ERUxGSV9kZTowODIyMjoyNDE3OjE6MSE90AoMWb1IQCnjAWVTrvAgQDIaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQ5NSbEXFK9SEBBcQM+P4zwIEBIlMrB1gZQjMvB1gZaBFdBTEtgAWkAAAAAAAAAAHEAAAAAAAAAAAq3AQoHSUNFIDI3NRIiMjAyNjEwMTVfMDY6NTBfZGUtREVMRklfMzQ0MDY3OTgyMxoaZGUtREVMRklfZGU6MDgyMjI6MjQxNzo1OjQhNSbEXFK9SEApcQM+P4zwIEAyGGRlLURFTEZJX2NoOjE6c2xvaWQ6MzAwMDmKCP8iaLBHQEFB0NGqlhQhQEj8zMHWBlDErcLWBloOSElHSFNQRUVEX1JBSUxgAWkAAAAAAAAAAHEAAAAAAAAAAAqVARoYZGUtREVMRklfY2g6MTpzbG9pZDozMDAwIYoI/yJosEdAKUHQ0aqWFCFAMi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDozMDAwOjU6ODmwHCEDebBHQEGBQGfSphIhQEiQrMLWBlC8rsLWBloEV0FMS2ABaQAAAAAAAAAAcQAAAAAAAAAACvYBCgNJQzISQzIwMjYxMDE1XzExOjA1X2NoLW9wZW50cmFuc3BvcnRkYXRhc3dpc3MyNl8ub2pwLTkxLTItRy4xLlRBLjE2NC5qMjYaL2NoLW9wZW50cmFuc3BvcnRkYXRhc3dpc3MyNl9jaDoxOnNsb2lkOjMwMDA6NTo4IbAcIQN5sEdAKYFAZ9KmEiFAMi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDo1MzAwOjI6Mjkq/1peuQBHQEEff2lRn+QhQEi8rsLWBlC448LWBloNTE9OR19ESVNUQU5DRWABaQAAAAAAAAAACpoBGi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDo1MzAwOjI6MiEq/1peuQBHQCkff2lRn+QhQDIvY2gtb3BlbnRyYW5zcG9ydGRhdGFzd2lzczI2X2NoOjE6c2xvaWQ6NTMwMDoxOjE5ct9qnbgAR0BBSMfVyK7kIUBIuOPC1gZQqOXC1gZaBFdBTEtgAQrqAQoEUkU4MBJCMjAyNjEwMTVfMTI6MjJfY2gtb3BlbnRyYW5zcG9ydGRhdGFzd2lzczI2Xy5vanAtOTEtODAuMS5UQS45MTUuajI2Gi9jaC1vcGVudHJhbnNwb3J0ZGF0YXN3aXNzMjZfY2g6MTpzbG9pZDo1MzAwOjE6MSFy32qduABHQClIx9XIruQhQDIjY2gtb3BlbnRyYW5zcG9ydGRhdGFzd2lzczI2XzgzMDE3MDA59dVVgVq+RkBBqwDfbd5oIkBIqOXC1gZQvIjD1gZaDVJFR0lPTkFMX1JBSUxgAXEAAAAAAAAAQA==", + "legs": [ + { + "mode": "METRO", + "from": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:3:3", + "parentId": "de-DELFI_de:08221:1160_G", + "importance": 0.003857618197798729, + "lat": 49.403842999999995, + "lon": 8.675138, + "level": -1, + "tz": "Europe/Berlin", + "departure": "2026-10-15T05:13:00Z", + "scheduledDeparture": "2026-10-15T05:13:00Z", + "scheduledTrack": "3", + "track": "3", + "description": "Gleis 2, Gleis 3", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "to": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:1:1", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479279999999996, + "lon": 8.470080000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:31:00Z", + "scheduledArrival": "2026-10-15T05:29:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "duration": 1080, + "startTime": "2026-10-15T05:13:00Z", + "endTime": "2026-10-15T05:31:00Z", + "scheduledStartTime": "2026-10-15T05:13:00Z", + "scheduledEndTime": "2026-10-15T05:29:00Z", + "realTime": true, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "Mannheim Hbf", + "tripFrom": { + "name": "Karlsruhe Hauptbahnhof", + "stopId": "de-DELFI_de:08212:90:4:6", + "parentId": "de-DELFI_de:08212:90", + "importance": 0.010275337845087051, + "lat": 48.993366, + "lon": 8.401277999999998, + "level": 0, + "tz": "Europe/Berlin", + "departure": "2026-10-15T04:21:00Z", + "scheduledDeparture": "2026-10-15T04:21:00Z", + "scheduledTrack": "6", + "track": "6", + "description": "Gleis 5+6", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "tripTo": { + "name": "Germersheim", + "stopId": "de-DELFI_de:07334:32637:4:5", + "importance": 0.0008799014030955732, + "lat": 49.225640000000006, + "lon": 8.364806000000002, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T06:12:00Z", + "scheduledArrival": "2026-10-15T06:12:00Z", + "scheduledTrack": "5", + "track": "5", + "description": "Gleis 4+5", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "REGIONAL_RAIL", + "SUBURBAN", + "BUS" + ] + }, + "routeId": "de-DELFI_de:rlp:s3:_109", + "routeUrl": "", + "directionId": "1", + "routeColor": "fddd04", + "routeTextColor": "231f20", + "routeType": 109, + "agencyName": "DB Regio AG Mitte Region Südwest", + "agencyUrl": "", + "agencyFareUrl": "", + "agencyId": "10449", + "tripId": "20261015_06:21_de-DELFI_3440478422", + "routeShortName": "S3", + "routeLongName": "", + "tripShortName": "038310", + "displayName": "S3", + "cancelled": false, + "source": "de_DELFI.gtfs.zip/stop_times.txt:40919793:40919824", + "intermediateStops": [ + { + "name": "HD-Pfaffengrund/Wieblingen", + "stopId": "de-DELFI_de:08221:1454:1:1", + "parentId": "de-DELFI_de:08221:1454", + "importance": 0.000958796648774296, + "lat": 49.41197, + "lon": 8.641532999999999, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:16:00Z", + "departure": "2026-10-15T05:17:00Z", + "scheduledArrival": "2026-10-15T05:16:00Z", + "scheduledDeparture": "2026-10-15T05:17:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "REGIONAL_RAIL", + "SUBURBAN", + "BUS", + "ODM" + ] + }, + { + "name": "MA-Friedrichsfeld, Südbahnhof", + "stopId": "de-DELFI_de:08222:2334:1:1", + "parentId": "de-DELFI_de:08222:2334", + "importance": 0.000713150657247752, + "lat": 49.438103000000005, + "lon": 8.572569000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:21:00Z", + "departure": "2026-10-15T05:22:00Z", + "scheduledArrival": "2026-10-15T05:21:00Z", + "scheduledDeparture": "2026-10-15T05:22:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Zug->Mannheim", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "SUBURBAN", + "BUS", + "ODM" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + }, + { + "mode": "WALK", + "from": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:1:1", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479279999999996, + "lon": 8.470080000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:31:00Z", + "departure": "2026-10-15T05:31:00Z", + "scheduledArrival": "2026-10-15T05:29:00Z", + "scheduledDeparture": "2026-10-15T05:31:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1", + "vertexType": "TRANSIT", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "to": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:5:4", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479076, + "lon": 8.46982, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:33:00Z", + "scheduledArrival": "2026-10-15T05:33:00Z", + "scheduledTrack": "4", + "track": "4", + "description": "Gleis 4, Gleis 5", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "duration": 120, + "startTime": "2026-10-15T05:31:00Z", + "endTime": "2026-10-15T05:33:00Z", + "scheduledStartTime": "2026-10-15T05:31:00Z", + "scheduledEndTime": "2026-10-15T05:33:00Z", + "realTime": false, + "scheduled": false, + "distance": 77, + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "steps": [], + "intermediateStops": [] + }, + { + "mode": "HIGHSPEED_RAIL", + "from": { + "name": "Mannheim, Hauptbahnhof", + "stopId": "de-DELFI_de:08222:2417:5:4", + "parentId": "de-DELFI_de:08222:2417", + "importance": 0.0067779431119561195, + "lat": 49.479076, + "lon": 8.46982, + "level": 0, + "tz": "Europe/Berlin", + "departure": "2026-10-15T05:37:00Z", + "scheduledDeparture": "2026-10-15T05:37:00Z", + "scheduledTrack": "4", + "track": "4", + "description": "Gleis 4, Gleis 5", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "to": { + "name": "Zürich HB", + "stopId": "de-DELFI_ch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378178000000005, + "lon": 8.540211999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:00:00Z", + "scheduledArrival": "2026-10-15T09:03:00Z", + "track": "10", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "duration": 12180, + "startTime": "2026-10-15T05:37:00Z", + "endTime": "2026-10-15T09:00:00Z", + "scheduledStartTime": "2026-10-15T05:37:00Z", + "scheduledEndTime": "2026-10-15T09:03:00Z", + "realTime": true, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "Zürich HB", + "tripFrom": { + "name": "Basel SBB", + "stopId": "de-DELFI_ch:1:sloid:10", + "importance": 0.03557715564966202, + "lat": 47.547413, + "lon": 7.589563000000001, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T08:07:00Z", + "scheduledDeparture": "2026-10-15T08:07:00Z", + "track": "4", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "tripTo": { + "name": "Basel SBB", + "stopId": "de-DELFI_ch:1:sloid:10", + "importance": 0.03557715564966202, + "lat": 47.547413, + "lon": 7.589563000000001, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T07:47:00Z", + "scheduledArrival": "2026-10-15T07:48:00Z", + "track": "4", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "routeId": "de-DELFI_162290_101", + "routeUrl": "", + "directionId": "1", + "routeType": 101, + "agencyName": "DB Fernverkehr AG", + "agencyUrl": "", + "agencyFareUrl": "", + "agencyId": "12681", + "tripId": "20261015_06:50_de-DELFI_3440679823", + "routeShortName": "ICE 275", + "routeLongName": "", + "tripShortName": "ICE 275", + "displayName": "ICE 275", + "cancelled": false, + "source": "de_DELFI.gtfs.zip/stop_times.txt:39893110:39893116", + "intermediateStops": [ + { + "name": "Karlsruhe Hauptbahnhof", + "stopId": "de-DELFI_de:08212:90:2:2", + "parentId": "de-DELFI_de:08212:90", + "importance": 0.010275337845087051, + "lat": 48.99373200000001, + "lon": 8.401070999999998, + "level": 1, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T05:59:00Z", + "departure": "2026-10-15T06:01:00Z", + "scheduledArrival": "2026-10-15T05:59:00Z", + "scheduledDeparture": "2026-10-15T06:01:00Z", + "scheduledTrack": "2", + "track": "2", + "description": "Gleis 1+2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + { + "name": "Offenburg Bahnhof", + "stopId": "de-DELFI_de:08317:14506:1:1", + "parentId": "de-DELFI_de:08317:14506", + "importance": 0.002785325516015291, + "lat": 48.47649, + "lon": 7.946129000000001, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T06:29:00Z", + "departure": "2026-10-15T06:30:00Z", + "scheduledArrival": "2026-10-15T06:29:00Z", + "scheduledDeparture": "2026-10-15T06:30:00Z", + "scheduledTrack": "1", + "track": "1", + "description": "Gleis 1+2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "BUS" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + }, + { + "mode": "WALK", + "from": { + "name": "Zürich HB", + "stopId": "de-DELFI_ch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378178000000005, + "lon": 8.540211999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:00:00Z", + "departure": "2026-10-15T09:00:00Z", + "scheduledArrival": "2026-10-15T09:03:00Z", + "scheduledDeparture": "2026-10-15T09:00:00Z", + "track": "10", + "vertexType": "TRANSIT", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "to": { + "name": "Zürich HB", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:3000:5:8", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378693, + "lon": 8.536429, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:05:00Z", + "scheduledArrival": "2026-10-15T09:05:00Z", + "scheduledTrack": "8", + "track": "8", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "duration": 300, + "startTime": "2026-10-15T09:00:00Z", + "endTime": "2026-10-15T09:05:00Z", + "scheduledStartTime": "2026-10-15T09:00:00Z", + "scheduledEndTime": "2026-10-15T09:05:00Z", + "realTime": false, + "scheduled": false, + "distance": 421, + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "steps": [], + "intermediateStops": [] + }, + { + "mode": "LONG_DISTANCE", + "from": { + "name": "Zürich HB", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:3000:5:8", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378693, + "lon": 8.536429, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T09:05:00Z", + "scheduledDeparture": "2026-10-15T09:05:00Z", + "scheduledTrack": "8", + "track": "8", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "to": { + "name": "Lugano", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:2:2", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300", + "importance": 0.013114487752318382, + "lat": 46.005657, + "lon": 8.946527999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T10:58:00Z", + "scheduledArrival": "2026-10-15T10:58:00Z", + "scheduledTrack": "2", + "track": "2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "duration": 6780, + "startTime": "2026-10-15T09:05:00Z", + "endTime": "2026-10-15T10:58:00Z", + "scheduledStartTime": "2026-10-15T09:05:00Z", + "scheduledEndTime": "2026-10-15T10:58:00Z", + "realTime": false, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "Lugano", + "tripFrom": { + "name": "Zürich HB", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:3000:5:8", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:3000", + "importance": 0.07083380967378616, + "lat": 47.378693, + "lon": 8.536429, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T09:05:00Z", + "scheduledDeparture": "2026-10-15T09:05:00Z", + "scheduledTrack": "8", + "track": "8", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "tripTo": { + "name": "Lugano", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:2:2", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300", + "importance": 0.013114487752318382, + "lat": 46.005657, + "lon": 8.946527999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T10:58:00Z", + "scheduledArrival": "2026-10-15T10:58:00Z", + "scheduledTrack": "2", + "track": "2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "routeId": "ch-opentransportdataswiss26_91-2-G-j26-1", + "routeUrl": "", + "directionId": "0", + "routeType": 102, + "agencyName": "Schweizerische Bundesbahnen SBB", + "agencyUrl": "https://sbb.ch", + "agencyFareUrl": "", + "agencyId": "11", + "tripId": "20261015_11:05_ch-opentransportdataswiss26_.ojp-91-2-G.1.TA.164.j26", + "routeShortName": "IC2", + "routeLongName": "", + "tripShortName": "869", + "displayName": "IC2", + "cancelled": false, + "source": "ch_opentransportdataswiss26.gtfs.zip/stop_times.txt:108396:108401", + "intermediateStops": [ + { + "name": "Zug", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:2204:1:2", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:2204", + "importance": 0.014396363869309425, + "lat": 47.174859999999995, + "lon": 8.515957, + "level": 1, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:29:00Z", + "departure": "2026-10-15T09:30:00Z", + "scheduledArrival": "2026-10-15T09:29:00Z", + "scheduledDeparture": "2026-10-15T09:30:00Z", + "scheduledTrack": "2", + "track": "2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + { + "name": "Arth-Goldau", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5004:2:4", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5004", + "importance": 0.010756864212453365, + "lat": 47.049150000000004, + "lon": 8.550308, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T09:45:00Z", + "departure": "2026-10-15T09:49:00Z", + "scheduledArrival": "2026-10-15T09:45:00Z", + "scheduledDeparture": "2026-10-15T09:49:00Z", + "scheduledTrack": "4", + "track": "4", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "BUS", + "FUNICULAR" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + }, + { + "mode": "WALK", + "from": { + "name": "Lugano", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:2:2", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300", + "importance": 0.013114487752318382, + "lat": 46.005657, + "lon": 8.946527999999999, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T10:58:00Z", + "departure": "2026-10-15T10:58:00Z", + "scheduledArrival": "2026-10-15T10:58:00Z", + "scheduledDeparture": "2026-10-15T10:58:00Z", + "scheduledTrack": "2", + "track": "2", + "vertexType": "TRANSIT", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "to": { + "name": "Lugano", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:1:1", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300", + "importance": 0.013114487752318382, + "lat": 46.005634, + "lon": 8.946646000000001, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T11:02:00Z", + "scheduledArrival": "2026-10-15T11:02:00Z", + "scheduledTrack": "1", + "track": "1", + "vertexType": "TRANSIT", + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "duration": 240, + "startTime": "2026-10-15T10:58:00Z", + "endTime": "2026-10-15T11:02:00Z", + "scheduledStartTime": "2026-10-15T10:58:00Z", + "scheduledEndTime": "2026-10-15T11:02:00Z", + "realTime": false, + "scheduled": false, + "distance": 461, + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "steps": [], + "intermediateStops": [] + }, + { + "mode": "REGIONAL_RAIL", + "from": { + "name": "Lugano", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5300:1:1", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5300", + "importance": 0.013114487752318382, + "lat": 46.005634, + "lon": 8.946646000000001, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T11:02:00Z", + "scheduledDeparture": "2026-10-15T11:02:00Z", + "scheduledTrack": "1", + "track": "1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "to": { + "name": "Milano Centrale", + "stopId": "ch-opentransportdataswiss26_8301700", + "parentId": "ch-opentransportdataswiss26_Parent8301700", + "importance": 0.010967501439154148, + "lat": 45.487137, + "lon": 9.204821999999998, + "level": 2, + "tz": "Europe/Rome", + "arrival": "2026-10-15T12:17:00Z", + "scheduledArrival": "2026-10-15T12:17:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "duration": 4500, + "startTime": "2026-10-15T11:02:00Z", + "endTime": "2026-10-15T12:17:00Z", + "scheduledStartTime": "2026-10-15T11:02:00Z", + "scheduledEndTime": "2026-10-15T12:17:00Z", + "realTime": false, + "scheduled": true, + "interlineWithPreviousLeg": false, + "headsign": "Milano Centrale", + "tripFrom": { + "name": "Locarno", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5400:1:1", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5400", + "importance": 0.004943060223013163, + "lat": 46.17233300000001, + "lon": 8.802816000000002, + "level": 0, + "tz": "Europe/Zurich", + "departure": "2026-10-15T10:22:00Z", + "scheduledDeparture": "2026-10-15T10:22:00Z", + "scheduledTrack": "1", + "track": "1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + "tripTo": { + "name": "Milano Centrale", + "stopId": "ch-opentransportdataswiss26_8301700", + "parentId": "ch-opentransportdataswiss26_Parent8301700", + "importance": 0.010967501439154148, + "lat": 45.487137, + "lon": 9.204821999999998, + "level": 2, + "tz": "Europe/Rome", + "arrival": "2026-10-15T12:17:00Z", + "scheduledArrival": "2026-10-15T12:17:00Z", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "NIGHT_RAIL", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "routeId": "ch-opentransportdataswiss26_91-80-j26-1", + "routeUrl": "", + "directionId": "0", + "routeType": 106, + "agencyName": "Schweizerische Bundesbahnen SBB", + "agencyUrl": "https://sbb.ch", + "agencyFareUrl": "", + "agencyId": "11", + "tripId": "20261015_12:22_ch-opentransportdataswiss26_.ojp-91-80.1.TA.915.j26", + "routeShortName": "RE80", + "routeLongName": "", + "tripShortName": "25521", + "displayName": "RE80", + "cancelled": false, + "source": "ch_opentransportdataswiss26.gtfs.zip/stop_times.txt:925419:925434", + "intermediateStops": [ + { + "name": "Paradiso", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5301:1:1", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5301", + "importance": 0.005389102268964052, + "lat": 45.98940699999999, + "lon": 8.946888, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T11:05:00Z", + "departure": "2026-10-15T11:05:00Z", + "scheduledArrival": "2026-10-15T11:05:00Z", + "scheduledDeparture": "2026-10-15T11:05:00Z", + "scheduledTrack": "1", + "track": "1", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "REGIONAL_RAIL", + "SUBURBAN" + ] + }, + { + "name": "Mendrisio", + "stopId": "ch-opentransportdataswiss26_ch:1:sloid:5305:2:2", + "parentId": "ch-opentransportdataswiss26_Parentch:1:sloid:5305", + "importance": 0.0074639697559177876, + "lat": 45.869205, + "lon": 8.978509, + "level": 0, + "tz": "Europe/Zurich", + "arrival": "2026-10-15T11:18:00Z", + "departure": "2026-10-15T11:18:00Z", + "scheduledArrival": "2026-10-15T11:18:00Z", + "scheduledDeparture": "2026-10-15T11:18:00Z", + "scheduledTrack": "2", + "track": "2", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "LONG_DISTANCE", + "REGIONAL_RAIL", + "SUBURBAN" + ] + } + ], + "legGeometry": { + "points": "u~sqF}dceAu@" + }, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "alternatives": [], + "steps": [] + } + ] + } + ], + "previousPageCursor": "EARLIER|1792038420", + "nextPageCursor": "LATER|1792045500" +} \ No newline at end of file diff --git a/website/test/fixtures/transitous/stoptimes.json b/website/test/fixtures/transitous/stoptimes.json new file mode 100644 index 000000000..474b7d95b --- /dev/null +++ b/website/test/fixtures/transitous/stoptimes.json @@ -0,0 +1,830 @@ +{ + "stopTimes": [ + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:1:D", + "importance": 0.003857618197798729, + "lat": 49.404906999999994, + "lon": 8.675713, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:26:00Z", + "departure": "2026-10-15T04:27:00Z", + "scheduledArrival": "2026-10-15T04:26:00Z", + "scheduledDeparture": "2026-10-15T04:27:00Z", + "scheduledTrack": "D", + "track": "D", + "description": "Tram/Bus", + "vertexType": "TRANSIT", + "pickupType": "NOT_ALLOWED", + "dropoffType": "NOT_ALLOWED", + "cancelled": true, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "TRAM", + "realTime": true, + "headsign": "Edingen", + "tripFrom": { + "name": "Käfertal, Bahnhof (RNV)", + "stopId": "de-DELFI_de:08222:5547:1:3", + "importance": 0.00008580861322116107, + "lat": 49.510146999999996, + "lon": 8.518570999999998, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "G1", + "track": "G4", + "description": "Tram", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "TRAM", + "BUS" + ] + }, + "tripTo": { + "name": "Viernheim, Bahnhof", + "stopId": "de-DELFI_de:06431:3851:2:TrRiO", + "importance": 0.00004391103720990941, + "lat": 49.535397, + "lon": 8.582630000000002, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "3", + "track": "3", + "description": "Tram", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "TRAM", + "BUS", + "ODM" + ] + }, + "agencyId": "7911", + "agencyName": "RNV HD-MA-Weinheim (Strab)", + "agencyUrl": "", + "routeId": "de-DELFI_de:vrn:02005:_0", + "routeUrl": "", + "directionId": "1", + "routeColor": "00965e", + "routeTextColor": "ffffff", + "tripId": "20261015_05:41_de-DELFI_3436853287", + "routeType": 0, + "routeShortName": "RNV 5", + "routeLongName": "", + "tripShortName": "5200093", + "displayName": "RNV 5", + "pickupDropoffType": "NOT_ALLOWED", + "cancelled": true, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:5163314:5163355" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:1:P", + "importance": 0.003857618197798729, + "lat": 49.404163, + "lon": 8.677789, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:26:00Z", + "departure": "2026-10-15T04:27:00Z", + "scheduledArrival": "2026-10-15T04:26:00Z", + "scheduledDeparture": "2026-10-15T04:27:00Z", + "scheduledTrack": "P", + "track": "P", + "description": "Bus Ost", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "BUS", + "realTime": true, + "headsign": "Heidelberg, Universitätsplatz", + "tripFrom": { + "name": "Emmertsgrund, Endstelle", + "stopId": "de-DELFI_de:08221:1127:0:11", + "importance": 0.00002280418630107306, + "lat": 49.358936, + "lon": 8.704378999999998, + "level": 0, + "tz": "Europe/Berlin", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "BUS" + ] + }, + "tripTo": { + "name": "Heidelberg, Universitätsplatz", + "stopId": "de-DELFI_de:08221:1198:0:11", + "importance": 0.00001679499473539181, + "lat": 49.4111, + "lon": 8.705789000000001, + "level": 0, + "tz": "Europe/Berlin", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "BUS" + ] + }, + "agencyId": "7915", + "agencyName": "RNV Heidelberg (Bus)", + "agencyUrl": "", + "routeId": "de-DELFI_de:vrn:02033:_3", + "routeUrl": "", + "directionId": "1", + "routeColor": "e3000b", + "routeTextColor": "ffffff", + "tripId": "20261015_05:48_de-DELFI_3429190890", + "routeType": 3, + "routeShortName": "RNV 33", + "routeLongName": "", + "tripShortName": "2200057", + "displayName": "RNV 33", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:21177333:21177365" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:1:P", + "importance": 0.003857618197798729, + "lat": 49.404163, + "lon": 8.677789, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:28:00Z", + "departure": "2026-10-15T04:28:00Z", + "scheduledArrival": "2026-10-15T04:30:00Z", + "scheduledDeparture": "2026-10-15T04:30:00Z", + "scheduledTrack": "P", + "track": "P", + "description": "Bus Ost", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "BUS", + "realTime": true, + "headsign": "Neuenheim, Schwimmbad", + "tripFrom": { + "name": "Emmertsgrund, Mombertplatz", + "stopId": "de-DELFI_de:08221:1130:0:A", + "importance": 0.00003534605275490321, + "lat": 49.369510000000005, + "lon": 8.704046, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "A", + "track": "A", + "description": "Bus", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "BUS" + ] + }, + "tripTo": { + "name": "Neuenheim, Schwimmbad", + "stopId": "de-DELFI_de:08221:1240:0:12", + "importance": 0.00001845390215748921, + "lat": 49.420383, + "lon": 8.659337, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "B", + "track": "B", + "description": "Neuenheim, Schwimmbad", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "BUS" + ] + }, + "agencyId": "7915", + "agencyName": "RNV Heidelberg (Bus)", + "agencyUrl": "", + "routeId": "de-DELFI_de:vrn:02028:_3", + "routeUrl": "", + "directionId": "1", + "routeColor": "b09fcd", + "routeTextColor": "ffffff", + "tripId": "20261015_06:00_de-DELFI_3436862110", + "routeType": 3, + "routeShortName": "RNV 28", + "routeLongName": "", + "tripShortName": "4200024", + "displayName": "RNV 28", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:28457739:28457763" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:2:Ri NW", + "importance": 0.003857618197798729, + "lat": 49.404635999999996, + "lon": 8.676513, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:28:00Z", + "departure": "2026-10-15T04:29:00Z", + "scheduledArrival": "2026-10-15T04:27:00Z", + "scheduledDeparture": "2026-10-15T04:28:00Z", + "scheduledTrack": "B", + "track": "B", + "description": "Tram/Bus", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "TRAM", + "realTime": true, + "headsign": "Heidelberg Bismarckplatz", + "tripFrom": { + "name": "Weinheim, Alter OEG-Bahnhof", + "stopId": "de-DELFI_de:08226:4146:1:TrRiN", + "importance": 0.00001689577584329527, + "lat": 49.549206, + "lon": 8.665032000000002, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "TrRiN", + "track": "TrRiN", + "description": "TrRiN Bahnhof DB", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "RIDE_SHARING", + "TRAM", + "BUS", + "ODM" + ] + }, + "tripTo": { + "name": "Weinheim, Alter OEG-Bahnhof", + "stopId": "de-DELFI_de:08226:4146:1:TrRiN", + "importance": 0.00001689577584329527, + "lat": 49.549206, + "lon": 8.665032000000002, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "TrRiN", + "track": "TrRiN", + "description": "TrRiN Bahnhof DB", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "RIDE_SHARING", + "TRAM", + "BUS", + "ODM" + ] + }, + "agencyId": "7911", + "agencyName": "RNV HD-MA-Weinheim (Strab)", + "agencyUrl": "", + "routeId": "de-DELFI_de:vrn:02005:_0", + "routeUrl": "", + "directionId": "0", + "routeColor": "00965e", + "routeTextColor": "ffffff", + "tripId": "20261015_04:55_de-DELFI_3436853968", + "routeType": 0, + "routeShortName": "RNV 5", + "routeLongName": "", + "tripShortName": "5200068", + "displayName": "RNV 5", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:5190985:5191057" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:6:BusO1", + "importance": 0.003857618197798729, + "lat": 49.40441, + "lon": 8.676772999999999, + "level": 0, + "tz": "Europe/Berlin", + "departure": "2026-10-15T04:29:00Z", + "scheduledDeparture": "2026-10-15T04:29:00Z", + "scheduledTrack": "K", + "track": "K", + "description": "Bus W.-Brandt-Pl.", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "BUS", + "realTime": true, + "headsign": "Schatthausen, Kirche", + "tripFrom": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:6:BusO1", + "importance": 0.003857618197798729, + "lat": 49.40441, + "lon": 8.676772999999999, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "K", + "track": "K", + "description": "Bus W.-Brandt-Pl.", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "tripTo": { + "name": "Schatthausen, Kirche", + "stopId": "de-DELFI_de:08226:4247:0:RiN", + "importance": 0.000016631132893962786, + "lat": 49.31796599999999, + "lon": 8.757945, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "RiO", + "track": "RiO", + "description": "Schatthausen, Kirche", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "BUS", + "ODM" + ] + }, + "agencyId": "7927", + "agencyName": "Busverkehr-Rhein-Neckar", + "agencyUrl": "", + "routeId": "de-DELFI_de:vrn:02757:_3", + "routeUrl": "", + "directionId": "0", + "routeColor": "6c91a7", + "routeTextColor": "ffffff", + "tripId": "20261015_06:29_de-DELFI_3429202375", + "routeType": 3, + "routeShortName": "757", + "routeLongName": "", + "tripShortName": "3200101", + "displayName": "757", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:33185171:33185190" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:8:10", + "parentId": "de-DELFI_de:08221:1160_G", + "importance": 0.003857618197798729, + "lat": 49.403503, + "lon": 8.674374999999998, + "level": -1, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:31:00Z", + "departure": "2026-10-15T04:32:00Z", + "scheduledArrival": "2026-10-15T04:30:00Z", + "scheduledDeparture": "2026-10-15T04:31:00Z", + "scheduledTrack": "10", + "track": "10", + "description": "Gleis 9, Gleis 10", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "METRO", + "realTime": true, + "headsign": "Karlsruhe Hauptbahnhof", + "tripFrom": { + "name": "Kaiserslautern, Hbf", + "stopId": "de-DELFI_de:07312:9035:4:5", + "parentId": "de-DELFI_de:07312:9035", + "importance": 0.0021683480590581894, + "lat": 49.43573800000001, + "lon": 7.768622000000001, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "5", + "track": "5", + "description": "Gl4/5", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "COACH", + "REGIONAL_RAIL", + "SUBURBAN", + "BUS", + "ODM" + ] + }, + "tripTo": { + "name": "Karlsruhe Hauptbahnhof", + "stopId": "de-DELFI_de:08212:90:4:6", + "parentId": "de-DELFI_de:08212:90", + "importance": 0.010275337845087051, + "lat": 48.993366, + "lon": 8.401277999999998, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "6", + "track": "6", + "description": "Gleis 5+6", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "agencyId": "10449", + "agencyName": "DB Regio AG Mitte Region Südwest", + "agencyUrl": "", + "routeId": "de-DELFI_de:rlp:s3:_109", + "routeUrl": "", + "directionId": "0", + "routeColor": "fddd04", + "routeTextColor": "231f20", + "tripId": "20261015_04:55_de-DELFI_3440478632", + "routeType": 109, + "routeShortName": "S3", + "routeLongName": "", + "tripShortName": "038315", + "displayName": "S3", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:41039226:41039260" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:2:RiO", + "importance": 0.003857618197798729, + "lat": 49.40467999999999, + "lon": 8.676531, + "level": 0, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:31:00Z", + "departure": "2026-10-15T04:32:00Z", + "scheduledArrival": "2026-10-15T04:31:00Z", + "scheduledDeparture": "2026-10-15T04:32:00Z", + "scheduledTrack": "C", + "track": "C", + "description": "Tram/Bus", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "TRAM", + "realTime": true, + "headsign": "Rohrbach, Süd", + "tripFrom": { + "name": "Handschuh., Heiligenbergschule", + "stopId": "de-DELFI_de:08221:1333:1:Tram", + "importance": 0.0000730760584701784, + "lat": 49.423320000000004, + "lon": 8.678964999999998, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "A", + "track": "A", + "description": "Ri Technologie", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "TRAM" + ] + }, + "tripTo": { + "name": "Rohrbach, Süd", + "stopId": "de-DELFI_de:08221:1278:1:TrRiS", + "importance": 0.00008212682587327436, + "lat": 49.365837, + "lon": 8.688694, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "A", + "track": "A", + "description": "Tram", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "TRAM", + "BUS" + ] + }, + "agencyId": "7910", + "agencyName": "RNV Heidelberg (Strab)", + "agencyUrl": "", + "routeId": "de-DELFI_de:vrn:02024:_0", + "routeUrl": "", + "directionId": "0", + "routeColor": "8c1d75", + "routeTextColor": "ffffff", + "tripId": "20261015_06:23_de-DELFI_3429231255", + "routeType": 0, + "routeShortName": "RNV 24", + "routeLongName": "", + "tripShortName": "1200102", + "displayName": "RNV 24", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:4428863:4428881" + }, + { + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160:4:5", + "parentId": "de-DELFI_de:08221:1160_G", + "importance": 0.003857618197798729, + "lat": 49.403819999999996, + "lon": 8.674716, + "level": -1, + "tz": "Europe/Berlin", + "arrival": "2026-10-15T04:31:00Z", + "departure": "2026-10-15T04:33:00Z", + "scheduledArrival": "2026-10-15T04:28:00Z", + "scheduledDeparture": "2026-10-15T04:31:00Z", + "scheduledTrack": "5", + "track": "5", + "description": "Gleis 4, Gleis 5", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "mode": "METRO", + "realTime": true, + "headsign": "Kaiserslautern, Hbf", + "tripFrom": { + "name": "Karlsruhe Hauptbahnhof", + "stopId": "de-DELFI_de:08212:90:4:6", + "parentId": "de-DELFI_de:08212:90", + "importance": 0.010275337845087051, + "lat": 48.993366, + "lon": 8.401277999999998, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "6", + "track": "6", + "description": "Gleis 5+6", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "NIGHT_RAIL", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS" + ] + }, + "tripTo": { + "name": "Kaiserslautern, Hbf", + "stopId": "de-DELFI_de:07312:9035:3:3", + "parentId": "de-DELFI_de:07312:9035", + "importance": 0.0021683480590581894, + "lat": 49.435905000000005, + "lon": 7.7685319999999995, + "level": 0, + "tz": "Europe/Berlin", + "scheduledTrack": "3", + "track": "3", + "description": "Gl2/3", + "vertexType": "TRANSIT", + "pickupType": "NORMAL", + "dropoffType": "NORMAL", + "cancelled": false, + "modes": [ + "HIGHSPEED_RAIL", + "COACH", + "REGIONAL_RAIL", + "SUBURBAN", + "BUS", + "ODM" + ] + }, + "agencyId": "10449", + "agencyName": "DB Regio AG Mitte Region Südwest", + "agencyUrl": "", + "routeId": "de-DELFI_de:rlp:s2:_109", + "routeUrl": "", + "directionId": "1", + "routeColor": "1575c5", + "routeTextColor": "ffffff", + "tripId": "20261015_05:40_de-DELFI_3440478785", + "routeType": 109, + "routeShortName": "S2", + "routeLongName": "", + "tripShortName": "038204", + "displayName": "S2", + "pickupDropoffType": "NORMAL", + "cancelled": false, + "tripCancelled": false, + "bikesAllowed": false, + "wheelchairAccessible": "NOT_ACCESSIBLE", + "reservation": "NONE", + "source": "de_DELFI.gtfs.zip/stop_times.txt:41033856:41033888" + } + ], + "place": { + "name": "Heidelberg, Hauptbahnhof", + "stopId": "de-DELFI_de:08221:1160", + "importance": 0.003857618197798729, + "lat": 49.40357, + "lon": 8.675442000000002, + "level": 0, + "tz": "Europe/Berlin", + "vertexType": "TRANSIT", + "modes": [ + "HIGHSPEED_RAIL", + "LONG_DISTANCE", + "COACH", + "RIDE_SHARING", + "REGIONAL_RAIL", + "SUBURBAN", + "TRAM", + "BUS", + "ODM" + ] + }, + "previousPageCursor": "EARLIER|1792038360", + "nextPageCursor": "LATER|1792038840" +} \ No newline at end of file diff --git a/website/test/prompts.test.ts b/website/test/prompts.test.ts index cffd68f17..a4506f2aa 100644 --- a/website/test/prompts.test.ts +++ b/website/test/prompts.test.ts @@ -20,6 +20,7 @@ describe("prompt files", () => { it("carries the tool contract's known content", () => { // A stable marker per prompt, so a file/getter mix-up cannot pass silently. expect(toolContractPrompt.body).toContain("bundestakt.de"); + expect(toolContractPrompt.body).toContain("transitous.org"); expect(teenPrompt.body).toContain("teenagers"); expect(researchPrompt.body).toContain("note_findings"); }); diff --git a/website/test/transitous.test.ts b/website/test/transitous.test.ts new file mode 100644 index 000000000..f4bf21077 --- /dev/null +++ b/website/test/transitous.test.ts @@ -0,0 +1,207 @@ +/** + * Fixtures are literal copies of the live API responses, captured 2026-10-13 against + * api.transitous.org — see tools/transitous.ts's header. Trimmed only where a field is + * unbounded per leg (intermediateStops cut to 2 entries; legGeometry/steps kept but + * minimal, so the selectors' dropping them is asserted against a response that really + * carries them). Most tests here are pure function tests against them: no fetch mocking, + * no cache to reset, because the module has neither. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + selectStops, + selectBoard, + selectJourneys, + fetchGeocode, + fetchStoptimes, + fetchPlan, + fetchFailed, + type TransitousResult, +} from "../tools/transitous"; +import geocodeFixture from "./fixtures/transitous/geocode.json"; +import stoptimesFixture from "./fixtures/transitous/stoptimes.json"; +import planFixture from "./fixtures/transitous/plan.json"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** Narrows the result union to its "ok" shape, asserting that status along the way. */ +function assertOk(result: TransitousResult): asserts result is { status: "ok" } & T { + expect(result.status).toBe("ok"); +} + +/** Fixture rows carry 7 STOP and 3 PLACE entries, so filter and cap are both exercised. */ +describe("selectStops", () => { + it("keeps only STOP entries and caps the list at six", () => { + const result = selectStops(geocodeFixture); + assertOk<{ stops: { id: string; type?: string }[] }>(result); + + expect(result.stops).toHaveLength(6); + for (const stop of result.stops) { + expect(typeof stop.id).toBe("string"); + expect(stop.type).toBeUndefined(); + } + }); + + it("projects each stop to exactly name, id and modes", () => { + const result = selectStops(geocodeFixture); + assertOk<{ stops: Record[] }>(result); + + expect(Object.keys(result.stops[0]).sort()).toEqual(["id", "modes", "name"]); + }); + + it("returns an empty list, not an error, when nothing matches", () => { + const result = selectStops([{ type: "PLACE", name: "Somewhere", id: "node/1", modes: [] }]); + + expect(result).toEqual({ status: "ok", stops: [] }); + }); +}); + +describe("selectBoard", () => { + it("projects each departure to the board row and drops the stop name the API does not send", () => { + const result = selectBoard(stoptimesFixture); + assertOk<{ departures: Record[]; stop?: unknown }>(result); + + expect(result.stop).toBeUndefined(); + expect(result.departures).toHaveLength(8); + expect(Object.keys(result.departures[0]).sort()).toEqual([ + "cancelled", + "delayMin", + "headsign", + "line", + "realTime", + "time", + "track", + "train", + ]); + }); + + it("keeps the delay's sign: late is positive, early is negative", () => { + const result = selectBoard(stoptimesFixture); + assertOk<{ departures: { delayMin: number; realTime: boolean }[] }>(result); + + // The fixture board has a delayed S2 (+2), a delayed S3 (+1) and an early RNV 28 (-2). + expect(result.departures.some((d) => d.delayMin > 0)).toBe(true); + expect(result.departures.some((d) => d.delayMin < 0)).toBe(true); + expect(result.departures.some((d) => d.realTime)).toBe(true); + }); + + it("projects the whole board inside the byte budget of a tool result", () => { + const result = selectBoard(stoptimesFixture); + + expect(new TextEncoder().encode(JSON.stringify(result)).length).toBeLessThanOrEqual(2048); + }); +}); + +describe("selectJourneys", () => { + it("caps the itinerary list at three", () => { + const raw = { itineraries: [...planFixture.itineraries, ...planFixture.itineraries, ...planFixture.itineraries] }; + const result = selectJourneys(raw); + assertOk<{ itineraries: unknown[] }>(result); + + expect(result.itineraries).toHaveLength(3); + }); + + it("projects an itinerary to start, end, durationMin, transfers, realTime and legs", () => { + const result = selectJourneys(planFixture); + assertOk<{ itineraries: Record[] }>(result); + + expect(result.itineraries).toHaveLength(2); + expect(Object.keys(result.itineraries[0]).sort()).toEqual([ + "durationMin", + "end", + "legs", + "realTime", + "start", + "transfers", + ]); + expect(typeof result.itineraries[0].transfers).toBe("number"); + expect(result.itineraries[0].realTime).toBe(true); + }); + + it("aggregates realTime over transit legs only", () => { + const raw = JSON.parse(JSON.stringify(planFixture)); + for (const itinerary of raw.itineraries) { + for (const leg of itinerary.legs) leg.realTime = false; + } + const result = selectJourneys(raw); + assertOk<{ itineraries: { realTime: boolean }[] }>(result); + + expect(result.itineraries.every((it) => it.realTime === false)).toBe(true); + }); + + it("renders WALK legs as a single walkMin field", () => { + const result = selectJourneys(planFixture); + assertOk<{ itineraries: { legs: Record[] }[] }>(result); + + const walkLegs = result.itineraries.flatMap((it) => it.legs).filter((leg) => leg.walkMin !== undefined); + expect(walkLegs.length).toBeGreaterThan(0); + for (const leg of walkLegs) expect(Object.keys(leg)).toEqual(["walkMin"]); + }); + + it("drops the per-leg baggage the model must not pay for", () => { + const result = selectJourneys(planFixture); + assertOk<{ itineraries: { legs: Record[] }[] }>(result); + + const transitLegs = result.itineraries.flatMap((it) => it.legs).filter((leg) => leg.mode !== undefined); + expect(transitLegs.length).toBeGreaterThan(0); + for (const leg of transitLegs) { + expect(Object.keys(leg).sort()).toEqual([ + "agency", + "cancelled", + "from", + "headsign", + "line", + "mode", + "realTime", + "to", + "train", + ]); + expect(leg.intermediateStops).toBeUndefined(); + expect(leg.legGeometry).toBeUndefined(); + expect(leg.steps).toBeUndefined(); + } + }); + + it("projects the whole plan inside the byte budget of a tool result", () => { + const result = selectJourneys(planFixture); + + expect(new TextEncoder().encode(JSON.stringify(result)).length).toBeLessThanOrEqual(4096); + }); +}); + +describe("fetchers", () => { + it("throw a TransitousError with the HTTP status when the API answers non-OK", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 503 })); + + await expect(fetchGeocode("Heidelberg Hbf")).rejects.toMatchObject({ name: "TransitousError", status: 503 }); + await expect(fetchStoptimes("de-DELFI_de:08221:1160")).rejects.toMatchObject({ status: 503 }); + await expect(fetchPlan({ from: "a", to: "b" })).rejects.toMatchObject({ status: 503 }); + }); + + it("sends the query as URL parameters and reads the JSON body", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ stopTimes: [] }) }); + vi.stubGlobal("fetch", mockFetch); + + await fetchGeocode("Heidelberg Hbf"); + + const url = new URL(vi.mocked(mockFetch).mock.calls[0][0] as string); + expect(url).toMatchObject({ origin: "https://api.transitous.org", pathname: "/api/v1/geocode" }); + expect(url.searchParams.get("text")).toBe("Heidelberg Hbf"); + + await fetchStoptimes("de-DELFI_de:08221:1160", undefined, "2026-10-16T08:00:00Z"); + + const boardUrl = new URL(vi.mocked(mockFetch).mock.calls[1][0] as string); + expect(boardUrl.pathname).toBe("/api/v1/stoptimes"); + expect(boardUrl.searchParams.get("stopId")).toBe("de-DELFI_de:08221:1160"); + expect(boardUrl.searchParams.get("n")).toBe("8"); + expect(boardUrl.searchParams.get("time")).toBe("2026-10-16T08:00:00Z"); + }); + + it("exports the shared failure shape for the runner's catch", () => { + expect(fetchFailed(new Error("Transitous answered 503"))).toEqual({ + status: "fetch_failed", + reason: "Transitous answered 503", + }); + }); +}); diff --git a/website/tools/transitous.ts b/website/tools/transitous.ts new file mode 100644 index 000000000..696abb820 --- /dev/null +++ b/website/tools/transitous.ts @@ -0,0 +1,310 @@ +import type { X402Tool } from "../types/x402"; + +/** + * Transitous (api.transitous.org, MOTIS 2 API) as tools for the chat model — stop search, + * departure boards and journey planning across Europe. Free, keyless, open CORS. Verified + * live against the API on 2026-10-13: an empty User-Agent gets a 403, but a browser's own + * UA is accepted — and browsers cannot set the header anyway (it is a forbidden header name + * in fetch, silently dropped), so no attempt is made here. The usage policy's identifiability + * intent is met by the Origin/Referer of the requests plus the visible citation line + * (`source: "transitous"` in AssistantChat's `sourceLinks`); it also asks for a heads-up in + * their Matrix channel before regular use of the routing endpoint, which is a merge + * precondition rather than a code concern. + * + * Deliberately stateless and React-free — see the fetch/select split below — so a future MCP + * server can import the fetchers and selectors directly and bring its own caching policy, + * instead of duplicating this logic or inheriting caching it doesn't want. + * + * `fetchGeocode`/`fetchStoptimes`/`fetchPlan` are dumb fetch wrappers that throw; + * `selectStops`/`selectBoard`/`selectJourneys` are pure functions over already-parsed JSON. + * The wiring lives in AssistantChat.tsx via `queryClient.fetchQuery` for caching and dedup — + * this module has no cache of its own. + */ + +const BASE = "https://api.transitous.org/api"; + +// Tool results are input tokens on every later hop of a metered chat endpoint, so projection +// is mandatory, not a nicety: a raw single-itinerary /v1/plan response measures ~232 KB +// (legGeometry, steps, intermediateStops); the projected result is ~1 KB. Coordinates are +// dropped everywhere — maps are out of scope, and they are the single biggest token sink in +// MOTIS responses after the per-leg baggage. +const MAX_STOPS = 6; +const MAX_ITINERARIES = 3; +const MAX_LEGS = 8; +const MAX_BOARD_ROWS = 8; +const MAX_NAME_CHARS = 60; + +// --- Tool definitions (OpenAI function-calling shape) ------------------------------------- + +export const searchStopTool: X402Tool = { + type: "function", + function: { + name: "search_stop", + description: + "Find stops and stations by name (Transitous, Europe-wide). Call this first when a " + + "journey or board request names a place, then pass a returned stop id to plan_journey " + + "or get_departures. If several stops match, pick with the user or take the obvious " + + "hub (e.g. 'Milano Centrale' over 'Milano Romolo').", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Stop or station name, e.g. 'Heidelberg Hbf', 'Milano Centrale'.", + }, + }, + required: ["query"], + }, + }, +}; + +export const planJourneyTool: X402Tool = { + type: "function", + function: { + name: "plan_journey", + description: + "Plan door-to-door journeys between two European stops (Transitous/MOTIS) by train, " + + "bus, coach, ferry or a mix; rail_only restricts to trains. from and to are stop ids " + + "from search_stop. Returns up to 3 itineraries with legs and live delays where " + + "operators publish them. All times are UTC ISO — convert to the user's local timezone " + + "before presenting. Schedules only: prices and booking are out of scope; when an " + + "itinerary has realTime=false, present times as scheduled and say so.", + parameters: { + type: "object", + properties: { + from: { type: "string", description: "Departure stop id from search_stop." }, + to: { type: "string", description: "Arrival stop id from search_stop." }, + departure: { type: "string", description: "ISO datetime; omit for 'now'." }, + arrive_by: { + type: "boolean", + description: "Treat `departure` as the required arrival time instead of the departure time.", + }, + rail_only: { + type: "boolean", + description: "Trains only, excludes buses and long-distance coaches. Default false.", + }, + }, + required: ["from", "to"], + }, + }, +}; + +export const getDeparturesTool: X402Tool = { + type: "function", + function: { + name: "get_departures", + description: + "Next departures from a stop with live delays (Transitous/MOTIS). Use for questions " + + "like 'what leaves from X in the next hour', or from a later moment ('tomorrow " + + "morning') by passing `time`. stop_id comes from search_stop. All times are UTC ISO " + + "— convert to the user's local timezone before presenting.", + parameters: { + type: "object", + properties: { + stop_id: { type: "string", description: "Stop id from search_stop." }, + time: { + type: "string", + description: "ISO datetime the board starts from; omit for 'now'.", + }, + }, + required: ["stop_id"], + }, + }, +}; + +// --- Wire shapes (only the fields we read) ------------------------------------------------- + +interface RawGeocodeRow { + type?: string; + name?: string; + id?: string; + modes?: string[]; +} + +interface RawPlace { + name?: string; + arrival?: string; + departure?: string; + scheduledArrival?: string; + scheduledDeparture?: string; + track?: string; + scheduledTrack?: string; +} + +interface RawStoptime { + place?: RawPlace; + routeShortName?: string; + displayName?: string; + tripShortName?: string; + headsign?: string; + cancelled?: boolean; + realTime?: boolean; +} + +interface RawStoptimesResponse { + stopTimes?: RawStoptime[]; +} + +interface RawLeg { + mode?: string; + duration?: number; + routeShortName?: string; + displayName?: string; + tripShortName?: string; + headsign?: string; + agencyName?: string; + from?: RawPlace; + to?: RawPlace; + cancelled?: boolean; + realTime?: boolean; +} + +interface RawItinerary { + startTime?: string; + endTime?: string; + duration?: number; + transfers?: number; + legs?: RawLeg[]; +} + +interface RawPlanResponse { + itineraries?: RawItinerary[]; +} + +// --- Result contract ------------------------------------------------------------------------ + +export type TransitousResult = + | { status: "ok"; [key: string]: unknown } + | { status: "bad_query"; reason: string } + | { status: "fetch_failed"; reason: string }; + +/** Turns a fetch-time error (thrown by the fetchers) into a result. */ +export { fetchFailed } from "./failure"; + +// --- Fetchers: plain fetch, no cache, throw on failure --------------------------------------- + +export class TransitousError extends Error { + constructor(message: string, readonly status: number | null = null) { + super(message); + this.name = "TransitousError"; + } +} + +async function get(path: string, params: Record): Promise { + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v) q.set(k, v); + const res = await fetch(`${BASE}${path}?${q}`, { headers: { accept: "application/json" } }); + if (!res.ok) throw new TransitousError(`Transitous answered ${res.status}`, res.status); + return res.json() as Promise; +} + +export const fetchGeocode = (query: string): Promise => + get("/v1/geocode", { text: query }); + +export const fetchStoptimes = (stopId: string, n = MAX_BOARD_ROWS, time?: string): Promise => + get("/v1/stoptimes", { stopId, n: String(n), time }); + +export const fetchPlan = (args: { + from: string; + to: string; + time?: string; + arriveBy?: boolean; + transitModes?: string; +}): Promise => + get("/v1/plan", { + fromPlace: args.from, + toPlace: args.to, + numItineraries: String(MAX_ITINERARIES), + time: args.time, + arriveBy: args.arriveBy ? "true" : undefined, + transitModes: args.transitModes, + }); + +// --- Selectors: pure, synchronous, operate on already-parsed JSON --------------------------- + +const minutesBetween = (a?: string, b?: string) => + a && b ? Math.round((Date.parse(a) - Date.parse(b)) / 60000) : 0; + +const clip = (s: unknown, n = MAX_NAME_CHARS): string | null => { + if (typeof s !== "string" || !s) return null; + return s.length > n ? s.slice(0, n - 1) + "…" : s; +}; + +/** One board/leg endpoint: name, the live time, the delay in minutes and the track. */ +const place = (p: RawPlace = {}) => { + const time = p.departure ?? p.arrival; + const scheduled = p.scheduledDeparture ?? p.scheduledArrival; + return { + name: clip(p.name), + time: time ?? null, + // The one number users act on. Positive = late; the sign must survive projection. + delayMin: minutesBetween(time, scheduled), + track: clip(p.track ?? p.scheduledTrack, 8), + }; +}; + +/** [{name, id, modes}] — ids survive because the next call needs them. */ +export function selectStops(raw: unknown): TransitousResult { + const rows = (Array.isArray(raw) ? raw : []) as RawGeocodeRow[]; + return { + status: "ok", + stops: rows + .filter((r) => r?.type === "STOP") + .slice(0, MAX_STOPS) + .map((r) => ({ name: clip(r.name), id: r.id, modes: r.modes ?? [] })), + }; +} + +/** Board rows only, no stop name: /v1/stoptimes has no top-level place field, and the model + * already knows the stop — it picked the id via search_stop. */ +export function selectBoard(raw: unknown): TransitousResult { + const board = (raw as RawStoptimesResponse | null) ?? {}; + return { + status: "ok", + departures: (board.stopTimes ?? []).slice(0, MAX_BOARD_ROWS).map((s) => ({ + line: clip(s.routeShortName ?? s.displayName, 16), + train: clip(s.tripShortName, 12), + headsign: clip(s.headsign), + time: s.place?.departure ?? null, + delayMin: minutesBetween(s.place?.departure, s.place?.scheduledDeparture), + track: clip(s.place?.track ?? s.place?.scheduledTrack, 8), + cancelled: !!s.cancelled, + // false = the operator publishes no live feed for this trip; the schedule is all we have. + realTime: !!s.realTime, + })), + }; +} + +export function selectJourneys(raw: unknown): TransitousResult { + const plan = (raw as RawPlanResponse | null) ?? {}; + return { + status: "ok", + itineraries: (plan.itineraries ?? []).slice(0, MAX_ITINERARIES).map((it) => { + const legs = (it.legs ?? []).slice(0, MAX_LEGS).map((l) => + l.mode === "WALK" + ? { walkMin: Math.round((l.duration ?? 0) / 60) } + : { + mode: l.mode ?? null, + line: clip(l.routeShortName ?? l.displayName, 16), + train: clip(l.tripShortName, 12), + headsign: clip(l.headsign), + agency: clip(l.agencyName, 30), + from: place(l.from), + to: place(l.to), + cancelled: !!l.cancelled, + realTime: !!l.realTime, + }, + ); + const rides = (it.legs ?? []).filter((l) => l.mode !== "WALK"); + return { + start: it.startTime ?? null, + end: it.endTime ?? null, + durationMin: Math.round((it.duration ?? 0) / 60), + transfers: it.transfers ?? Math.max(0, rides.length - 1), + // No live feed anywhere on the route -> the model must say "scheduled times". + realTime: rides.some((l) => l.realTime), + legs, + }; + }), + }; +} From 51c2ed617f010a0740189c961ce3efbbecdd823a Mon Sep 17 00:00:00 2001 From: fretchen Date: Thu, 15 Oct 2026 07:46:38 +0200 Subject: [PATCH 2/5] prettier --- website/components/AssistantChat.tsx | 5 +- website/locales/de.ts | 3 +- website/locales/en.ts | 3 +- website/test/AssistantChat.test.tsx | 4 +- website/test/fixtures/transitous/geocode.json | 40 +-- website/test/fixtures/transitous/plan.json | 307 +++--------------- .../test/fixtures/transitous/stoptimes.json | 75 +---- website/tools/transitous.ts | 11 +- 8 files changed, 74 insertions(+), 374 deletions(-) diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index be7f99662..44b2ac115 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -877,7 +877,10 @@ export function AssistantChat() { */ const argString = (v: unknown): string => (typeof v === "string" ? v : ""); - async function loadTransitous(kind: "geocode" | "stoptimes" | "plan", args: Record): Promise { + async function loadTransitous( + kind: "geocode" | "stoptimes" | "plan", + args: Record, + ): Promise { // An empty required argument is an answer the model can act on, not a malfunction: fetching // anyway would 4xx at the API, `fetch_failed` would withdraw the tool for the rest of the // turn, and with it the one thing that fixes the mistake — a corrected query, a real stop diff --git a/website/locales/de.ts b/website/locales/de.ts index da1d61866..04357b6a9 100644 --- a/website/locales/de.ts +++ b/website/locales/de.ts @@ -117,7 +117,8 @@ export default { // in ToolSelector kommt vom ersten Eintrag der Gruppe (siehe TOOL_REGISTRY), und das // ist dieser — sie beschreibt deshalb die ganze Fähigkeit und nennt die Verkehrsmittel, // weil das Zeilenlabel ("Fahrplan") bewusst gattungsneutral bleibt. - toolTransitSearchDesc: "Züge, Busse und Fähren in ganz Europa – Verbindungen und Abfahrten, mit Echtzeit, wo Betreiber sie liefern.", + toolTransitSearchDesc: + "Züge, Busse und Fähren in ganz Europa – Verbindungen und Abfahrten, mit Echtzeit, wo Betreiber sie liefern.", toolTransitPlanDesc: "Plant eine Verbindung von Tür zu Tür – Züge, Busse, Fähren.", toolTransitDeparturesDesc: "Die nächsten Abfahrten einer Haltestelle.", // Zeilentitel für die zusammengefasste Zeile in ToolSelector (get_sitzungen + diff --git a/website/locales/en.ts b/website/locales/en.ts index a5303751a..a55a24d34 100644 --- a/website/locales/en.ts +++ b/website/locales/en.ts @@ -114,7 +114,8 @@ export default { // The one actually shown: the grouped timetable row's description comes from the first // entry in that group (see TOOL_REGISTRY), so this describes the whole capability and // names the modes — the row label stays deliberately generic. - toolTransitSearchDesc: "Trains, buses and ferries across Europe — journeys and departures, with live times where operators publish them.", + toolTransitSearchDesc: + "Trains, buses and ferries across Europe — journeys and departures, with live times where operators publish them.", toolTransitPlanDesc: "Plans a door-to-door journey — trains, buses, ferries.", toolTransitDeparturesDesc: "The next departures from a stop.", capBundestag: "Bundestag", diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 5eb1beefc..205ba1839 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -906,9 +906,7 @@ describe("AssistantChat", () => { // (TOOL_REGISTRY's `group`, whose locale value is the row label), so one click // switches off all three wire names at once. fireEvent.click(screen.getAllByLabelText("Train & bus")[0]); - expect(window.localStorage.getItem("x402-chat-disabled-tools")).toBe( - "search_stop,plan_journey,get_departures", - ); + expect(window.localStorage.getItem("x402-chat-disabled-tools")).toBe("search_stop,plan_journey,get_departures"); sendUserMessage("Hi"); await waitFor(() => expect(mockSendMessage).toHaveBeenCalledOnce()); diff --git a/website/test/fixtures/transitous/geocode.json b/website/test/fixtures/transitous/geocode.json index c9b4bb187..5399171c2 100644 --- a/website/test/fixtures/transitous/geocode.json +++ b/website/test/fixtures/transitous/geocode.json @@ -155,10 +155,7 @@ } ], "score": -26.631120681762695, - "modes": [ - "TRAM", - "BUS" - ], + "modes": ["TRAM", "BUS"], "importance": 0.000045373457396635786 }, { @@ -306,16 +303,7 @@ } ], "score": -18.81806755065918, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "SUBWAY", - "TRAM", - "BUS" - ], + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "SUBWAY", "TRAM", "BUS"], "importance": 0.021757638081908226 }, { @@ -359,16 +347,7 @@ } ], "score": -18.813016891479492, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "COACH", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "SUBWAY", - "BUS" - ], + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "COACH", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "SUBWAY", "BUS"], "importance": 0.021419377997517586 }, { @@ -539,16 +518,7 @@ } ], "score": -18.104284286499023, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "COACH", - "NIGHT_RAIL", - "RIDE_SHARING", - "REGIONAL_RAIL", - "TRAM", - "BUS" - ], + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "COACH", "NIGHT_RAIL", "RIDE_SHARING", "REGIONAL_RAIL", "TRAM", "BUS"], "importance": 0.005886611994355917 } -] \ No newline at end of file +] diff --git a/website/test/fixtures/transitous/plan.json b/website/test/fixtures/transitous/plan.json index d109ec472..a66f0ca97 100644 --- a/website/test/fixtures/transitous/plan.json +++ b/website/test/fixtures/transitous/plan.json @@ -30,15 +30,7 @@ "level": 2, "tz": "Europe/Rome", "vertexType": "TRANSIT", - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "direct": [], "itineraries": [ @@ -167,11 +159,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "REGIONAL_RAIL", - "SUBURBAN", - "BUS" - ] + "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS"] }, "routeId": "de-DELFI_de:rlp:s3:_109", "routeUrl": "", @@ -211,12 +199,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "REGIONAL_RAIL", - "SUBURBAN", - "BUS", - "ODM" - ] + "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"] }, { "name": "MA-Friedrichsfeld, Südbahnhof", @@ -238,11 +221,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "SUBURBAN", - "BUS", - "ODM" - ] + "modes": ["SUBURBAN", "BUS", "ODM"] } ], "legGeometry": { @@ -374,13 +353,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "duration": 12180, "startTime": "2026-10-15T05:37:00Z", @@ -406,15 +379,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "tripTo": { "name": "Basel SBB", @@ -431,15 +396,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "routeId": "de-DELFI_162290_101", "routeUrl": "", @@ -509,13 +466,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "BUS"] } ], "legGeometry": { @@ -544,13 +495,7 @@ "track": "10", "vertexType": "TRANSIT", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "to": { "name": "ZUERICH HB", @@ -564,13 +509,7 @@ "arrival": "2026-10-15T09:05:00Z", "scheduledArrival": "2026-10-15T09:05:00Z", "vertexType": "TRANSIT", - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "duration": 300, "startTime": "2026-10-15T09:00:00Z", @@ -603,13 +542,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "to": { "name": "MILANO CENTRALE", @@ -626,15 +559,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "duration": 11820, "startTime": "2026-10-15T09:33:00Z", @@ -660,13 +585,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "tripTo": { "name": "MILANO CENTRALE", @@ -683,15 +602,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "routeId": "it-trenitalia_60083_7cf9622d2ca9dab7", "routeUrl": "", @@ -728,10 +639,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "REGIONAL_RAIL" - ] + "modes": ["LONG_DISTANCE", "REGIONAL_RAIL"] }, { "name": "ARTH-GOLDAU", @@ -750,14 +658,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "BUS", - "FUNICULAR" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "BUS", "FUNICULAR"] } ], "legGeometry": { @@ -896,11 +797,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "REGIONAL_RAIL", - "SUBURBAN", - "BUS" - ] + "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS"] }, "routeId": "de-DELFI_de:rlp:s3:_109", "routeUrl": "", @@ -940,12 +837,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "REGIONAL_RAIL", - "SUBURBAN", - "BUS", - "ODM" - ] + "modes": ["REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"] }, { "name": "MA-Friedrichsfeld, Südbahnhof", @@ -967,11 +859,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "SUBURBAN", - "BUS", - "ODM" - ] + "modes": ["SUBURBAN", "BUS", "ODM"] } ], "legGeometry": { @@ -1103,13 +991,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "duration": 12180, "startTime": "2026-10-15T05:37:00Z", @@ -1135,15 +1017,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "tripTo": { "name": "Basel SBB", @@ -1160,15 +1034,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "routeId": "de-DELFI_162290_101", "routeUrl": "", @@ -1238,13 +1104,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "BUS"] } ], "legGeometry": { @@ -1273,13 +1133,7 @@ "track": "10", "vertexType": "TRANSIT", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "to": { "name": "Zürich HB", @@ -1295,13 +1149,7 @@ "scheduledTrack": "8", "track": "8", "vertexType": "TRANSIT", - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "duration": 300, "startTime": "2026-10-15T09:00:00Z", @@ -1336,13 +1184,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "to": { "name": "Lugano", @@ -1361,12 +1203,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "duration": 6780, "startTime": "2026-10-15T09:05:00Z", @@ -1394,13 +1231,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "tripTo": { "name": "Lugano", @@ -1419,12 +1250,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "routeId": "ch-opentransportdataswiss26_91-2-G-j26-1", "routeUrl": "", @@ -1461,11 +1287,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "REGIONAL_RAIL", "SUBURBAN"] }, { "name": "Arth-Goldau", @@ -1486,14 +1308,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "BUS", - "FUNICULAR" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "BUS", "FUNICULAR"] } ], "legGeometry": { @@ -1524,12 +1339,7 @@ "track": "2", "vertexType": "TRANSIT", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "to": { "name": "Lugano", @@ -1545,12 +1355,7 @@ "scheduledTrack": "1", "track": "1", "vertexType": "TRANSIT", - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "duration": 240, "startTime": "2026-10-15T10:58:00Z", @@ -1585,12 +1390,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN"] }, "to": { "name": "Milano Centrale", @@ -1607,15 +1407,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "duration": 4500, "startTime": "2026-10-15T11:02:00Z", @@ -1643,11 +1435,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "REGIONAL_RAIL", "SUBURBAN"] }, "tripTo": { "name": "Milano Centrale", @@ -1664,15 +1452,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "LONG_DISTANCE", - "NIGHT_RAIL", - "REGIONAL_RAIL", - "SUBURBAN", - "TRAM", - "BUS" - ] + "modes": ["HIGHSPEED_RAIL", "LONG_DISTANCE", "NIGHT_RAIL", "REGIONAL_RAIL", "SUBURBAN", "TRAM", "BUS"] }, "routeId": "ch-opentransportdataswiss26_91-80-j26-1", "routeUrl": "", @@ -1709,10 +1489,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["REGIONAL_RAIL", "SUBURBAN"] }, { "name": "Mendrisio", @@ -1733,11 +1510,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "LONG_DISTANCE", - "REGIONAL_RAIL", - "SUBURBAN" - ] + "modes": ["LONG_DISTANCE", "REGIONAL_RAIL", "SUBURBAN"] } ], "legGeometry": { @@ -1754,4 +1527,4 @@ ], "previousPageCursor": "EARLIER|1792038420", "nextPageCursor": "LATER|1792045500" -} \ No newline at end of file +} diff --git a/website/test/fixtures/transitous/stoptimes.json b/website/test/fixtures/transitous/stoptimes.json index 474b7d95b..36cfe919b 100644 --- a/website/test/fixtures/transitous/stoptimes.json +++ b/website/test/fixtures/transitous/stoptimes.json @@ -50,10 +50,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "TRAM", - "BUS" - ] + "modes": ["TRAM", "BUS"] }, "tripTo": { "name": "Viernheim, Bahnhof", @@ -70,11 +67,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "TRAM", - "BUS", - "ODM" - ] + "modes": ["TRAM", "BUS", "ODM"] }, "agencyId": "7911", "agencyName": "RNV HD-MA-Weinheim (Strab)", @@ -145,9 +138,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "BUS" - ] + "modes": ["BUS"] }, "tripTo": { "name": "Heidelberg, Universitätsplatz", @@ -161,9 +152,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "BUS" - ] + "modes": ["BUS"] }, "agencyId": "7915", "agencyName": "RNV Heidelberg (Bus)", @@ -237,9 +226,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "BUS" - ] + "modes": ["BUS"] }, "tripTo": { "name": "Neuenheim, Schwimmbad", @@ -256,9 +243,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "BUS" - ] + "modes": ["BUS"] }, "agencyId": "7915", "agencyName": "RNV Heidelberg (Bus)", @@ -332,12 +317,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "RIDE_SHARING", - "TRAM", - "BUS", - "ODM" - ] + "modes": ["RIDE_SHARING", "TRAM", "BUS", "ODM"] }, "tripTo": { "name": "Weinheim, Alter OEG-Bahnhof", @@ -354,12 +334,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "RIDE_SHARING", - "TRAM", - "BUS", - "ODM" - ] + "modes": ["RIDE_SHARING", "TRAM", "BUS", "ODM"] }, "agencyId": "7911", "agencyName": "RNV HD-MA-Weinheim (Strab)", @@ -458,10 +433,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "BUS", - "ODM" - ] + "modes": ["BUS", "ODM"] }, "agencyId": "7927", "agencyName": "Busverkehr-Rhein-Neckar", @@ -537,14 +509,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "COACH", - "REGIONAL_RAIL", - "SUBURBAN", - "BUS", - "ODM" - ] + "modes": ["HIGHSPEED_RAIL", "COACH", "REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"] }, "tripTo": { "name": "Karlsruhe Hauptbahnhof", @@ -646,9 +611,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "TRAM" - ] + "modes": ["TRAM"] }, "tripTo": { "name": "Rohrbach, Süd", @@ -665,10 +628,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "TRAM", - "BUS" - ] + "modes": ["TRAM", "BUS"] }, "agencyId": "7910", "agencyName": "RNV Heidelberg (Strab)", @@ -772,14 +732,7 @@ "pickupType": "NORMAL", "dropoffType": "NORMAL", "cancelled": false, - "modes": [ - "HIGHSPEED_RAIL", - "COACH", - "REGIONAL_RAIL", - "SUBURBAN", - "BUS", - "ODM" - ] + "modes": ["HIGHSPEED_RAIL", "COACH", "REGIONAL_RAIL", "SUBURBAN", "BUS", "ODM"] }, "agencyId": "10449", "agencyName": "DB Regio AG Mitte Region Südwest", @@ -827,4 +780,4 @@ }, "previousPageCursor": "EARLIER|1792038360", "nextPageCursor": "LATER|1792038840" -} \ No newline at end of file +} diff --git a/website/tools/transitous.ts b/website/tools/transitous.ts index 696abb820..dd75cdd22 100644 --- a/website/tools/transitous.ts +++ b/website/tools/transitous.ts @@ -184,7 +184,10 @@ export { fetchFailed } from "./failure"; // --- Fetchers: plain fetch, no cache, throw on failure --------------------------------------- export class TransitousError extends Error { - constructor(message: string, readonly status: number | null = null) { + constructor( + message: string, + readonly status: number | null = null, + ) { super(message); this.name = "TransitousError"; } @@ -198,8 +201,7 @@ async function get(path: string, params: Record): return res.json() as Promise; } -export const fetchGeocode = (query: string): Promise => - get("/v1/geocode", { text: query }); +export const fetchGeocode = (query: string): Promise => get("/v1/geocode", { text: query }); export const fetchStoptimes = (stopId: string, n = MAX_BOARD_ROWS, time?: string): Promise => get("/v1/stoptimes", { stopId, n: String(n), time }); @@ -222,8 +224,7 @@ export const fetchPlan = (args: { // --- Selectors: pure, synchronous, operate on already-parsed JSON --------------------------- -const minutesBetween = (a?: string, b?: string) => - a && b ? Math.round((Date.parse(a) - Date.parse(b)) / 60000) : 0; +const minutesBetween = (a?: string, b?: string) => (a && b ? Math.round((Date.parse(a) - Date.parse(b)) / 60000) : 0); const clip = (s: unknown, n = MAX_NAME_CHARS): string | null => { if (typeof s !== "string" || !s) return null; From 218d82aaf09db15afe9bc082636e1a74b2c8dac4 Mon Sep 17 00:00:00 2001 From: fretchen Date: Fri, 16 Oct 2026 18:05:32 +0200 Subject: [PATCH 3/5] first round of changes --- website/components/AssistantChat.tsx | 22 +++- website/hooks/useX402Chat.ts | 55 ++++++++-- website/hooks/x402Currency.ts | 44 ++++++++ website/locales/de.ts | 4 + website/locales/en.ts | 4 + website/test/AssistantChat.test.tsx | 39 ++++++- website/test/failure.test.ts | 3 + website/test/useX402Chat.test.ts | 149 +++++++++++++++++++++++++++ website/test/x402Currency.test.ts | 56 ++++++++++ website/test/x402PaidFetch.test.ts | 49 +++++++++ website/utils/x402PaidFetch.ts | 61 ++++++++++- 11 files changed, 472 insertions(+), 14 deletions(-) diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 44b2ac115..3e0b360e5 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -554,6 +554,10 @@ export function AssistantChat() { const networkFallbackLabel = useLocale({ label: "assistent.networkFallback" }); const eurcBaseOnlyLabel = useLocale({ label: "payment.eurcBaseOnly" }); const currencyFallbackLabel = useLocale({ label: "payment.currencyFallback" }); + // The wallet-side note: guarded-B fell back to a token the wallet can fund. Named apart from + // `payment.currencyFallback` (the agent-side note below) because the reasons differ — "the + // agent doesn't take it" and "your wallet holds none of it" must not share a sentence. + const walletFallbackLabel = useLocale({ label: "assistent.walletFallback" }); // Mobile detection React.useEffect(() => { @@ -704,6 +708,7 @@ export function AssistantChat() { paymentReceipt, paymentNetwork, paymentCurrency, + currencyFallback, status: chatStatus, } = useX402Chat(desiredNetwork, agentUrl, currency); const { network, switchIfNeeded, getSwitchError } = useAutoNetwork([paymentNetwork]); @@ -1413,8 +1418,10 @@ export function AssistantChat() {

)} {/* Separately from the network fallback above: the agent may accept the negotiated - network but only in the other currency (e.g. USDC-only on Base). */} - {paymentCurrency !== currency && ( + network but only in the other currency (e.g. USDC-only on Base). Suppressed when + the wallet note is active — then the deviation is the wallet's, not the agent's, + and the wrong attribution would be worse than silence. */} + {paymentCurrency !== currency && !currencyFallback && (

{currencyFallbackLabel} {paymentCurrency}.

@@ -1599,6 +1606,17 @@ export function AssistantChat() { )) )} + {currencyFallback && ( + // One honest line when the send switched token: the deviation is announced, the + // stored preference stays untouched (the hook never persists a fallback), and + // the line quietly teaches where the currency setting lives. +
+ {walletFallbackLabel + .replaceAll("{used}", currencyFallback.used) + .replaceAll("{preferred}", currencyFallback.preferred)} +
+ )} + {isLoading && (
{isDefaultAgent &&
} diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index 542a19b5f..a09f5a2e0 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -17,8 +17,8 @@ import { useWalletClient } from "wagmi"; import { getConfiguredPublicClient } from "./useConfiguredPublicClient"; import { useIsWalletConnected } from "./useIsWalletConnected"; import { probeAccepts, negotiateNetwork, LLM_V1_FLOOR } from "./x402Discovery"; -import { DEFAULT_CURRENCY, offeredCurrency, type PaymentCurrency } from "./x402Currency"; -import { createPaidFetch, type PaidFetch } from "../utils/x402PaidFetch"; +import { DEFAULT_CURRENCY, offeredCurrency, offeredCurrencies, selectFundedCurrency, type PaymentCurrency } from "./x402Currency"; +import { createPaidFetch, readFundableTokens, type PaidFetch } from "../utils/x402PaidFetch"; import type { X402ChatMessage, X402ChatResponse, @@ -68,6 +68,14 @@ export interface UseX402ChatResult { * `currency` until the agent has been probed. */ paymentCurrency: PaymentCurrency; + /** + * When the token this send would have paid in could not fund its deposit and another + * token the wallet holds and the agent offers was used instead: `{ preferred, used }`, + * per-payment and never persisted (the stored preference stays the user's alone). Null + * on every send that paid in the negotiated currency. The component announces it; the + * deposit signature was the consent gate. + */ + currencyFallback: { preferred: PaymentCurrency; used: PaymentCurrency } | null; } /** @@ -101,6 +109,11 @@ export function useX402Chat( network: string; paid: PaymentCurrency; } | null>(null); + // The guarded-B fallback of the most recent send, or null — set per send, never persisted. + const [currencyFallback, setCurrencyFallback] = useState<{ + preferred: PaymentCurrency; + used: PaymentCurrency; + } | null>(null); const isCurrent = negotiated?.agentUrl === agentUrl && negotiated?.preferred === network && negotiated?.currency === currency; const paymentNetwork = isCurrent ? negotiated.network : network; @@ -155,10 +168,6 @@ export function useX402Chat( // that — proceed on the preferred network and let the real 402 be the judge. const payNetwork = resolved ?? network; const paid = offeredCurrency(accepts, payNetwork, currency) ?? currency; - setNegotiated({ agentUrl, preferred: network, currency, network: payNetwork, paid }); - // Synchronously, unlike the state above: this turn's tool calls read it during the very - // loop this send belongs to, long before a re-render could deliver the state. - payRef.current = { network: payNetwork, currency }; // A readContract-capable client is required: batch-settlement's corrective-402 // recovery reads channel state on-chain, unlike the exact scheme. Resolved here, not @@ -168,6 +177,27 @@ export function useX402Chat( throw new Error(`No public client for network ${payNetwork}`); } + // Guarded-B currency fallback (selectFundedCurrency in x402Currency.ts): when the + // token this send would pay in cannot fund its deposit floor, pay in a token the + // wallet actually holds and the agent actually offers. `paid` is passed, not the raw + // preference, so the seller-side negotiation runs first — a fallback can only replace + // a token that could not pay anyway, and never chooses among funded options. The + // switch is never persisted, the deposit signature stays the consent gate, and the + // component announces the deviation via `currencyFallback`. + const candidates = offeredCurrencies(accepts, payNetwork); + const fundable = + candidates.length > 0 + ? await readFundableTokens(walletClient.account.address, publicClient, payNetwork) + : () => false; + const effective = selectFundedCurrency(paid, candidates, fundable); + setCurrencyFallback(effective.fellBack ? { preferred: paid, used: effective.currency } : null); + setNegotiated({ agentUrl, preferred: network, currency, network: payNetwork, paid: effective.currency }); + // Synchronously, unlike the state above: this turn's tool calls read it during the very + // loop this send belongs to, long before a re-render could deliver the state. The + // effective currency, not the preference — the tools must spend on the channel this + // turn actually opened. + payRef.current = { network: payNetwork, currency: effective.currency }; + setStatus("awaiting-signature"); setError(null); // Don't clear paymentReceipt here: it represents the currently-open channel's @@ -269,7 +299,18 @@ export function useX402Chat( setPaymentReceipt(null); }, []); - return { sendMessage, paidFetch, status, error, paymentReceipt, reset, isReady, paymentNetwork, paymentCurrency }; + return { + sendMessage, + paidFetch, + status, + error, + paymentReceipt, + reset, + isReady, + paymentNetwork, + paymentCurrency, + currencyFallback, + }; } export default useX402Chat; diff --git a/website/hooks/x402Currency.ts b/website/hooks/x402Currency.ts index 8b603f11d..f44738730 100644 --- a/website/hooks/x402Currency.ts +++ b/website/hooks/x402Currency.ts @@ -108,3 +108,47 @@ export function offeredCurrency( const first = onNetwork[0]; return first ? currencyOf(first) : null; } + +/** + * The settlement currencies the agent offers on `network`, in `PAYMENT_CURRENCIES` order — + * the candidate list for `selectFundedCurrency`. A fallback is only ever chosen from what + * the seller actually takes, so a funded token it does not offer is never picked. Empty for + * an unreadable offer (`accepts === null`): with no candidates the fallback cannot fire, + * and the send proceeds exactly as before. + */ +export function offeredCurrencies(accepts: AcceptsEntry[] | null, network: string): PaymentCurrency[] { + if (!accepts) return []; + const onNetwork = accepts.filter((entry) => entry.network === network); + return PAYMENT_CURRENCIES.filter((currency) => onNetwork.some((entry) => currencyOf(entry) === currency)); +} + +/** + * The guarded-B currency fallback: pay in a token the wallet can actually fund, when the + * token the send would pay in cannot cover its deposit floor. + * + * Pure on purpose — the caller gathers the facts, this only decides: + * - `paid` is what the send would pay in after seller-side negotiation (`offeredCurrency`), + * not the raw user preference. + * - `candidates` are the tokens the seller offers on the pay network, in + * `PAYMENT_CURRENCIES` order (euro-first), so among two funded alternatives EURC beats USDC. + * - `fundable` answers "can the wallet balance cover this token's deposit floor". + * + * The rules, each of which is a guard agreed in the plan: the paid currency always wins when + * it can fund the deposit; a fallback is only ever chosen from what the seller actually offers + * (a fundable preference the seller does not take is unusable by construction); and when + * nothing is fundable the paid currency stands unchanged — the send then fails with a message + * that is honest exactly because this fallback ran first ("recharge" is only correct advice + * when no held token could fund it). A fallback never persists: the stored preference is the + * user's alone, and the deposit signature remains the consent gate for the new token. + */ +export function selectFundedCurrency( + paid: PaymentCurrency, + candidates: readonly PaymentCurrency[], + fundable: (currency: PaymentCurrency) => boolean, +): { currency: PaymentCurrency; fellBack: boolean } { + if (candidates.includes(paid) && fundable(paid)) { + return { currency: paid, fellBack: false }; + } + const funded = candidates.find((candidate) => fundable(candidate)); + return funded ? { currency: funded, fellBack: true } : { currency: paid, fellBack: false }; +} diff --git a/website/locales/de.ts b/website/locales/de.ts index 04357b6a9..f5cca6918 100644 --- a/website/locales/de.ts +++ b/website/locales/de.ts @@ -178,6 +178,10 @@ export default { analyticsSource: "Quelle: Analytics", braveSource: "Quelle: Brave Search", transitousSource: "Quelle: Transitous", + // Guarded-B: wenn die bevorzugte Währung die Einzahlung nicht decken kann, zahlt der Chat + // automatisch mit einer, die die Wallet hält — und sagt es hier, statt zu behaupten, man + // müsse erst EURe kaufen. Die Wallet-Signatur bleibt der Moment der Zustimmung. + walletFallback: "Mit {used} gezahlt — deine Wallet hält kein {preferred}. Umstellen unter Erweitert.", errorPrefix: "Fehler:", unknownError: "Unbekannter Fehler", loading: "Lädt...", diff --git a/website/locales/en.ts b/website/locales/en.ts index a55a24d34..726ff43eb 100644 --- a/website/locales/en.ts +++ b/website/locales/en.ts @@ -169,6 +169,10 @@ export default { analyticsSource: "Source: Analytics", braveSource: "Source: Brave Search", transitousSource: "Source: Transitous", + // Guarded-B: when the preferred currency cannot fund the deposit, the chat pays with one + // the wallet holds — and says so here, instead of claiming you need to buy EURe first. + // The wallet signature remains the moment of consent. + walletFallback: "Paid with {used} — your wallet holds no {preferred}. Switch under Advanced.", errorPrefix: "Error:", unknownError: "Unknown error", loading: "Loading...", diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 205ba1839..7bc5d8edc 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -58,6 +58,7 @@ vi.mock("../hooks/useX402Chat", () => ({ isReady: true, paymentNetwork: "eip155:10", paymentCurrency: "USDC", + currencyFallback: null, })), DEFAULT_LLM_AGENT_URL: "https://llm-agent.fretchen.eu", })); @@ -280,6 +281,7 @@ describe("AssistantChat", () => { isReady: true, paymentNetwork: "eip155:10", paymentCurrency: "USDC", + currencyFallback: null, }); vi.mocked(useWalletConnection).mockReturnValue({ address: "0x1234567890123456789012345678901234567890", @@ -410,6 +412,7 @@ describe("AssistantChat", () => { isReady: true, paymentNetwork: "eip155:10", paymentCurrency: "USDC", + currencyFallback: null, }); mockSendMessage.mockImplementation(() => new Promise(() => {})); // never resolves: stay loading @@ -494,6 +497,7 @@ describe("AssistantChat", () => { isReady: true, paymentNetwork: "eip155:8453", paymentCurrency: "EURC", + currencyFallback: null, }); renderWithQuery(); @@ -571,6 +575,7 @@ describe("AssistantChat", () => { isReady: true, paymentNetwork: "eip155:8453", paymentCurrency: "EURC", + currencyFallback: null, }); mockSendMessage.mockRejectedValueOnce( new PaymentError(402, JSON.stringify({ error: "insufficient_balance" }), "EURC"), @@ -918,6 +923,34 @@ describe("AssistantChat", () => { expect(names).not.toContain("get_departures"); }); + it("announces a wallet-driven currency switch, and does not blame the agent for it", () => { + const { unmount } = renderWithQuery(); + expect(screen.queryByText(/assistent\.walletFallback/)).not.toBeInTheDocument(); + unmount(); + + // EURe preferred, the wallet could only fund USDC — the hook fell back and reports it. + // paymentCurrency differs from the preference too, which alone would trigger the + // agent-side note; the wallet note must supersede it, or the UI would attribute the + // switch to the agent when it was the wallet. + vi.mocked(useX402Chat).mockReturnValue({ + sendMessage: mockSendMessage, + paidFetch: mockPaidFetch, + status: "idle", + error: null, + paymentReceipt: null, + reset: vi.fn(), + isReady: true, + paymentNetwork: "eip155:10", + paymentCurrency: "USDC", + currencyFallback: { preferred: "EURe", used: "USDC" }, + }); + + renderWithQuery(); + + expect(screen.getByText(/assistent\.walletFallback/)).toBeInTheDocument(); + expect(screen.queryByText(/payment\.currencyFallback/)).not.toBeInTheDocument(); + }); + it("keeps the image tool on offer when a Bundestakt lookup fails", async () => { // The regression this guards: a single shared `toolFailed` flag would have disabled // generate_image for the rest of the turn because an unrelated, free lookup failed. @@ -1532,7 +1565,8 @@ describe("AssistantChat", () => { isReady: true, paymentNetwork: "eip155:8453", paymentCurrency: "USDC", - }); + currencyFallback: null, + }); renderWithQuery(); @@ -1555,7 +1589,8 @@ describe("AssistantChat", () => { isReady: true, paymentNetwork: "eip155:8453", paymentCurrency: "USDC", - }); + currencyFallback: null, + }); renderWithQuery(); diff --git a/website/test/failure.test.ts b/website/test/failure.test.ts index 10483d9e4..40e5cf2c2 100644 --- a/website/test/failure.test.ts +++ b/website/test/failure.test.ts @@ -20,6 +20,9 @@ describe("paymentFailed", () => { expect(result?.status).toBe("payment_failed"); expect(result?.reason).toMatch(/USDC/i); + // The model's advice must point at the in-chat switch, not at buying the missing token — + // the guarded-B fallback makes this error a race-era residue, and the switch is the fix. + expect(result?.reason).toMatch(/switch the payment currency in the assistant's advanced settings/i); }); /** diff --git a/website/test/useX402Chat.test.ts b/website/test/useX402Chat.test.ts index 699205531..4b089b60c 100644 --- a/website/test/useX402Chat.test.ts +++ b/website/test/useX402Chat.test.ts @@ -58,6 +58,16 @@ vi.mock("@x402/evm/batch-settlement/client", () => ({ mockReadChannelBalanceAndTotalClaimed(signer, channelId), })); +// The funding-facts reader is unit-tested in x402PaidFetch.test.ts; here its answer is +// injected, so these tests cover exactly the wiring: candidates, selection, announcement +// state, and what reaches createPaidFetch. vi.hoisted because the factory below spreads +// this in immediately — a plain const is still uninitialised when the hoisted vi.mock runs. +const mockReadFundableTokens = vi.hoisted(() => vi.fn()); +vi.mock("../utils/x402PaidFetch", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readFundableTokens: mockReadFundableTokens }; +}); + const NETWORK = "eip155:84532"; const mockWalletClient = { account: { address: "0x1234567890123456789012345678901234567890" }, @@ -71,6 +81,9 @@ describe("useX402Chat", () => { // Every case here probes the same agent URL; a cached hit would serve the previous case's // accepts[] and silently defeat the per-case fetch mocks. resetAcceptsCache(); + // The default: everything fundable, so the paid currency always wins and the existing + // cases see today's behavior. Tests that need a fallback override this themselves. + mockReadFundableTokens.mockResolvedValue(() => true); mockGetPaymentSettleResponse.mockReturnValue({ success: true, transaction: "0xdeposit", @@ -952,6 +965,142 @@ describe("useX402Chat", () => { afterEach(() => { vi.unstubAllGlobals(); }); + + describe("guarded-B currency fallback", () => { + // LLM_V1_FLOOR is mainnet-only, and mainnet Base is the one network carrying all three + // settlement tokens — so this describe negotiates on eip155:8453, not the Sepolia NETWORK + // the rest of the file uses. + const PAY_BASE = "eip155:8453"; + // Base Sepolia's registered settlement tokens (chain-utils' own addresses) — the asset + // field is what maps an accepts entry to a currency the site can pay with. + const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C"; + const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; + const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + + const isProbe = (init?: RequestInit) => { + try { + return (JSON.parse(init?.body as string) as { model?: string }).model === "probe"; + } catch { + return false; + } + }; + + /** An agent that 402s the probe with all three tokens offered on NETWORK, then succeeds. */ + function stubAgentOfferingAllTokens() { + const accepts = [ + { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURE }, + { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURC }, + { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_USDC }, + ]; + const header = btoa(JSON.stringify({ accepts })); + vi.stubGlobal( + "fetch", + vi.fn((_input: string, init?: RequestInit) => + Promise.resolve( + isProbe(init) + ? new Response("{}", { status: 402, headers: { "Payment-Required": header } }) + : new Response(JSON.stringify({ content: "hi" }), { status: 200 }), + ), + ), + ); + } + + beforeEach(() => { + vi.mocked(useWalletClient).mockReturnValue(buildWalletClientData({ data: mockWalletClient })); + vi.mocked(useAccount).mockReturnValue(buildAccountData({ isConnected: true })); + }); + + it("keeps the paid currency and reports no fallback when it can fund the deposit", async () => { + stubAgentOfferingAllTokens(); + + const { result } = renderHook(() => useX402Chat(PAY_BASE)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(result.current.paymentCurrency).toBe("EURe"); + expect(result.current.currencyFallback).toBeNull(); + }); + + it("falls back to the first funded candidate and reports the deviation", async () => { + stubAgentOfferingAllTokens(); + // EURe (the default and the paid currency) is unfunded; both EURC and USDC are funded, + // so the registry order picks EURC — the euro-first candidate. + mockReadFundableTokens.mockResolvedValue((c: string) => c === "EURC" || c === "USDC"); + + const { result } = renderHook(() => useX402Chat(PAY_BASE)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(result.current.paymentCurrency).toBe("EURC"); + expect(result.current.currencyFallback).toEqual({ preferred: "EURe", used: "EURC" }); + }); + + it("keeps today's behavior when nothing is fundable — the failure wording is then honest advice", async () => { + stubAgentOfferingAllTokens(); + mockReadFundableTokens.mockResolvedValue(() => false); + + const { result } = renderHook(() => useX402Chat(PAY_BASE)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(result.current.paymentCurrency).toBe("EURe"); + expect(result.current.currencyFallback).toBeNull(); + }); + + it("never reads balances when the agent offer is unreadable — unknown must not switch tokens", async () => { + // No 402 on the probe: accepts is null, candidates are empty, and the read is skipped. + vi.stubGlobal( + "fetch", + vi.fn((_input: string, init?: RequestInit) => + Promise.resolve( + isProbe(init) + ? new Response("{}", { status: 200 }) + : new Response(JSON.stringify({ content: "hi" }), { status: 200 }), + ), + ), + ); + + const { result } = renderHook(() => useX402Chat(PAY_BASE)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(mockReadFundableTokens).not.toHaveBeenCalled(); + expect(result.current.paymentCurrency).toBe("EURe"); + expect(result.current.currencyFallback).toBeNull(); + }); + + it("does not fall back to a token the agent does not offer, even when it is the only funded one", async () => { + // The agent offers EURe and USDC only; EURC is funded but unofferable. + const accepts = [ + { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_EURE }, + { scheme: "batch-settlement", network: PAY_BASE, payTo: "0xabc", asset: BASE_USDC }, + ]; + const header = btoa(JSON.stringify({ accepts })); + vi.stubGlobal( + "fetch", + vi.fn((_input: string, init?: RequestInit) => + Promise.resolve( + isProbe(init) + ? new Response("{}", { status: 402, headers: { "Payment-Required": header } }) + : new Response(JSON.stringify({ content: "hi" }), { status: 200 }), + ), + ), + ); + mockReadFundableTokens.mockResolvedValue((c: string) => c === "EURC"); + + const { result } = renderHook(() => useX402Chat(PAY_BASE)); + await act(async () => { + await result.current.sendMessage([{ role: "user", content: "Hi" }]); + }); + + expect(result.current.paymentCurrency).toBe("EURe"); + expect(result.current.currencyFallback).toBeNull(); + }); + }); }); describe("WebStorageClientChannelStorage", () => { diff --git a/website/test/x402Currency.test.ts b/website/test/x402Currency.test.ts index c86aaae6b..ca0ff8a56 100644 --- a/website/test/x402Currency.test.ts +++ b/website/test/x402Currency.test.ts @@ -9,9 +9,12 @@ import { DEFAULT_CURRENCY, PAYMENT_CURRENCIES, networksForCurrency, + offeredCurrencies, preferCurrency, readCurrency, + selectFundedCurrency, storeCurrency, + type PaymentCurrency, } from "../hooks/x402Currency"; const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C" as const; @@ -97,3 +100,56 @@ describe("preferCurrency selector", () => { expect(paid).toBe("EURe"); }); }); + +describe("selectFundedCurrency", () => { + /** `fundable` answers "can this token's wallet balance cover the deposit floor". */ + const funded = (...held: PaymentCurrency[]) => (c: PaymentCurrency) => held.includes(c); + const all = ["EURe", "EURC", "USDC"] as const; + + it("keeps the paid currency when it can fund the deposit", () => { + expect(selectFundedCurrency("EURe", all, () => true)).toEqual({ currency: "EURe", fellBack: false }); + }); + + it("falls back to the first funded candidate in preference order — EURC before USDC", () => { + expect(selectFundedCurrency("EURe", all, funded("EURC", "USDC"))).toEqual({ currency: "EURC", fellBack: true }); + }); + + it("skips an unfunded candidate to the next funded one", () => { + expect(selectFundedCurrency("EURe", all, funded("USDC"))).toEqual({ currency: "USDC", fellBack: true }); + }); + + it("keeps today's behavior when nothing is fundable — the failure wording is then honest advice", () => { + expect(selectFundedCurrency("EURe", all, () => false)).toEqual({ currency: "EURe", fellBack: false }); + }); + + it("keeps the paid currency when no candidates are offered", () => { + expect(selectFundedCurrency("USDC", [], () => true)).toEqual({ currency: "USDC", fellBack: false }); + }); + + it("never picks a currency the seller does not offer, even when its balance would fund it", () => { + expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({ currency: "USDC", fellBack: true }); + }); +}); + +describe("offeredCurrencies", () => { + it("lists the seller's offered tokens on the network, in preference order", () => { + const accepts = [ + accept("eip155:8453", BASE_USDC), + accept("eip155:8453", BASE_EURE), + accept("eip155:8453", BASE_EURC), + ]; + expect(offeredCurrencies(accepts, "eip155:8453")).toEqual(["EURe", "EURC", "USDC"]); + }); + + it("ignores offers on other networks and tokens this site does not pay with", () => { + const accepts = [ + accept("eip155:10", BASE_USDC), + accept("eip155:8453", "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85"), // not a settlement token + ]; + expect(offeredCurrencies(accepts, "eip155:8453")).toEqual([]); + }); + + it("is empty for an unreadable offer — no candidates, no fallback", () => { + expect(offeredCurrencies(null, "eip155:8453")).toEqual([]); + }); +}); diff --git a/website/test/x402PaidFetch.test.ts b/website/test/x402PaidFetch.test.ts index 40031c472..bad777f38 100644 --- a/website/test/x402PaidFetch.test.ts +++ b/website/test/x402PaidFetch.test.ts @@ -45,6 +45,7 @@ import { PaymentError, WebStorageClientChannelStorage, readChargedTotal, + readFundableTokens, } from "../utils/x402PaidFetch"; import { formatSpend } from "../utils/x402Prices"; @@ -355,3 +356,51 @@ describe("formatSpend", () => { expect(formatSpend(1_000n, "EURC")).toBe("€0.001 EURC"); }); }); + +describe("readFundableTokens", () => { + // Base carries all three settlement tokens; the addresses are chain-utils' own. + const BASE_EURE = "0xbf6e2966A9C3D99C9E4D069E04f7Bdb9C8aa762C"; + const BASE_EURC = "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"; + const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + const wallet = "0x1234567890123456789012345678901234567890" as `0x${string}`; + + /** A public client that answers `balanceOf` from a per-address table. */ + function clientWith(balances: Record) { + return { + readContract: vi.fn(async ({ address }: { address: string }) => { + const balance = balances[address]; + if (balance === "boom") throw new Error("RPC down"); + return balance; + }), + }; + } + + it("funds only the tokens whose wallet balance covers the deposit floor", async () => { + // 0 EURe, 1.00 EURC (>= 0.50), 0.10 USDC (< 0.50). + const client = clientWith({ + [BASE_EURE]: 0n, + [BASE_EURC]: 1_000_000n, + [BASE_USDC]: 100_000n, + }); + + const fundable = await readFundableTokens(wallet, client, "eip155:8453"); + + expect(fundable("EURe")).toBe(false); + expect(fundable("EURC")).toBe(true); + expect(fundable("USDC")).toBe(false); + }); + + it("treats a failing read as unfunded — unknown must not trigger a fallback", async () => { + const client = clientWith({ + [BASE_EURE]: "boom", + [BASE_EURC]: "boom", + [BASE_USDC]: "boom", + }); + + const fundable = await readFundableTokens(wallet, client, "eip155:8453"); + + expect(fundable("EURe")).toBe(false); + expect(fundable("EURC")).toBe(false); + expect(fundable("USDC")).toBe(false); + }); +}); diff --git a/website/utils/x402PaidFetch.ts b/website/utils/x402PaidFetch.ts index 739791c3d..0fe69c531 100644 --- a/website/utils/x402PaidFetch.ts +++ b/website/utils/x402PaidFetch.ts @@ -1,4 +1,5 @@ import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { getSettlementTokens } from "@fretchen/chain-utils"; import { buildStablecoinAllowedAssets } from "../hooks/x402SpendControls"; import { CURRENCY_DECIMALS, preferCurrency, type PaymentCurrency } from "../hooks/x402Currency"; // Type-only import — erased at compile time, so no @x402 runtime is pulled into SSR. @@ -215,6 +216,53 @@ export function minimumDepositAtomic(currency: PaymentCurrency): bigint { return 5n * 10n ** BigInt(CURRENCY_DECIMALS[currency] - 1); } +/** + * Which settlement tokens this wallet could fund a *new* channel deposit in — the funding + * facts behind `selectFundedCurrency` (hooks/x402Currency.ts). Reads `balanceOf` for every + * token registered on `network` (three parallel reads), and a token counts as fundable when + * the balance covers its `minimumDepositAtomic` floor — the same 0.50 the deposit itself + * would move, so the answer cannot drift from what the SDK will actually ask for. + * + * A failing read (RPC down, unknown token) counts as *unfunded*, never as "try another": + * "unknown" must not trigger a currency fallback, or a flaky RPC would silently switch the + * token the user chose. Per-call state by design — balances are read fresh at send time, + * never cached, matching the "in context of the action" rule of the chain switch. + * + * `publicClient` is `unknown` and cast inside, the file's own convention: viem's generic + * `readContract` cannot be matched structurally, and `createPaidFetch` casts the same value + * for the same reason (see the call at `toClientEvmSigner`). + */ +export async function readFundableTokens( + walletAddress: `0x${string}`, + // Unknown, cast below to the minimal shape actually called: viem's generic readContract + // cannot be matched structurally, and `createPaidFetch` accepts the same value as unknown + // for the same reason. The cast keeps this function any-free. + publicClient: unknown, + network: string, +): Promise<(currency: PaymentCurrency) => boolean> { + const client = publicClient as { readContract: (call: unknown) => Promise }; + const tokens = getSettlementTokens(network); + const held = new Map(); + await Promise.all( + tokens.map(async (token) => { + try { + const balance = await client.readContract({ + address: token.address, + functionName: "balanceOf", + args: [walletAddress], + }); + held.set(token.symbol, typeof balance === "bigint" ? balance : null); + } catch { + held.set(token.symbol, null); + } + }), + ); + return (currency) => { + const balance = held.get(currency); + return balance !== undefined && balance !== null && balance >= minimumDepositAtomic(currency); + }; +} + /** * Custom deposit sizing: always deposit/top-up to at least `minimumDepositAtomic(currency)`, * regardless of the SDK's default multiplier-of-ceiling formula — see that function's @@ -274,10 +322,17 @@ function describePaymentError(status: number, body: string, currency: PaymentCur return DRAINED_CHANNEL_MESSAGE.chat; } if (errorCode?.includes("insufficient_balance")) { - // The USDC.e note is USDC's alone: bridged EURC is not a thing people end up holding by accident. + // The switch hint is the actionable advice: the guarded-B fallback (useX402Chat) has + // already moved the send to a funded token when one exists, so reaching this message + // means either a race between the balance read and the deposit, or nothing funded — and + // the hint points at the one in-chat fix either way, instead of "go buy ". + // Both variants are written to fit `describeFailure`'s 200-char cap: the hint is the part + // the model must not lose, and the USDC.e note is shortened, not the hint. + // (USDC's note is USDC's alone: bridged EURC is not a thing people end up holding by accident.) + const switchHint = "Alternatively, switch the payment currency in the assistant's advanced settings."; return currency === "USDC" - ? "Not enough USDC in your wallet to fund the payment channel. Note that only native USDC works — a bridged variant such as USDC.e cannot be used." - : `Not enough ${currency} in your wallet to fund the payment channel.`; + ? `Not enough USDC in your wallet to fund the payment channel. Note that only native USDC works, not USDC.e. ${switchHint}` + : `Not enough ${currency} in your wallet to fund the payment channel. ${switchHint}`; } return `Request failed: ${status} - ${body}`; } From ddb083d03e027b1b1e6be265e895649321e0a9a1 Mon Sep 17 00:00:00 2001 From: fretchen Date: Fri, 16 Oct 2026 18:14:40 +0200 Subject: [PATCH 4/5] fix names --- website/components/AssistantChat.tsx | 3 ++- website/pages/assistent/+description.ts | 2 +- website/pages/assistent/+title.ts | 2 +- website/pages/lab/+Page.tsx | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/website/components/AssistantChat.tsx b/website/components/AssistantChat.tsx index 3e0b360e5..bb094f0b8 100644 --- a/website/components/AssistantChat.tsx +++ b/website/components/AssistantChat.tsx @@ -1,6 +1,7 @@ /** * AssistantChat — the interactive core of the x402 batch-settlement chat assistant - * (`/assistent`). Pays per message via `useX402Chat` (USDC payment channels): the + * (`/assistent`). Pays per message via `useX402Chat` (stablecoin payment channels — + * EURe by default, EURC and USDC one toggle away): the * first message opens a channel (one wallet-signed deposit), later messages are * off-chain voucher signatures reusing the open channel. */ diff --git a/website/pages/assistent/+description.ts b/website/pages/assistent/+description.ts index 5100894f7..e69288f1b 100644 --- a/website/pages/assistent/+description.ts +++ b/website/pages/assistent/+description.ts @@ -1,3 +1,3 @@ export function description() { - return "An AI assistant that pays per message with x402 batch-settlement USDC payment channels — no prepaid balance, no subscription. The channel opens on your first message; each message after is a cheap off-chain voucher."; + return "An AI assistant that pays per message with x402 batch-settlement stablecoin payment channels — no prepaid balance, no subscription. The channel opens on your first message; each message after is a cheap off-chain voucher."; } diff --git a/website/pages/assistent/+title.ts b/website/pages/assistent/+title.ts index 25d4e49a7..e2eb64ccb 100644 --- a/website/pages/assistent/+title.ts +++ b/website/pages/assistent/+title.ts @@ -1,3 +1,3 @@ export function title() { - return "AI Assistant with USDC Payment Channels | fretchen.eu"; + return "AI Assistant with Stablecoin Payment Channels | fretchen.eu"; } diff --git a/website/pages/lab/+Page.tsx b/website/pages/lab/+Page.tsx index 08d6ca68b..f805d2673 100644 --- a/website/pages/lab/+Page.tsx +++ b/website/pages/lab/+Page.tsx @@ -21,7 +21,7 @@ const LabPage: React.FC = () => { From a6379b6b686efb2cd7ab429c41b799020ec3b189 Mon Sep 17 00:00:00 2001 From: fretchen Date: Fri, 16 Oct 2026 21:21:46 +0200 Subject: [PATCH 5/5] make it prettier --- website/hooks/useX402Chat.ts | 8 +++++++- website/test/AssistantChat.test.tsx | 4 ++-- website/test/x402Currency.test.ts | 10 ++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/website/hooks/useX402Chat.ts b/website/hooks/useX402Chat.ts index a09f5a2e0..8857126fc 100644 --- a/website/hooks/useX402Chat.ts +++ b/website/hooks/useX402Chat.ts @@ -17,7 +17,13 @@ import { useWalletClient } from "wagmi"; import { getConfiguredPublicClient } from "./useConfiguredPublicClient"; import { useIsWalletConnected } from "./useIsWalletConnected"; import { probeAccepts, negotiateNetwork, LLM_V1_FLOOR } from "./x402Discovery"; -import { DEFAULT_CURRENCY, offeredCurrency, offeredCurrencies, selectFundedCurrency, type PaymentCurrency } from "./x402Currency"; +import { + DEFAULT_CURRENCY, + offeredCurrency, + offeredCurrencies, + selectFundedCurrency, + type PaymentCurrency, +} from "./x402Currency"; import { createPaidFetch, readFundableTokens, type PaidFetch } from "../utils/x402PaidFetch"; import type { X402ChatMessage, diff --git a/website/test/AssistantChat.test.tsx b/website/test/AssistantChat.test.tsx index 7bc5d8edc..86d983fe7 100644 --- a/website/test/AssistantChat.test.tsx +++ b/website/test/AssistantChat.test.tsx @@ -1566,7 +1566,7 @@ describe("AssistantChat", () => { paymentNetwork: "eip155:8453", paymentCurrency: "USDC", currencyFallback: null, - }); + }); renderWithQuery(); @@ -1590,7 +1590,7 @@ describe("AssistantChat", () => { paymentNetwork: "eip155:8453", paymentCurrency: "USDC", currencyFallback: null, - }); + }); renderWithQuery(); diff --git a/website/test/x402Currency.test.ts b/website/test/x402Currency.test.ts index ca0ff8a56..087b216b5 100644 --- a/website/test/x402Currency.test.ts +++ b/website/test/x402Currency.test.ts @@ -103,7 +103,10 @@ describe("preferCurrency selector", () => { describe("selectFundedCurrency", () => { /** `fundable` answers "can this token's wallet balance cover the deposit floor". */ - const funded = (...held: PaymentCurrency[]) => (c: PaymentCurrency) => held.includes(c); + const funded = + (...held: PaymentCurrency[]) => + (c: PaymentCurrency) => + held.includes(c); const all = ["EURe", "EURC", "USDC"] as const; it("keeps the paid currency when it can fund the deposit", () => { @@ -127,7 +130,10 @@ describe("selectFundedCurrency", () => { }); it("never picks a currency the seller does not offer, even when its balance would fund it", () => { - expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({ currency: "USDC", fellBack: true }); + expect(selectFundedCurrency("EURe", ["USDC"], funded("EURe", "USDC"))).toEqual({ + currency: "USDC", + fellBack: true, + }); }); });