Skip to content

Commit bfb57ad

Browse files
committed
Strip bio preambles in profile snippets: remove introductory scaffolding like status notes, headings, and labels for cleaner and more relevant previews.
1 parent 19c3758 commit bfb57ad

3 files changed

Lines changed: 285 additions & 5 deletions

File tree

backend/api/src/create-user-and-profile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async
113113
avatar_url: avatarUrl,
114114
is_banned_from_posting: Boolean(
115115
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
116-
(ip && bannedIpAddresses.includes(ip)),
116+
(ip && bannedIpAddresses.includes(ip)),
117117
),
118118
data: {},
119119
})

backend/api/src/get-profiles.ts

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,17 +217,168 @@ const PROFILE_CARD_COLS = [
217217
*/
218218
const BIO_SNIPPET_CHARS = 600
219219

220+
/** A block whose whole text is bold and no longer than this reads as a heading, not as prose. */
221+
const HEADING_LIKE_CHARS = 80
222+
223+
/** Openers we treat as an aside, mapped to the closer that ends them. */
224+
const BRACKET_PAIRS: Record<string, string> = {'(': ')', '[': ']', '{': '}'}
225+
226+
/**
227+
* Leading blocks that are pure scaffolding: a section label the bio itself repeats ("About me"), or
228+
* an editorial note about the document rather than about the person. Matched case-insensitively
229+
* against the block's whole text, so a paragraph merely *starting* with "Note that..." is prose and
230+
* survives.
231+
*/
232+
const LABEL_BLOCK = /^(about me|summary)\s*[:.]?$/i
233+
const META_NOTE_BLOCK = /^(note|nb|disclaimer|edit|update|ps)\b\s*[:\-]/i
234+
235+
/** Every text node under `node`, in document order. */
236+
const textNodes = (node: JSONContent): JSONContent[] =>
237+
node.type === 'text' ? [node] : (node.content ?? []).flatMap(textNodes)
238+
239+
/**
240+
* The block's text with marks flattened. Inline siblings are joined without a separator (a bolded
241+
* word mid-sentence is its own text node); anything else — list items, table cells — gets a space so
242+
* words from different blocks don't run together.
243+
*/
244+
const blockText = (node: JSONContent): string => {
245+
if (node.type === 'text') return node.text ?? ''
246+
const children = node.content ?? []
247+
const parts = children.map(blockText)
248+
const separator = children.every((c) => c.type === 'text') ? '' : ' '
249+
return parts.join(separator).replace(/\s+/g, ' ').trim()
250+
}
251+
252+
/** Whether every non-blank text node in the block carries the bold mark. */
253+
const isAllBold = (node: JSONContent) => {
254+
const nodes = textNodes(node).filter((n) => (n.text ?? '').trim())
255+
return nodes.length > 0 && nodes.every((n) => n.marks?.some((m) => m.type === 'bold'))
256+
}
257+
258+
/**
259+
* Index of the bracket that closes the one opening `text`, or -1 if `text` doesn't open with a
260+
* bracket / never closes it. Nesting is counted so `[updated (2 Aug)]` closes at the last character.
261+
*/
262+
const closingBracketIndex = (text: string) => {
263+
const open = text[0]
264+
const close = BRACKET_PAIRS[open]
265+
if (!close) return -1
266+
let depth = 0
267+
for (let i = 0; i < text.length; i++) {
268+
if (text[i] === open) depth++
269+
else if (text[i] === close && --depth === 0) return i
270+
}
271+
return -1
272+
}
273+
274+
/** A block that is nothing but a bracketed aside, e.g. "[profile updated 2 Aug 2026]". */
275+
const isBracketedBlock = (text: string) => closingBracketIndex(text) === text.length - 1
276+
277+
/**
278+
* Drops a bracketed aside that opens the bio inline — the same "[profile updated ...]" preamble, but
279+
* written at the head of the first real paragraph instead of on its own line. Repeats, since bios
280+
* stack them ("[updated 2 Aug] (he/him) Hi, ...").
281+
*/
282+
const stripLeadingBrackets = (text: string) => {
283+
let out = text.trim()
284+
for (;;) {
285+
const end = closingBracketIndex(out)
286+
if (end <= 0 || end === out.length - 1) return out
287+
out = out.slice(end + 1).trim()
288+
}
289+
}
290+
291+
/**
292+
* Whether a *leading* block is preliminary — scaffolding a reader skims past to reach the bio. Only
293+
* ever asked about blocks before the first real prose, so a heading deeper in the bio is left alone.
294+
*/
295+
const isPreliminaryBlock = (node: JSONContent, text: string) =>
296+
!text ||
297+
node.type === 'heading' ||
298+
isBracketedBlock(text) ||
299+
LABEL_BLOCK.test(text) ||
300+
META_NOTE_BLOCK.test(text) ||
301+
// "1. Introduction" / "Introduction" written as bold prose rather than as a heading node.
302+
(isAllBold(node) && text.length <= HEADING_LIKE_CHARS)
303+
304+
/**
305+
* At most this many leading lines are dropped. A bio that is heading-shaped all the way down is
306+
* more likely to be unusual formatting than to be all preamble, and the snippet should still show
307+
* something from the top of it.
308+
*/
309+
const MAX_DROPPED_BLOCKS = 5
310+
311+
/**
312+
* The visual lines of a block, as pseudo-blocks. Bios pasted from a doc routinely arrive as one
313+
* paragraph whose "paragraphs" are `hardBreak` pairs, so the preamble ("[profile updated 2 Aug
314+
* 2026]", "1. Introduction") sits inside the same node as the bio itself — splitting here is what
315+
* lets {@link isPreliminaryBlock} see those lines at all. Blocks without a hard break are their own
316+
* single line.
317+
*/
318+
const blockLines = (node: JSONContent): JSONContent[] => {
319+
const children = node.content ?? []
320+
if (!children.some((c) => c.type === 'hardBreak')) return [node]
321+
const lines: JSONContent[] = []
322+
let current: JSONContent[] = []
323+
for (const child of children) {
324+
if (child.type === 'hardBreak') {
325+
if (current.length) lines.push({...node, content: current})
326+
current = []
327+
} else {
328+
current.push(child)
329+
}
330+
}
331+
if (current.length) lines.push({...node, content: current})
332+
return lines
333+
}
334+
335+
/**
336+
* The bio with its preamble removed: title headings, "About me" labels, bracketed status notes, and
337+
* editorial notes about the document itself (Richard's date-me doc opens with a parenthetical about
338+
* reading it on Google Docs). Falls back to the untouched document when stripping would leave
339+
* nothing — a bio that is *only* a heading is better shown than blanked.
340+
*
341+
* Scans line by line (see {@link blockLines}) and stops at the first line of real prose, so only a
342+
* leading run is ever dropped and a heading further down the bio is left alone.
343+
*/
344+
const stripBioPreamble = (bio: JSONContent) => {
345+
const blocks = bio?.content
346+
if (!Array.isArray(blocks)) return bio
347+
let dropped = 0
348+
for (let i = 0; i < blocks.length; i++) {
349+
const lines = blockLines(blocks[i])
350+
let start = 0
351+
while (
352+
start < lines.length &&
353+
dropped < MAX_DROPPED_BLOCKS &&
354+
isPreliminaryBlock(lines[start], blockText(lines[start]))
355+
) {
356+
start++
357+
dropped++
358+
}
359+
// Prose found in this block: keep its surviving lines and everything after it.
360+
const kept = lines.slice(start)
361+
if (kept.length) return {...bio, content: [...kept, ...blocks.slice(i + 1)]}
362+
// The whole block was preamble — carry the drop count into the next one.
363+
}
364+
return bio
365+
}
366+
220367
/**
221368
* The card renders `parseJsonContentToText(profile.bio)` clamped to a few lines, so shipping the whole
222369
* rich-text document is wasted. Computed here rather than read from the `bio_text` column: that column
223370
* is built with `string_agg(DISTINCT ...)` for search, which reorders and dedupes the text nodes — fine
224371
* for a tsvector, wrong for something a person reads.
372+
*
373+
* The snippet starts at the first block of actual bio (see {@link stripBioPreamble}) rather than at
374+
* the top of the document, so the few lines a card shows aren't spent on a title or a changelog note.
225375
*/
226376
const toBioSnippet = (bio: unknown) => {
227-
const text = parseJsonContentToText(bio as JSONContent)
228-
.replace(/\s+/g, ' ')
229-
.trim()
230-
return text.length > BIO_SNIPPET_CHARS ? `${text.slice(0, BIO_SNIPPET_CHARS)}…` : text
377+
const content = bio as JSONContent
378+
const stripped = typeof content === 'object' && content ? stripBioPreamble(content) : content
379+
const text = stripLeadingBrackets(parseJsonContentToText(stripped).replace(/\s+/g, ' ').trim())
380+
const snippet = text || parseJsonContentToText(content).replace(/\s+/g, ' ').trim()
381+
return snippet.length > BIO_SNIPPET_CHARS ? `${snippet.slice(0, BIO_SNIPPET_CHARS)}…` : snippet
231382
}
232383

233384
let profileCols: any

backend/api/tests/unit/get-profiles.unit.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,135 @@ describe('loadProfiles', () => {
392392

393393
expect(profiles[0].bio_snippet).toEqual(`${'a'.repeat(600)}…`)
394394
})
395+
396+
describe('preamble stripping', () => {
397+
const para = (text: string, marks?: {type: string}[]) => ({
398+
type: 'paragraph',
399+
content: [{type: 'text', text, ...(marks ? {marks} : {})}],
400+
})
401+
const bold = [{type: 'bold'}]
402+
403+
const snippetOf = async (content: any[]) => {
404+
;(mockPg.map as jest.Mock).mockResolvedValue([{bio: {type: 'doc', content}} as any])
405+
;(mockPg.one as jest.Mock).mockResolvedValue(1)
406+
const {profiles} = await profilesModule.loadProfiles({projection: 'card'})
407+
return profiles[0].bio_snippet
408+
}
409+
410+
it('drops an opening editorial note about the document', async () => {
411+
const note =
412+
'(NOTE: This date-me doc was designed to be read on google docs with comments enabled. ' +
413+
'For a smoother experience, read the original here: https://docs.google.com/document/d/1Cuyr3)'
414+
415+
expect(await snippetOf([para(note), para('I am a software engineer.')])).toEqual(
416+
'I am a software engineer.',
417+
)
418+
})
419+
420+
it('drops an opening note even when it is not parenthesised', async () => {
421+
expect(
422+
await snippetOf([para('NOTE: read the original doc instead.'), para('Hi, I am Sam.')]),
423+
).toEqual('Hi, I am Sam.')
424+
})
425+
426+
it('drops a leading bracketed status line', async () => {
427+
expect(
428+
await snippetOf([para('[profile updated 2 Aug 2026]'), para('Hi, I am Raskia.')]),
429+
).toEqual('Hi, I am Raskia.')
430+
})
431+
432+
it('drops a leading bracketed aside written inline with the first paragraph', async () => {
433+
expect(await snippetOf([para('[profile updated 2 Aug 2026] Hi, I am Raskia.')])).toEqual(
434+
'Hi, I am Raskia.',
435+
)
436+
})
437+
438+
it('drops a leading heading node', async () => {
439+
expect(
440+
await snippetOf([
441+
{type: 'heading', attrs: {level: 1}, content: [{type: 'text', text: 'Introduction'}]},
442+
para('I live in Melbourne.'),
443+
]),
444+
).toEqual('I live in Melbourne.')
445+
})
446+
447+
it('drops a bold enumerated heading that is not a heading node', async () => {
448+
expect(
449+
await snippetOf([para('1. Introduction', bold), para('I live in Melbourne.')]),
450+
).toEqual('I live in Melbourne.')
451+
})
452+
453+
it('drops an "About me" or "Summary" label', async () => {
454+
expect(await snippetOf([para('About Me'), para('I am Otito.')])).toEqual('I am Otito.')
455+
expect(await snippetOf([para('summary:'), para('I am Otito.')])).toEqual('I am Otito.')
456+
})
457+
458+
it('drops several stacked preliminaries', async () => {
459+
expect(
460+
await snippetOf([
461+
{type: 'heading', attrs: {level: 1}, content: [{type: 'text', text: 'My date-me doc'}]},
462+
para('[updated 2 Aug 2026]'),
463+
para('About me', bold),
464+
para('I am Otito.'),
465+
]),
466+
).toEqual('I am Otito.')
467+
})
468+
469+
it('keeps prose that merely starts with a bold phrase or a bracketed word', async () => {
470+
expect(
471+
await snippetOf([
472+
{
473+
type: 'paragraph',
474+
content: [
475+
{type: 'text', text: 'Hi!', marks: bold},
476+
{type: 'text', text: " I'm a software engineer in Melbourne."},
477+
],
478+
},
479+
]),
480+
).toEqual("Hi! I'm a software engineer in Melbourne.")
481+
})
482+
483+
it('keeps a heading deeper in the bio', async () => {
484+
expect(
485+
await snippetOf([
486+
para('I am Otito.'),
487+
{type: 'heading', attrs: {level: 2}, content: [{type: 'text', text: 'My values'}]},
488+
para('Honesty.'),
489+
]),
490+
).toEqual('I am Otito. My values Honesty.')
491+
})
492+
493+
it('drops preliminary lines separated by hard breaks inside one paragraph', async () => {
494+
// The real shape of a bio pasted out of a doc: no paragraph nodes, just hardBreak pairs.
495+
const br = {type: 'hardBreak'}
496+
497+
expect(
498+
await snippetOf([
499+
{
500+
type: 'paragraph',
501+
content: [
502+
{type: 'text', text: '[profile updated '},
503+
{type: 'text', text: '2 Aug 2026', marks: bold},
504+
{type: 'text', text: ']'},
505+
br,
506+
br,
507+
{type: 'text', text: '1. Introduction', marks: bold},
508+
br,
509+
br,
510+
{type: 'text', text: 'Hello!'},
511+
br,
512+
br,
513+
{type: 'text', text: 'I am a 30 y/o engineer.'},
514+
],
515+
},
516+
]),
517+
).toEqual('Hello! I am a 30 y/o engineer.')
518+
})
519+
520+
it('falls back to the whole bio when it is nothing but a preliminary', async () => {
521+
expect(await snippetOf([para('About me')])).toEqual('About me')
522+
})
523+
})
395524
})
396525

397526
describe('when ordering by compatibility score', () => {

0 commit comments

Comments
 (0)