mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

fathom-topics.ts

Language TypeScript6.3 KB170 lines

config/fathom-topics.ts

  1. /**
  2. * Topic buckets for meeting action items: the human-edited knowledge that turns
  3. * a flat list into something you can open one piece of.
  4. *
  5. * A hand-maintained map and not something cleverer: buckets come from a table
  6. * a person edits, like the stage taxonomies and rosters already in this
  7. * directory. The alternatives were measured and rejected:
  8. *
  9. * Fathom has no topic field. `FathomActionItem` carries a description, an
  10. * assignee, a completion flag and a playback timestamp, nothing categorical.
  11. * `FathomMeeting` has a `meeting_type`, but it is unverified against this
  12. * account and is dropped at the collector, so nothing downstream has ever
  13. * seen it.
  14. *
  15. * Grouping by meeting title looks free and is not useful: in the observed
  16. * window roughly 40% of meetings are called "Impromptu Zoom Meeting", so the
  17. * largest button would carry the least meaning.
  18. *
  19. * Asking a model would put its judgment on the page as though it were data,
  20. * and would only refresh on a weekday morning.
  21. *
  22. * So the rules are visible and wrong-by-hand rather than wrong-by-inference. A
  23. * mis-bucketed item is a line in this file somebody can fix, and anything that
  24. * matches nothing lands in Uncategorized in full view: never hidden, never
  25. * guessed at. That bucket is the report on this file's coverage (a default
  26. * that stands in for knowledge has to be surfaced every run).
  27. *
  28. * Seeded from a real window: the meeting titles and the item vocabulary
  29. * actually present, not an imagined taxonomy. It is deliberately
  30. * short. A keyword that catches the wrong thing is worse than one that catches
  31. * nothing, because Uncategorized is honest and a wrong bucket is not.
  32. */
  33. export interface Topic {
  34. /** Shown on the button. Keep it short; these sit in a row. */
  35. label: string
  36. /**
  37. * Matched case-insensitively as whole words against the item text and its
  38. * meeting title. Order within the list does not matter; order of the list
  39. * does: first topic to match wins, so put the specific above the general.
  40. */
  41. keywords: string[]
  42. }
  43. /**
  44. * Order is the tiebreak and it is a human decision. An item saying "send Ines
  45. * the logo refs" matches both Creative and Outreach if both list a word in it;
  46. * whichever appears first here takes it. Moving a topic up changes what its
  47. * neighbours catch, which is why this is a list and not a map.
  48. */
  49. export const FATHOM_TOPICS: Topic[] = [
  50. {
  51. label: 'Creative & design',
  52. keywords: [
  53. 'image', 'images', 'imagery', 'logo', 'icon', 'icons', 'artwork', 'proof',
  54. 'mockup', 'layers', 'refs', 'design', 'creative', 'template', 'banner',
  55. 'photoshop', 'illustrator', 'kittl', 'render',
  56. ],
  57. },
  58. {
  59. label: 'Outreach & winback',
  60. keywords: [
  61. 'outreach', 'winback', 'campaign', 'sequence', 'prospect', 'cold',
  62. 'follow-up', 'followup', 'newsletter', 'blast', 'abandoned',
  63. ],
  64. },
  65. {
  66. label: 'AI & automation',
  67. keywords: [
  68. 'ai', 'claude', 'gpt', 'automation', 'automate', 'prompt', 'agent',
  69. 'workflow', 'extension', 'script', 'zapier',
  70. ],
  71. },
  72. {
  73. label: 'Pipeline & numbers',
  74. keywords: [
  75. 'pipeline', 'numbers', 'forecast', 'quota', 'revenue', 'deal', 'deals',
  76. 'lead', 'leads', 'metrics', 'report', 'dashboard', 'flywheel',
  77. ],
  78. },
  79. {
  80. label: 'Orders & production',
  81. keywords: [
  82. 'order', 'orders', 'shipment', 'shipping', 'vendor', 'production',
  83. 'invoice', 'fulfillment', 'sample', 'reprint', 'sku',
  84. ],
  85. },
  86. {
  87. label: 'Website & product',
  88. keywords: ['site', 'website', 'page', 'listing', 'catalog', 'checkout', 'seo'],
  89. },
  90. {
  91. label: 'Partner: Northgate',
  92. keywords: ['northgate'],
  93. },
  94. {
  95. label: 'People & leadership',
  96. keywords: ['headcount', 'comp', 'hiring', 'onboarding', 'review', 'one-on-one', '1:1'],
  97. },
  98. ]
  99. /**
  100. * Where anything that matched nothing goes.
  101. *
  102. * Not a failure state and not hidden. It is how this file reports its own
  103. * coverage: a big Uncategorized bucket means the map is behind the work, which
  104. * is a thing to fix rather than a thing to conceal. An item never disappears
  105. * because nobody wrote a keyword for it.
  106. */
  107. export const UNCATEGORIZED = 'Uncategorized'
  108. /** Attribute- and id-safe key for a label. */
  109. export const topicSlug = (label: string): string =>
  110. label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'topic'
  111. /**
  112. * Whole-word, case-insensitive. Not substring matching, for the same reason
  113. * `matchesFilter` refuses it: "ai" inside "email" and "detail",
  114. * "comp" inside "complete" and "company". A keyword that catches the wrong
  115. * thing is worse than one that catches nothing.
  116. */
  117. function mentions(haystack: string, keyword: string): boolean {
  118. const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  119. return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:[^a-z0-9]|$)`, 'i').test(haystack)
  120. }
  121. /**
  122. * The topic for one item. Matched against the item text and its meeting title,
  123. * because "send the refs" in a meeting called Design Review is a creative
  124. * item even though its own words say nothing.
  125. */
  126. export function topicFor(text: string, meeting: string): string {
  127. const hay = `${text} ${meeting}`
  128. for (const t of FATHOM_TOPICS) {
  129. for (const k of t.keywords) if (mentions(hay, k)) return t.label
  130. }
  131. return UNCATEGORIZED
  132. }
  133. export interface TopicBucket<T> {
  134. label: string
  135. slug: string
  136. items: T[]
  137. }
  138. /**
  139. * Group items into buckets, ordered as FATHOM_TOPICS is, with Uncategorized
  140. * last. Empty topics are omitted: a button for a bucket with nothing in it is
  141. * a control that does nothing, and on a per-person surface it would also
  142. * advertise topics from meetings this viewer was not in.
  143. *
  144. * Every item lands in exactly one bucket, so the counts sum to the input length
  145. * and the header can say so (a number and the list under it must be able to
  146. * account for each other).
  147. */
  148. export function bucketByTopic<T extends { text: string; meeting: string }>(
  149. items: T[],
  150. ): TopicBucket<T>[] {
  151. const by = new Map<string, T[]>()
  152. for (const item of items) {
  153. const label = topicFor(item.text, item.meeting)
  154. const list = by.get(label)
  155. if (list) list.push(item)
  156. else by.set(label, [item])
  157. }
  158. const ordered = [...FATHOM_TOPICS.map((t) => t.label), UNCATEGORIZED]
  159. return ordered
  160. .filter((label) => by.has(label))
  161. .map((label) => ({ label, slug: topicSlug(label), items: by.get(label)! }))
  162. }