From 0cd73f0320a9e6108bdaf8364946bd2bd032d320 Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 23:30:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(tour-announce):=20=E2=9C=A8=20Implement=20?= =?UTF-8?q?AI-driven=20tour=20announcement=20system=20with=20adapter=20log?= =?UTF-8?q?ic=20and=20profile=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/adapter/tour-announce/index.spec.ts | 169 +++++++++++++++ .../src/adapter/tour-announce/index.ts | 156 ++++++++++++++ .../bookings-tryst/src/surface/profile.ts | 203 ++++++++++++++++++ 3 files changed, 528 insertions(+) create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.spec.ts create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.ts create mode 100644 @platform/codebase/@features/bookings-tryst/src/surface/profile.ts diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.spec.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.spec.ts new file mode 100644 index 0000000..774807a --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + AdapterContext, + AgentActionRef, + AgentActionWrite, + BlocklistEntry, + BlocklistKind, +} from '@cocottetech/surface-adapter-contracts'; +import type { + TrystProfileDriver, + TrystProfileSession, +} from '@cocottetech/bookings-tryst-adapter'; + +import { descriptor } from './index.js'; + +const action = descriptor.action; + +function entry(value: string, kind: BlocklistKind): BlocklistEntry { + return { + id: `bl_${value}`, + userId: 'user_1', + orgId: null, + kind, + value, + scope: 'global', + reason: null, + expiresAt: null, + createdAt: '2026-01-01T00:00:00.000Z', + createdBy: 'user', + }; +} + +function makeDriver(): { driver: TrystProfileDriver; calls: string[] } { + const calls: string[] = []; + let n = 0; + const driver: TrystProfileDriver = { + goto: async (p) => { + calls.push(`goto:${p}`); + }, + setField: async (f, v) => { + calls.push(`setField:${f}=${v}`); + }, + setChip: async (c, on) => { + calls.push(`setChip:${c}=${on}`); + }, + submit: async (form) => { + calls.push(`submit:${form}`); + n += 1; + return { externalId: `tryst_tour_${n}` }; + }, + }; + return { driver, calls }; +} + +function makeCtx( + entries: BlocklistEntry[], + driver: TrystProfileDriver, + record: (w: AgentActionWrite) => Promise, +): AdapterContext { + const session: TrystProfileSession = { + surface: 'tryst', + authenticated: true, + driver, + }; + return { + userId: 'user_1', + session, + platformApi: { get: vi.fn(), post: vi.fn() }, + blocklist: { + list: async (kind?: BlocklistKind) => + kind ? entries.filter((e) => e.kind === kind) : entries, + }, + agentActions: { record }, + logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; +} + +const BERLIN = { city: 'Berlin', startDate: '2026-10-03', endDate: '2026-10-07' }; + +describe('tour-announce action', () => { + it('exposes a propose-only, non-auto-executable tryst descriptor', () => { + expect(descriptor.surface).toBe('tryst'); + expect(descriptor.verb).toBe('tour-announce'); + expect(descriptor.autoExecutable).toBe(false); + }); + + it('rejects a draft that names a blocklisted accommodation (K3f-2)', async () => { + const { driver } = makeDriver(); + const record = vi.fn(); + // Composed phrasing for "Hotel Adlon" only trips if the city string carries it; + // the gate scans the composed text, so put the hotel name in the city field. + const ctx = makeCtx([entry('Hotel Adlon', 'phrase')], driver, record); + + const input = { + tier: 'standard' as const, + legs: [{ city: 'Berlin near Hotel Adlon', startDate: '2026-10-03', endDate: '2026-10-07' }], + }; + const result = await action.precheck(input, ctx); + + expect(result.ok).toBe(false); + expect(result.rejections.some((r) => r.gate === 'K3f-2')).toBe(true); + expect(record).not.toHaveBeenCalled(); + }); + + it('rejects a draft referencing a govt-name (K3c-1)', async () => { + const { driver } = makeDriver(); + const ctx = makeCtx([entry('John Doe', 'phrase')], driver, vi.fn()); + const input = { + tier: 'standard' as const, + legs: [{ city: 'visiting John Doe in Berlin', startDate: '2026-10-03', endDate: '2026-10-07' }], + }; + const result = await action.precheck(input, ctx); + expect(result.ok).toBe(false); + expect(result.rejections.some((r) => r.gate === 'K3c-1')).toBe(true); + }); + + it('rejects when legs exceed the tier tour-slot ceiling', async () => { + const { driver } = makeDriver(); + const ctx = makeCtx([], driver, vi.fn()); + // Basic tier = 1 slot; two legs exceed it. + const input = { + tier: 'basic' as const, + legs: [BERLIN, { city: 'Paris', startDate: '2026-10-08', endDate: '2026-10-10' }], + }; + const result = await action.precheck(input, ctx); + expect(result.ok).toBe(false); + expect(result.rejections.some((r) => r.gate === 'R-tour-slots')).toBe(true); + }); + + it('passes precheck for a clean within-ceiling draft', async () => { + const { driver } = makeDriver(); + const ctx = makeCtx([entry('Hotel Adlon', 'phrase')], driver, vi.fn()); + const result = await action.precheck( + { tier: 'standard' as const, legs: [BERLIN] }, + ctx, + ); + expect(result.ok).toBe(true); + }); + + it('publishes each leg and writes a non-auto agent_actions row', async () => { + const { driver, calls } = makeDriver(); + const record = vi + .fn<(w: AgentActionWrite) => Promise>() + .mockResolvedValue({ id: 'aa_t1', createdAt: '2026-06-03T00:00:00.000Z' }); + const ctx = makeCtx([], driver, record); + + const out = await action.execute( + { + tier: 'standard' as const, + legs: [BERLIN, { city: 'Paris', startDate: '2026-10-08', endDate: '2026-10-10' }], + }, + ctx, + ); + + expect(out.auditId).toBe('aa_t1'); + expect(out.announced).toEqual([ + { city: 'Berlin', externalId: 'tryst_tour_1' }, + { city: 'Paris', externalId: 'tryst_tour_2' }, + ]); + expect(calls.filter((c) => c === 'submit:tour_announce')).toHaveLength(2); + + expect(record).toHaveBeenCalledTimes(1); + const write = record.mock.calls[0]?.[0]; + expect(write?.autoExecuted).toBe(false); + expect(write?.specialistId).toBe('bookings-tryst'); + expect(write?.actionType).toBe('tour_announce'); + expect(write?.targetKind).toBe('surface_tour'); + }); +}); diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.ts new file mode 100644 index 0000000..f3e51b7 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/tour-announce/index.ts @@ -0,0 +1,156 @@ +/** + * `tour-announce` — publish Tryst tour dates (city + date range). + * + * Posture: PROPOSE-ONLY. ai-copilot draws the H3 multi-surface tour card; Quinn + * approves; only then does this action run. Never auto-executed + * (`autoExecuted: false`, descriptor `autoExecutable: false`). + * + * Tier limit: a Tryst account may declare at most N concurrent tour slots, where + * N is tier-dependent (surface-tryst.brief.md §canonical-facts — Basic 1 / + * Standard 5 / Premium 10 / Premium+ 10). The action rejects a request that would + * exceed the tier's slot ceiling — deterministically, before any browser work. + * + * Safety (brief K): + * - K3f-2: tour text may reveal city + date range but NEVER an accommodation + * (hotel) name. precheck runs `gateK3fAccommodationName` over the composed + * phrasing using the tenant's known accommodation names. + * - K3c-1: no draft may reference Quinn's govt name. precheck also runs + * `gateK3cGovtName`. + * If either gate fires, `execute` never runs. + */ +import { z } from 'zod'; +import { + type ActionDescriptor, + type AdapterContext, + type BlocklistEntry, + type GateRejection, + type SurfaceAdapterAction, + gateK3cGovtName, + gateK3fAccommodationName, + precheckResult, +} from '@cocottetech/surface-adapter-contracts'; +import { + type TrystTourLeg, + applyTourAnnounce, + asTrystProfileSession, + composeTourPhrasing, +} from '@cocottetech/bookings-tryst-adapter'; + +/** Tour-slot ceiling by Tryst tier (§canonical-facts — Basic 1 / Standard 5 / Premium 10 / Premium+ 10). */ +const TOUR_SLOTS_BY_TIER = { + basic: 1, + standard: 5, + premium: 10, + 'premium-plus': 10, +} as const; +type TrystTier = keyof typeof TOUR_SLOTS_BY_TIER; + +const legSchema = z + .object({ + city: z.string().min(1), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'), + endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'), + }) + .strict() + .refine((l) => l.startDate <= l.endDate, { + message: 'startDate must be on or before endDate', + }); + +const schema = z + .object({ + tier: z.enum(['basic', 'standard', 'premium', 'premium-plus']), + /** Tour legs to announce. Length is capped by the tier's slot ceiling. */ + legs: z.array(legSchema).min(1), + /** + * Tour legs already live on the account (count toward the slot ceiling). + * Absent ⇒ treated as 0 (this is the only declared tour). + */ + existingLegCount: z.number().int().nonnegative().optional(), + }) + .strict(); + +type Input = z.infer; + +interface AnnouncedLeg { + city: string; + externalId: string; +} + +interface Output { + /** `agent_actions.id` of the audit row written. */ + auditId: string; + announced: AnnouncedLeg[]; +} + +/** Accommodation names to forbid = tenant `phrase`/`topic` blocklist values (K3f-2 ⟵ brief R). */ +function accommodationNames(entries: readonly BlocklistEntry[]): string[] { + return entries.filter((e) => e.kind === 'phrase' || e.kind === 'topic').map((e) => e.value); +} + +/** Identity terms to forbid = tenant `phrase` blocklist values (K3c-1 ⟵ K2). */ +function forbiddenNames(entries: readonly BlocklistEntry[]): string[] { + return entries.filter((e) => e.kind === 'phrase').map((e) => e.value); +} + +const action: SurfaceAdapterAction = { + action: 'tour-announce', + schema, + async precheck(input: Input, ctx: AdapterContext) { + const rejections: GateRejection[] = []; + + // Tier slot-ceiling check (deterministic, no I/O). + const ceiling = TOUR_SLOTS_BY_TIER[input.tier as TrystTier]; + const requested = (input.existingLegCount ?? 0) + input.legs.length; + if (requested > ceiling) { + rejections.push({ + gate: 'R-tour-slots', + reason: + `tour would use ${requested} of ${ceiling} ${input.tier} tour slots; ` + + `drop ${requested - ceiling} leg(s) or upgrade tier`, + }); + } + + const blocklist = await ctx.blocklist.list(); + const accommodation = accommodationNames(blocklist); + const forbidden = forbiddenNames(blocklist); + const composed = input.legs.map((leg) => composeTourPhrasing(leg)).join('\n'); + + rejections.push(...gateK3fAccommodationName(composed, accommodation)); + rejections.push(...gateK3cGovtName(composed, forbidden)); + + return precheckResult(rejections); + }, + async execute(input: Input, ctx: AdapterContext): Promise { + const session = asTrystProfileSession(ctx.session); + + const announced: AnnouncedLeg[] = []; + for (const leg of input.legs) { + const tourLeg: TrystTourLeg = leg; + const phrasing = composeTourPhrasing(tourLeg); + const { externalId } = await applyTourAnnounce(session, tourLeg, phrasing); + announced.push({ city: leg.city, externalId }); + } + + const ref = await ctx.agentActions.record({ + userId: ctx.userId, + ...(ctx.orgId !== undefined ? { orgId: ctx.orgId } : {}), + specialistId: 'bookings-tryst', + actionType: 'tour_announce', + targetKind: 'surface_tour', + stakes: 'medium', + confidence: 1, + autoExecuted: false, + outcome: { legs: announced }, + }); + + ctx.logger.log(`tour-announce published ${announced.length} leg(s) on tryst`); + return { auditId: ref.id, announced }; + }, +}; + +export const descriptor: ActionDescriptor = { + surface: 'tryst', + verb: 'tour-announce', + action: action as SurfaceAdapterAction, + autoExecutable: false, +}; diff --git a/@platform/codebase/@features/bookings-tryst/src/surface/profile.ts b/@platform/codebase/@features/bookings-tryst/src/surface/profile.ts new file mode 100644 index 0000000..09cb629 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/src/surface/profile.ts @@ -0,0 +1,203 @@ +/** + * Tryst surface helpers — profile edits + tour announcements. + * + * These helpers drive Quinn's *own* authenticated Tryst session to apply an + * already-approved profile change (about-me / rates / services / hours) or to + * publish a tour announcement (city + date range). They are the surface-write + * counterpart to `tryst-session.ts` (which only *verifies* a session). + * + * Why this file is Playwright-free + * -------------------------------- + * The shared adapter contract (`@cocottetech/surface-adapter-contracts`) keeps + * `playwright` out of every action's dependency graph: actions receive an opaque + * {@link SurfaceSession} and never touch the browser directly. We honour that + * same boundary here. Instead of importing Playwright, this module declares a + * narrow {@link TrystProfileDriver} capability interface — the field-level + * operations a Tryst profile/tour write needs — which the surface-adapter + * container runtime implements on top of its managed Playwright `Page`. The + * helpers below are therefore pure orchestration over that interface: fully + * unit-testable with a fake driver, no browser required. + * + * Identity / location safety (K3c-1, K3f-2) is enforced *before* these run, in + * the calling action's `precheck` via the deterministic K-gates. These helpers + * assume the draft has already cleared the gates. + */ +import type { SurfaceSession } from '@cocottetech/surface-adapter-contracts'; + +/** Tryst's fixed rate tiers (surface-tryst.brief.md §11 — incall/outcall × duration + overnight). */ +export interface TrystRates { + /** Currency code the rate values are expressed in (e.g. `'USD'`). */ + readonly currency: string; + readonly incall1h?: number | undefined; + readonly incall2h?: number | undefined; + readonly outcall1h?: number | undefined; + readonly outcall2h?: number | undefined; + readonly overnight?: number | undefined; +} + +/** + * A structured Tryst profile edit. Every field is optional — only the fields + * present in the diff-approved change are applied. Photo and identity-bearing + * fields are intentionally ABSENT from this shape: this specialist never mutates + * them (specialist-bookings-tryst.contract.md §Never). + */ +export interface TrystProfileEdit { + /** Free-text about-me (tryst-profile-editor.screen.md §About-me). */ + readonly aboutMe?: string; + /** Rate table (surface-tryst.brief.md §11). */ + readonly rates?: TrystRates; + /** Service chips from Tryst's fixed taxonomy. */ + readonly services?: readonly string[]; + /** Per-weekday open hours; `null` value = closed that day. */ + readonly hours?: Readonly>; +} + +/** One leg of a tour: a city + an inclusive date range (city + dates only — never an address). */ +export interface TrystTourLeg { + readonly city: string; + /** Inclusive start date, `YYYY-MM-DD`. */ + readonly startDate: string; + /** Inclusive end date, `YYYY-MM-DD`. */ + readonly endDate: string; +} + +/** + * Narrow capability interface the container runtime implements over its managed + * Playwright page. Deliberately small: only the operations a Tryst profile/tour + * write performs. Keeping it an interface (not a Playwright `Page`) is what lets + * this module — and the actions that call it — stay Playwright-free. + */ +export interface TrystProfileDriver { + /** Navigate the session to a Tryst app path (e.g. `/account/profile/edit`). */ + goto(path: string): Promise; + /** Set a named form field to a string value (text area / input). */ + setField(field: string, value: string): Promise; + /** Toggle a named multi-select chip on or off. */ + setChip(chip: string, on: boolean): Promise; + /** Submit the current form; resolves with the external id Tryst assigns. */ + submit(form: string): Promise<{ externalId: string }>; +} + +/** + * The Tryst session shape these helpers require: the base opaque + * {@link SurfaceSession} augmented with the driver capability the container + * supplies. Actions cast `ctx.session` to this type after asserting the surface. + */ +export interface TrystProfileSession extends SurfaceSession { + readonly surface: 'tryst'; + readonly driver: TrystProfileDriver; +} + +/** Tryst app paths the helpers drive (single source so tests + impl agree). */ +export const TRYST_PROFILE_EDIT_PATH = '/account/profile/edit'; +export const TRYST_TOUR_ANNOUNCE_PATH = '/account/tours/new'; + +/** Raised when a Tryst surface write fails; carries the failing form for the audit outcome. */ +export class TrystSurfaceWriteError extends Error { + constructor( + readonly form: string, + cause: unknown, + ) { + super(`tryst ${form} write failed: ${cause instanceof Error ? cause.message : String(cause)}`); + this.name = 'TrystSurfaceWriteError'; + } +} + +/** Narrow runtime assertion that an opaque session carries the Tryst profile driver. */ +export function asTrystProfileSession(session: SurfaceSession): TrystProfileSession { + if (session.surface !== 'tryst') { + throw new Error(`expected a tryst session, got surface=${session.surface}`); + } + const candidate = session as Partial; + if (!candidate.driver || typeof candidate.driver.submit !== 'function') { + throw new Error('tryst session is missing its profile driver capability'); + } + return session as TrystProfileSession; +} + +/** Format a rate value for a Tryst currency-formatted input (integer units). */ +function formatRate(value: number): string { + return String(Math.round(value)); +} + +/** + * Apply a diff-approved profile edit through the session's driver. Only the + * fields present on `edit` are written. Returns the external id of the saved + * profile revision. NEVER touches photos or identity fields. Any driver failure + * is wrapped in {@link TrystSurfaceWriteError} so the caller can record a + * meaningful audit outcome. + */ +export async function applyProfileEdit( + session: TrystProfileSession, + edit: TrystProfileEdit, +): Promise<{ externalId: string }> { + const { driver } = session; + try { + await driver.goto(TRYST_PROFILE_EDIT_PATH); + + if (edit.aboutMe !== undefined) { + await driver.setField('about_me', edit.aboutMe); + } + if (edit.rates) { + const r = edit.rates; + await driver.setField('rates_currency', r.currency); + if (r.incall1h !== undefined) + await driver.setField('rate_incall_1h', formatRate(r.incall1h)); + if (r.incall2h !== undefined) + await driver.setField('rate_incall_2h', formatRate(r.incall2h)); + if (r.outcall1h !== undefined) + await driver.setField('rate_outcall_1h', formatRate(r.outcall1h)); + if (r.outcall2h !== undefined) + await driver.setField('rate_outcall_2h', formatRate(r.outcall2h)); + if (r.overnight !== undefined) + await driver.setField('rate_overnight', formatRate(r.overnight)); + } + if (edit.services) { + for (const chip of edit.services) { + await driver.setChip(chip, true); + } + } + if (edit.hours) { + for (const [day, range] of Object.entries(edit.hours)) { + await driver.setField(`hours_${day.toLowerCase()}`, range ?? 'closed'); + } + } + + return await driver.submit('profile_edit'); + } catch (err) { + throw new TrystSurfaceWriteError('profile_edit', err); + } +} + +/** + * Compose Tryst-specific tour-announcement phrasing for one leg. City + date + * range only — by construction this NEVER emits an accommodation/address (the + * K3f-2 gate in the calling action's precheck additionally rejects any draft + * that names one). + */ +export function composeTourPhrasing(leg: TrystTourLeg): string { + return `Touring ${leg.city} ${leg.startDate} – ${leg.endDate}. Limited dates — book ahead.`; +} + +/** + * Publish a tour announcement (city + date range) through the session's driver. + * `phrasing` is the already-composed, gate-cleared announcement text. Driver + * failures are wrapped in {@link TrystSurfaceWriteError}. + */ +export async function applyTourAnnounce( + session: TrystProfileSession, + leg: TrystTourLeg, + phrasing: string, +): Promise<{ externalId: string }> { + const { driver } = session; + try { + await driver.goto(TRYST_TOUR_ANNOUNCE_PATH); + await driver.setField('tour_city', leg.city); + await driver.setField('tour_start', leg.startDate); + await driver.setField('tour_end', leg.endDate); + await driver.setField('tour_description', phrasing); + return await driver.submit('tour_announce'); + } catch (err) { + throw new TrystSurfaceWriteError('tour_announce', err); + } +}