apps/web/src/lib/agendaPlan.ts

1import type { AgendaPlan, AgendaPlanSession, AiEvent } from '@local/ai'
2import type { AgendaSession } from '@local/ai/components/api/pineconeUpload'
3import {
4	DECISION_PILLARS,
5	decisionPillar,
6} from '@local/config/decision-pillars'
7
8function usable(session: AgendaSession): boolean {
9	return Boolean(
10		session.url &&
11			session.room &&
12			session.title &&
13			!/invite|private|restricted/i.test(session.access) &&
14			/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(session.start) &&
15			Number.isFinite(Date.parse(session.start)) &&
16			Number.isFinite(Date.parse(session.end)) &&
17			Date.parse(session.end) > Date.parse(session.start),
18	)
19}
20
21export function sessionsOverlap(
22	a: AgendaPlanSession,
23	b: AgendaPlanSession,
24): boolean {
25	return (
26		Date.parse(a.start) < Date.parse(b.end) &&
27		Date.parse(b.start) < Date.parse(a.end)
28	)
29}
30
31function toPlanSession(session: AgendaSession): AgendaPlanSession {
32	return {
33		title: session.title,
34		start: session.start,
35		end: session.end,
36		day: session.dayLabel || session.day,
37		room: session.room,
38		speakers: session.speakers,
39		url: session.url,
40	}
41}
42
43export function formatPlan(
44	event: AiEvent,
45	sessions: AgendaPlanSession[],
46	agendaPage: string | null,
47	reasons: Map<string, string> = new Map(),
48): string {
49	return [
50		`Your UNLEASH in ${event === 'paris' ? 'Paris' : 'Miami'} agenda`,
51		...sessions.map(
52			(s) =>
53				`- ${s.day}, ${s.start.slice(11, 16)}–${s.end.slice(11, 16)} — [${s.title}](${s.url}) — ${s.room}${s.speakers ? ` — ${s.speakers}` : ''}${reasons.get(s.url) ? `\n  Why it matches: ${reasons.get(s.url)}.` : ''}`,
54		),
55		'Times are local to the event. Use the gaps between sessions for lunch, networking and the exhibition; these are personal breaks, not scheduled event sessions.',
56		`The agenda may still be updated.${agendaPage ? ` See the full [Agenda](${agendaPage}).` : ''}`,
57	].join('\n\n')
58}
59
60/** Apply the visitor's day constraint even when the planner model omits it. */
61export function requestedAgendaDay(
62	all: AgendaSession[],
63	history: Array<{ role: string; text: string }>,
64	proposed?: string,
65): string | undefined {
66	const days = [...new Set(all.filter(usable).map((s) => s.day))].sort()
67	for (const turn of [...history].reverse()) {
68		if (turn.role !== 'user') continue
69		if (
70			/\b(?:all|both|three|3|two|2|multiple) days?\b|\bfull (?:event|conference)\b/i.test(
71				turn.text,
72			)
73		)
74			return undefined
75		const iso = turn.text.match(/\b\d{4}-\d{2}-\d{2}\b/)
76		if (iso) return iso[0]
77		const date = turn.text.match(
78			/\b(\d{1,2})(?:st|nd|rd|th)?\s+(January|February|March|April|May|June|July|August|September|October|November|December)\b/i,
79		)
80		if (date) {
81			const month =
82				[
83					'january',
84					'february',
85					'march',
86					'april',
87					'may',
88					'june',
89					'july',
90					'august',
91					'september',
92					'october',
93					'november',
94					'december',
95				].indexOf(date[2].toLowerCase()) + 1
96			const suffix = `-${String(month).padStart(2, '0')}-${date[1].padStart(2, '0')}`
97			return (
98				days.find((day) => day.endsWith(suffix)) ||
99				`${days[0]?.slice(0, 4) || new Date().getUTCFullYear()}${suffix}`
100			)
101		}
102		const weekday = turn.text.match(
103			/\b(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b/i,
104		)?.[0]
105		if (weekday) {
106			const match = days.find(
107				(day) =>
108					new Date(`${day}T12:00:00Z`)
109						.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' })
110						.toLowerCase() === weekday.toLowerCase(),
111			)
112			if (match) return match
113		}
114		if (
115			/\b(?:one[ -]day|single[ -]day|just one day|only one day)\b/i.test(turn.text)
116		)
117			return proposed && days.includes(proposed) ? proposed : days[0]
118	}
119	return proposed
120}
121
122/** Select published, public sessions; intervals are checked before anything reaches the visitor. */
123export function createAgendaPlan(
124	event: AiEvent,
125	all: AgendaSession[],
126	agendaPage: string | null,
127	topics: string,
128	day?: string,
129): AgendaPlan {
130	const selectedPillars = DECISION_PILLARS.filter(
131		(pillar) =>
132			topics.toLowerCase().includes(pillar.label.toLowerCase()) ||
133			topics.toLowerCase().includes(pillar.id),
134	)
135	const terms = topics
136		.toLowerCase()
137		.split(/[,;/]|\band\b/)
138		.map((term) => term.trim())
139		.filter(Boolean)
140	const matches = (session: AgendaSession) => {
141		const source =
142			`${session.decisionPillar || ''} ${session.title} ${session.topics} ${session.track}`.toLowerCase()
143		return terms.flatMap((term) =>
144			source.includes(term)
145				? [term]
146				: term
147						.split(/\W+/)
148						.filter(
149							(word) =>
150								/^(?:ai|data|skills|analytics|talent|pay|reward|trust|technology|wellbeing|culture|learning|recruitment|leadership)$/.test(
151									word,
152								) && new RegExp(`\\b${word}\\b`).test(source),
153						),
154		)
155	}
156	const candidates = all
157		.filter((session) => usable(session) && (!day || session.day === day))
158		.map((session) => ({
159			session,
160			score: selectedPillars.length
161				? Number(
162						selectedPillars.some(
163							(pillar) =>
164								pillar.id ===
165								(session.decisionPillarId ||
166									decisionPillar(session.decisionPillar || '')?.id),
167						),
168					)
169				: matches(session).length,
170		}))
171		.filter(({ score }) => score > 0)
172		.sort(
173			(a, b) =>
174				b.score - a.score || a.session.start.localeCompare(b.session.start),
175		)
176	const selected: AgendaPlanSession[] = []
177	const alternatives: AgendaPlanSession[] = []
178	const reasons = new Map<string, string>()
179	for (const { session } of candidates) {
180		const next = toPlanSession(session)
181		const matchedInterests = selectedPillars.length
182			? selectedPillars
183					.filter(
184						(p) =>
185							p.id ===
186							(session.decisionPillarId ||
187								decisionPillar(session.decisionPillar || '')?.id),
188					)
189					.map((p) => p.label)
190			: matches(session)
191		reasons.set(
192			next.url,
193			`matches your interest in ${matchedInterests.join(', ') || session.decisionPillar || topics}`,
194		)
195		if (selected.some((s) => s.url === next.url)) continue
196		// Ten minutes between rooms; no buffer needed for consecutive sessions in the same room.
197		const clash = selected.some(
198			(s) =>
199				sessionsOverlap(s, next) ||
200				(s.room !== next.room &&
201					Math.min(
202						Math.abs(Date.parse(next.start) - Date.parse(s.end)),
203						Math.abs(Date.parse(s.start) - Date.parse(next.end)),
204					) <
205						10 * 60_000),
206		)
207		if (clash) {
208			if (alternatives.length < 6) alternatives.push(next)
209		} else if (
210			selected.filter((s) => s.start.slice(0, 10) === session.day).length < 6
211		)
212			selected.push(next)
213	}
214	selected.sort((a, b) => a.start.localeCompare(b.start))
215	return {
216		event,
217		agendaPage,
218		sessions: selected,
219		alternatives,
220		text: formatPlan(event, selected, agendaPage, reasons),
221	}
222}
223
224/** Re-read selected IDs for email: never trust client-supplied agenda text or session details. */
225export function validateAgendaSelection(
226	all: AgendaSession[],
227	urls: string[],
228): AgendaPlanSession[] | null {
229	if (!urls.length || urls.length > 30 || new Set(urls).size !== urls.length)
230		return null
231	const selected: AgendaPlanSession[] = []
232	for (const url of urls) {
233		const session = all.find(
234			(candidate) => candidate.url === url && usable(candidate),
235		)
236		if (!session) return null
237		const next = toPlanSession(session)
238		if (selected.some((s) => sessionsOverlap(s, next))) return null
239		selected.push(next)
240	}
241	return selected.sort((a, b) => a.start.localeCompare(b.start))
242}
243