Apply demo-activity-bot: Bruno, the synthetic public-walk generator

Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.

- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:14:58 +02:00
parent 7304a9e904
commit ba4c4e1b4f
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 1003 additions and 43 deletions

View file

@ -0,0 +1,438 @@
import { randomUUID } from "node:crypto";
import { and, eq, lt, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, users } from "@trails-cool/db/schema/journal";
import { setGeomFromGpx } from "./routes.server.ts";
import { TERMS_VERSION } from "./legal.ts";
import { parseGpxAsync } from "@trails-cool/gpx";
import { logger } from "./logger.server.ts";
import {
demoBotSyntheticActivitiesTotal,
demoBotSyntheticRoutesTotal,
} from "./metrics.server.ts";
export const DEMO_USERNAME = "bruno";
export const DEMO_DISPLAY_NAME = "Bruno";
export const DEMO_BIO =
"Professional park inspector. Currently accepting tennis balls.";
/**
* Inner Berlin (Tiergarten Grunewald corridor). Keeps Bruno's walks
* somewhere plausible for an urban dog and makes templated copy work.
* Override with `DEMO_BOT_REGION='{"bbox":[w,s,e,n]}'`.
*/
const DEFAULT_BBOX: [number, number, number, number] = [13.25, 52.45, 13.55, 52.60];
export function isDemoBotEnabled(): boolean {
return process.env.DEMO_BOT_ENABLED === "true";
}
export function demoRetentionDays(): number {
const raw = process.env.DEMO_BOT_RETENTION_DAYS;
if (!raw) return 14;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : 14;
}
/**
* Idempotent insert of the Bruno demo user. Safe to call every worker
* boot; relies on `users.username` uniqueness to short-circuit duplicates.
*/
export async function ensureDemoUser(): Promise<string> {
const db = getDb();
const domain = process.env.DOMAIN ?? "localhost";
const [existing] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, DEMO_USERNAME));
if (existing) return existing.id;
const id = randomUUID();
await db
.insert(users)
.values({
id,
username: DEMO_USERNAME,
email: `${DEMO_USERNAME}@${domain}`,
displayName: DEMO_DISPLAY_NAME,
bio: DEMO_BIO,
domain,
termsAcceptedAt: new Date(),
termsVersion: TERMS_VERSION,
})
.onConflictDoNothing({ target: users.username });
// Re-read in case a concurrent insert won the race
const [row] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, DEMO_USERNAME));
if (!row) throw new Error("ensureDemoUser: insert succeeded but row not found");
return row.id;
}
export interface Region {
bbox: [number, number, number, number];
}
export function loadRegion(): Region {
const raw = process.env.DEMO_BOT_REGION;
if (!raw) return { bbox: DEFAULT_BBOX };
try {
const parsed = JSON.parse(raw) as { bbox?: unknown };
if (
Array.isArray(parsed.bbox) &&
parsed.bbox.length === 4 &&
parsed.bbox.every((n) => typeof n === "number")
) {
return { bbox: parsed.bbox as [number, number, number, number] };
}
} catch {
// fall through
}
logger.warn({ raw }, "DEMO_BOT_REGION invalid, using default");
return { bbox: DEFAULT_BBOX };
}
/**
* Great-circle distance in metres between two (lon, lat) points.
*/
function haversineMeters(a: [number, number], b: [number, number]): number {
const R = 6_371_000;
const [lon1, lat1] = a;
const [lon2, lat2] = b;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
}
function randInBbox(bbox: [number, number, number, number]): [number, number] {
const [w, s, e, n] = bbox;
return [w + Math.random() * (e - w), s + Math.random() * (n - s)];
}
export interface Endpoints {
start: [number, number];
end: [number, number];
}
/**
* Random start + end inside the bbox such that the crow-distance is in
* the dog-walk band (212 km). Gives up after N tries; on exhaustion
* returns whatever it has so the caller can decide (BRouter will likely
* succeed anyway, just with shorter/longer routes).
*/
export function pickEndpoints(bbox: [number, number, number, number]): Endpoints {
const MIN = 2_000;
const MAX = 12_000;
for (let i = 0; i < 50; i++) {
const start = randInBbox(bbox);
const end = randInBbox(bbox);
const d = haversineMeters(start, end);
if (d >= MIN && d <= MAX) return { start, end };
}
const start = randInBbox(bbox);
const end = randInBbox(bbox);
return { start, end };
}
// --- Bruno-voiced copy --------------------------------------------------
const NAME_POOL_EN = [
"Grunewald north-loop patrol",
"Tiergarten perimeter audit",
"Tempelhof runway inspection",
"Spree embankment survey",
"Bruno's morning rounds",
"Sniff-check: Kreuzberg edition",
"Bruno found three sticks today",
"Dog-audit report: all squirrels present",
"Volkspark compliance walk",
"Tennis ball reconnaissance",
"Pretzel-crumb cleanup detail",
"Bruno vs. the pigeons",
];
const NAME_POOL_DE = [
"Grunewald-Nordschleife-Patrouille",
"Tiergarten-Umfangsprüfung",
"Tempelhof-Startbahn-Inspektion",
"Spreeufer-Rundgang",
"Brunos Morgenrunde",
"Schnüffelkontrolle: Kreuzberg",
"Bruno hat heute drei Stöcke gefunden",
"Hundeprüfbericht: alle Eichhörnchen anwesend",
"Volkspark-Konformitätsgang",
"Tennisball-Aufklärung",
"Brezel-Krümel-Aufräumdienst",
"Bruno gegen die Tauben",
];
const DESCRIPTION_POOL_EN = [
"Inspected several bushes. All present and accounted for.",
"Three squirrels successfully monitored. Two got away.",
"Good sticks: 2. Great sticks: 1. Excellent sticks: 0.",
"Pace was brisk. Sniffs were thorough.",
"Encountered another dog. Diplomacy established.",
"Weather: windy. Ears: flopping.",
"Pigeons stood their ground. A follow-up visit is required.",
"Finished the route ahead of schedule. Extra treats expected.",
"Investigated one suspicious paper bag. False alarm.",
"Logged one (1) successful puddle inspection.",
];
const DESCRIPTION_POOL_DE = [
"Mehrere Büsche inspiziert. Alle vorhanden.",
"Drei Eichhörnchen erfolgreich beobachtet. Zwei entkommen.",
"Gute Stöcke: 2. Großartige Stöcke: 1. Exzellente Stöcke: 0.",
"Tempo zügig. Schnüffeln gründlich.",
"Einen anderen Hund getroffen. Diplomatie hergestellt.",
"Wetter: windig. Ohren: flatternd.",
"Die Tauben hielten Stand. Ein Folgebesuch ist erforderlich.",
"Route vor dem Zeitplan abgeschlossen. Zusätzliche Leckerlis erwartet.",
"Eine verdächtige Papiertüte untersucht. Fehlalarm.",
"Eine (1) erfolgreiche Pfützen-Inspektion protokolliert.",
];
function pickFrom<T>(pool: readonly T[], seed: number): T {
const idx = Math.abs(Math.floor(seed)) % pool.length;
return pool[idx]!;
}
/**
* Deterministic-ish per day + per started-at minute, so same-day walks
* don't land on the same name. Locale is chosen from the journal's
* default Bruno doesn't know German, but his walks come with German
* subtitles when the reader expects them.
*/
export function templateName(startedAt: Date, locale: "en" | "de" = "en"): string {
const pool = locale === "de" ? NAME_POOL_DE : NAME_POOL_EN;
const seed = startedAt.getUTCDate() * 60 + startedAt.getUTCMinutes();
return pickFrom(pool, seed);
}
export function templateDescription(
startedAt: Date,
locale: "en" | "de" = "en",
): string {
const pool = locale === "de" ? DESCRIPTION_POOL_DE : DESCRIPTION_POOL_EN;
const seed = startedAt.getUTCDate() * 137 + startedAt.getUTCSeconds() * 7;
return pickFrom(pool, seed);
}
// --- BRouter --------------------------------------------------------------
const BROUTER_URL = process.env.BROUTER_URL ?? "http://localhost:17777";
export interface BrouterResult {
gpx: string;
}
export type BrouterError =
| { kind: "no-route" }
| { kind: "timeout" }
| { kind: "upstream-error"; status?: number };
export async function requestBrouterGpx(
endpoints: Endpoints,
{ signal }: { signal?: AbortSignal } = {},
): Promise<BrouterResult | BrouterError> {
const lonlats = `${endpoints.start[0]},${endpoints.start[1]}|${endpoints.end[0]},${endpoints.end[1]}`;
const url = `${BROUTER_URL}/brouter?lonlats=${lonlats}&profile=trekking&alternativeidx=0&format=gpx`;
try {
const resp = await fetch(url, { signal });
if (!resp.ok) {
if (resp.status === 500) return { kind: "no-route" };
return { kind: "upstream-error", status: resp.status };
}
const gpx = await resp.text();
if (!gpx.includes("<trkpt")) return { kind: "no-route" };
return { gpx };
} catch (err) {
if ((err as Error).name === "AbortError") return { kind: "timeout" };
return { kind: "upstream-error" };
}
}
// --- Generation + retention ----------------------------------------------
export interface GenerateOptions {
now?: Date;
/** Localise the walk name; defaults to random-ish. */
locale?: "en" | "de";
}
/**
* Core generate step: insert one Bruno route + activity pair. Callers
* (the recurring job and the backfill loop) decide whether to invoke it.
*
* Returns the inserted route id on success, or `null` on BRouter failure
* or DB failure. Never throws the worker logs and moves on.
*/
export async function generateOneWalk(
ownerId: string,
{ now = new Date(), locale = Math.random() < 0.5 ? "en" : "de" }: GenerateOptions = {},
): Promise<string | null> {
const region = loadRegion();
const endpoints = pickEndpoints(region.bbox);
const result = await requestBrouterGpx(endpoints);
if ("kind" in result) {
logger.info({ endpoints, err: result.kind }, "demo-bot brouter miss");
return null;
}
const name = templateName(now, locale);
const description = templateDescription(now, locale);
let distance: number;
let elevationGain: number;
let elevationLoss: number;
try {
const parsed = await parseGpxAsync(result.gpx);
distance = parsed.distance;
elevationGain = parsed.elevation.gain;
elevationLoss = parsed.elevation.loss;
} catch {
return null;
}
if (!distance || distance < 500) return null;
const db = getDb();
const routeId = randomUUID();
const activityId = randomUUID();
await db.insert(routes).values({
id: routeId,
ownerId,
name,
description,
gpx: result.gpx,
routingProfile: "trekking",
distance,
elevationGain,
elevationLoss,
visibility: "public",
synthetic: true,
});
await setGeomFromGpx(routeId, "routes", result.gpx);
const walkingMetersPerSecond = 4.5 * 1000 / 3600; // ~4.5 km/h
const jitter = 0.85 + Math.random() * 0.3; // ±15 %
const durationSeconds = Math.round((distance / walkingMetersPerSecond) * jitter);
await db.insert(activities).values({
id: activityId,
ownerId,
routeId,
name,
description,
gpx: result.gpx,
startedAt: now,
duration: durationSeconds,
distance,
elevationGain,
elevationLoss,
visibility: "public",
synthetic: true,
});
await setGeomFromGpx(activityId, "activities", result.gpx);
return routeId;
}
export async function countSyntheticRoutesRecent(days: number): Promise<number> {
const db = getDb();
const result = await db.execute(
sql`SELECT count(*)::int AS count FROM journal.routes
WHERE synthetic = true AND created_at > now() - make_interval(days => ${days})`,
);
const rows = result as unknown as Array<{ count: number }>;
return rows[0]?.count ?? 0;
}
export async function countSyntheticRoutesTotal(): Promise<number> {
const db = getDb();
const result = await db.execute(
sql`SELECT count(*)::int AS count FROM journal.routes WHERE synthetic = true`,
);
const rows = result as unknown as Array<{ count: number }>;
return rows[0]?.count ?? 0;
}
async function countSyntheticActivitiesTotal(): Promise<number> {
const db = getDb();
const result = await db.execute(
sql`SELECT count(*)::int AS count FROM journal.activities WHERE synthetic = true`,
);
const rows = result as unknown as Array<{ count: number }>;
return rows[0]?.count ?? 0;
}
/**
* Refresh the prometheus gauges. Called by the job handlers so the
* worker process exposes fresh counts at scrape time without needing a
* periodic poller.
*/
export async function refreshDemoBotGauges(): Promise<void> {
const [routesCount, activitiesCount] = await Promise.all([
countSyntheticRoutesTotal(),
countSyntheticActivitiesTotal(),
]);
demoBotSyntheticRoutesTotal.set(routesCount);
demoBotSyntheticActivitiesTotal.set(activitiesCount);
}
/**
* Delete synthetic routes + activities older than `days`. Never touches
* non-synthetic rows.
*/
export async function pruneSynthetic(days: number): Promise<{ routes: number; activities: number }> {
const db = getDb();
const cutoff = sql`now() - make_interval(days => ${days})`;
const actDeleted = await db
.delete(activities)
.where(and(eq(activities.synthetic, true), lt(activities.createdAt, cutoff)))
.returning({ id: activities.id });
const routeDeleted = await db
.delete(routes)
.where(and(eq(routes.synthetic, true), lt(routes.createdAt, cutoff)))
.returning({ id: routes.id });
return { routes: routeDeleted.length, activities: actDeleted.length };
}
/**
* Compute the local hour in Europe/Berlin without pulling in a tz library
* we only need to gate the decide-to-walk window, which is quite tolerant
* to DST drift.
*/
export function berlinHour(now: Date): number {
const s = now.toLocaleString("en-US", {
timeZone: "Europe/Berlin",
hour12: false,
hour: "2-digit",
});
return Number.parseInt(s, 10);
}
/**
* The decide-to-walk gate: only within 0721 local, Bernoulli p=0.12.
* Factored out so tests can replace `Math.random` / `now`.
*/
export function shouldWalkNow(now: Date, rand: () => number = Math.random): boolean {
const hour = berlinHour(now);
if (hour < 7 || hour >= 21) return false;
return rand() < 0.12;
}
export const DEMO_DAILY_CAP = 40;
export const DEMO_BACKFILL_TARGET = 4;