fathom-topics.ts
Language TypeScript6.3 KB170 lines
/*** Topic buckets for meeting action items: the human-edited knowledge that turns* a flat list into something you can open one piece of.** A hand-maintained map and not something cleverer: buckets come from a table* a person edits, like the stage taxonomies and rosters already in this* directory. The alternatives were measured and rejected:** Fathom has no topic field. `FathomActionItem` carries a description, an* assignee, a completion flag and a playback timestamp, nothing categorical.* `FathomMeeting` has a `meeting_type`, but it is unverified against this* account and is dropped at the collector, so nothing downstream has ever* seen it.** Grouping by meeting title looks free and is not useful: in the observed* window roughly 40% of meetings are called "Impromptu Zoom Meeting", so the* largest button would carry the least meaning.** Asking a model would put its judgment on the page as though it were data,* and would only refresh on a weekday morning.** So the rules are visible and wrong-by-hand rather than wrong-by-inference. A* mis-bucketed item is a line in this file somebody can fix, and anything that* matches nothing lands in Uncategorized in full view: never hidden, never* guessed at. That bucket is the report on this file's coverage (a default* that stands in for knowledge has to be surfaced every run).** Seeded from a real window: the meeting titles and the item vocabulary* actually present, not an imagined taxonomy. It is deliberately* short. A keyword that catches the wrong thing is worse than one that catches* nothing, because Uncategorized is honest and a wrong bucket is not.*/export interface Topic {/** Shown on the button. Keep it short; these sit in a row. */label: string/*** Matched case-insensitively as whole words against the item text and its* meeting title. Order within the list does not matter; order of the list* does: first topic to match wins, so put the specific above the general.*/keywords: string[]}/*** Order is the tiebreak and it is a human decision. An item saying "send Ines* the logo refs" matches both Creative and Outreach if both list a word in it;* whichever appears first here takes it. Moving a topic up changes what its* neighbours catch, which is why this is a list and not a map.*/export const FATHOM_TOPICS: Topic[] = [{label: 'Creative & design',keywords: ['image', 'images', 'imagery', 'logo', 'icon', 'icons', 'artwork', 'proof','mockup', 'layers', 'refs', 'design', 'creative', 'template', 'banner','photoshop', 'illustrator', 'kittl', 'render',],},{label: 'Outreach & winback',keywords: ['outreach', 'winback', 'campaign', 'sequence', 'prospect', 'cold','follow-up', 'followup', 'newsletter', 'blast', 'abandoned',],},{label: 'AI & automation',keywords: ['ai', 'claude', 'gpt', 'automation', 'automate', 'prompt', 'agent','workflow', 'extension', 'script', 'zapier',],},{label: 'Pipeline & numbers',keywords: ['pipeline', 'numbers', 'forecast', 'quota', 'revenue', 'deal', 'deals','lead', 'leads', 'metrics', 'report', 'dashboard', 'flywheel',],},{label: 'Orders & production',keywords: ['order', 'orders', 'shipment', 'shipping', 'vendor', 'production','invoice', 'fulfillment', 'sample', 'reprint', 'sku',],},{label: 'Website & product',keywords: ['site', 'website', 'page', 'listing', 'catalog', 'checkout', 'seo'],},{label: 'Partner: Northgate',keywords: ['northgate'],},{label: 'People & leadership',keywords: ['headcount', 'comp', 'hiring', 'onboarding', 'review', 'one-on-one', '1:1'],},]/*** Where anything that matched nothing goes.** Not a failure state and not hidden. It is how this file reports its own* coverage: a big Uncategorized bucket means the map is behind the work, which* is a thing to fix rather than a thing to conceal. An item never disappears* because nobody wrote a keyword for it.*/export const UNCATEGORIZED = 'Uncategorized'/** Attribute- and id-safe key for a label. */export const topicSlug = (label: string): string =>label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'topic'/*** Whole-word, case-insensitive. Not substring matching, for the same reason* `matchesFilter` refuses it: "ai" inside "email" and "detail",* "comp" inside "complete" and "company". A keyword that catches the wrong* thing is worse than one that catches nothing.*/function mentions(haystack: string, keyword: string): boolean {const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:[^a-z0-9]|$)`, 'i').test(haystack)}/*** The topic for one item. Matched against the item text and its meeting title,* because "send the refs" in a meeting called Design Review is a creative* item even though its own words say nothing.*/export function topicFor(text: string, meeting: string): string {const hay = `${text} ${meeting}`for (const t of FATHOM_TOPICS) {for (const k of t.keywords) if (mentions(hay, k)) return t.label}return UNCATEGORIZED}export interface TopicBucket<T> {label: stringslug: stringitems: T[]}/*** Group items into buckets, ordered as FATHOM_TOPICS is, with Uncategorized* last. Empty topics are omitted: a button for a bucket with nothing in it is* a control that does nothing, and on a per-person surface it would also* advertise topics from meetings this viewer was not in.** Every item lands in exactly one bucket, so the counts sum to the input length* and the header can say so (a number and the list under it must be able to* account for each other).*/export function bucketByTopic<T extends { text: string; meeting: string }>(items: T[],): TopicBucket<T>[] {const by = new Map<string, T[]>()for (const item of items) {const label = topicFor(item.text, item.meeting)const list = by.get(label)if (list) list.push(item)else by.set(label, [item])}const ordered = [...FATHOM_TOPICS.map((t) => t.label), UNCATEGORIZED]return ordered.filter((label) => by.has(label)).map((label) => ({ label, slug: topicSlug(label), items: by.get(label)! }))}