Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion website/components/AssistantChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<ToolSource, { href: string; label: string }> = {
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 },
};
Expand Down Expand Up @@ -812,6 +862,90 @@ 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<string, unknown>,
): Promise<ToolRunResult> {
// 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.
Expand Down Expand Up @@ -987,6 +1121,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;
Expand Down
17 changes: 17 additions & 0 deletions website/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -110,10 +113,23 @@ 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:
Expand Down Expand Up @@ -161,6 +177,7 @@ export default {
bundestaktSource: "Quelle: Bundestakt",
analyticsSource: "Quelle: Analytics",
braveSource: "Quelle: Brave Search",
transitousSource: "Quelle: Transitous",
errorPrefix: "Fehler:",
unknownError: "Unbekannter Fehler",
loading: "Lädt...",
Expand Down
17 changes: 17 additions & 0 deletions website/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -108,7 +111,20 @@ 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).
Expand Down Expand Up @@ -152,6 +168,7 @@ export default {
bundestaktSource: "Source: Bundestakt",
analyticsSource: "Source: Analytics",
braveSource: "Source: Brave Search",
transitousSource: "Source: Transitous",
errorPrefix: "Error:",
unknownError: "Unknown error",
loading: "Loading...",
Expand Down
3 changes: 2 additions & 1 deletion website/prompts/tool-contract.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
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.

## 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`.
Expand Down
Loading
Loading