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