mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): skip markdown tables in speech output
This commit is contained in:
parent
f02d41cb21
commit
c95f04501d
2 changed files with 269 additions and 1 deletions
|
|
@ -14,4 +14,141 @@ describe('sanitizeTextForSpeech', () => {
|
|||
'Use git status after the change.'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips markdown table data while preserving surrounding human text', () => {
|
||||
const text = `Here is the quick takeaway: the totals remain unchanged.
|
||||
|
||||
| Item | Value | Notes |
|
||||
| --- | ---: | --- |
|
||||
| Example A | 10 | first row |
|
||||
| Example B | 20 | second row |
|
||||
|
||||
Full detail stays visible on screen.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe(
|
||||
'Here is the quick takeaway: the totals remain unchanged. Full detail stays visible on screen.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not strip prose that merely contains a pipe character', () => {
|
||||
const text = 'Use the summary first | keep the table on screen when it matters.'
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Use the summary first | keep the table on screen when it matters.')
|
||||
})
|
||||
|
||||
it('does not duplicate punctuation across paragraph breaks', () => {
|
||||
const text = `First sentence.
|
||||
|
||||
Second sentence.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('First sentence. Second sentence.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['markdown emphasis', '**First sentence.**\n\nSecond sentence.', 'First sentence. Second sentence.'],
|
||||
['a closing quote', '“First sentence.”\n\nSecond sentence.', '“First sentence.” Second sentence.'],
|
||||
['a closing parenthesis', '(First sentence.)\n\nSecond sentence.', '(First sentence.) Second sentence.']
|
||||
])('does not duplicate punctuation after %s', (_label, text, expected) => {
|
||||
expect(sanitizeTextForSpeech(text)).toBe(expected)
|
||||
})
|
||||
|
||||
it('skips markdown tables without leading and trailing pipes', () => {
|
||||
const text = `Main takeaway: total is unchanged.
|
||||
|
||||
Item | Value
|
||||
--- | ---:
|
||||
Example A | 10
|
||||
Example B | 20
|
||||
|
||||
Done.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Done.')
|
||||
})
|
||||
|
||||
it('skips markdown tables nested inside blockquotes', () => {
|
||||
const text = `Before the table.
|
||||
|
||||
> | Item | Value |
|
||||
> | --- | ---: |
|
||||
> | Example A | 10 |
|
||||
> | Example B | 20 |
|
||||
|
||||
After the table.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
|
||||
})
|
||||
|
||||
it('allows marker padding plus three spaces in blockquoted tables', () => {
|
||||
const text = `Before the table.
|
||||
|
||||
> | Item | Value |
|
||||
> | --- | ---: |
|
||||
> | Example A | 10 |
|
||||
|
||||
After the table.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
|
||||
})
|
||||
|
||||
it('skips explicit single-column markdown tables', () => {
|
||||
const text = `Before the table.
|
||||
|
||||
| Item |
|
||||
| --- |
|
||||
| Example A |
|
||||
|
||||
After the table.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
|
||||
})
|
||||
|
||||
it('preserves rows outside a table blockquote', () => {
|
||||
const text = `> | Item | Value |
|
||||
> | --- | ---: |
|
||||
> | Example A | 10 |
|
||||
Outside | prose`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Outside | prose')
|
||||
})
|
||||
|
||||
it('preserves malformed tables with mismatched column counts', () => {
|
||||
const text = `Heading | Detail
|
||||
--- | --- | ---
|
||||
Keep this prose.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toContain('Heading | Detail')
|
||||
})
|
||||
|
||||
it('skips GFM body rows whose cell counts differ from the header', () => {
|
||||
const text = `Before the table.
|
||||
|
||||
| Item | Value |
|
||||
| --- | ---: |
|
||||
| Example A |
|
||||
| Example B | 20 | ignored |
|
||||
|
||||
After the table.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
|
||||
})
|
||||
|
||||
it('skips tables containing escaped pipe characters', () => {
|
||||
const text = `Before the table.
|
||||
|
||||
| Item \\| detail | Value |
|
||||
| --- | ---: |
|
||||
| Example A | 10 |
|
||||
|
||||
After the table.`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
|
||||
})
|
||||
|
||||
it('preserves indented code that resembles a table', () => {
|
||||
const text = ` Item | Value
|
||||
--- | ---
|
||||
Example A | 10`
|
||||
|
||||
expect(sanitizeTextForSpeech(text)).toContain('Item | Value')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const CODE_BLOCK_SUMMARY = ' code block omitted '
|
|||
const INLINE_CODE_RE = /`([^`]+)`/g
|
||||
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g
|
||||
const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g
|
||||
const PUNCTUATED_PARAGRAPH_BREAK_RE = /([.!?])([*_~`>"'’”)}\]]*)[ \t]*\n{2,}[ \t]*/g
|
||||
const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g
|
||||
|
||||
const THINKING_PREFIX_RE =
|
||||
|
|
@ -12,16 +13,146 @@ const THINKING_PREFIX_RE =
|
|||
|
||||
const URL_RE = /\bhttps?:\/\/\S+/gi
|
||||
|
||||
const MARKDOWN_TABLE_DELIMITER_CELL_RE = /^:?-{3,}:?$/
|
||||
|
||||
interface MarkdownTableRow {
|
||||
blockquoteDepth: number
|
||||
cells: string[]
|
||||
}
|
||||
|
||||
function isUnescapedPipe(row: string, index: number): boolean {
|
||||
let backslashes = 0
|
||||
|
||||
for (let cursor = index - 1; cursor >= 0 && row[cursor] === '\\'; cursor -= 1) {
|
||||
backslashes += 1
|
||||
}
|
||||
|
||||
return backslashes % 2 === 0
|
||||
}
|
||||
|
||||
function splitMarkdownTableCells(row: string): string[] {
|
||||
const cells: string[] = []
|
||||
let cellStart = 0
|
||||
|
||||
for (let index = 0; index < row.length; index += 1) {
|
||||
if (row[index] === '|' && isUnescapedPipe(row, index)) {
|
||||
cells.push(row.slice(cellStart, index).trim())
|
||||
cellStart = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
cells.push(row.slice(cellStart).trim())
|
||||
|
||||
return cells
|
||||
}
|
||||
|
||||
function parseMarkdownTableRow(line: string): MarkdownTableRow | null {
|
||||
let row = line
|
||||
let blockquoteDepth = 0
|
||||
|
||||
while (true) {
|
||||
const indentation = row.match(/^[ \t]*/)?.[0] ?? ''
|
||||
|
||||
if (indentation.includes('\t') || indentation.length > 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
row = row.slice(indentation.length)
|
||||
|
||||
if (!row.startsWith('>')) {
|
||||
break
|
||||
}
|
||||
|
||||
blockquoteDepth += 1
|
||||
row = row.slice(1)
|
||||
|
||||
if (row.startsWith(' ')) {
|
||||
row = row.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
row = row.trimEnd()
|
||||
|
||||
const pipeIndexes = [...row.matchAll(/\|/g)].map(match => match.index).filter(index => isUnescapedPipe(row, index))
|
||||
|
||||
if (pipeIndexes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasLeadingPipe = pipeIndexes[0] === 0
|
||||
const hasTrailingPipe = pipeIndexes.at(-1) === row.length - 1
|
||||
|
||||
if (hasLeadingPipe) {
|
||||
row = row.slice(1)
|
||||
}
|
||||
|
||||
if (hasTrailingPipe) {
|
||||
row = row.slice(0, -1)
|
||||
}
|
||||
|
||||
const cells = splitMarkdownTableCells(row)
|
||||
|
||||
if (cells.length < 2 && !(hasLeadingPipe && hasTrailingPipe && cells.length === 1)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { blockquoteDepth, cells }
|
||||
}
|
||||
|
||||
function stripMarkdownTables(text: string): string {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||
const tableLines = new Set<number>()
|
||||
|
||||
let index = 1
|
||||
|
||||
while (index < lines.length) {
|
||||
const delimiterRow = parseMarkdownTableRow(lines[index])
|
||||
const headerRow = parseMarkdownTableRow(lines[index - 1])
|
||||
|
||||
if (
|
||||
!delimiterRow ||
|
||||
!headerRow ||
|
||||
!delimiterRow.cells.every(cell => MARKDOWN_TABLE_DELIMITER_CELL_RE.test(cell)) ||
|
||||
headerRow.cells.length !== delimiterRow.cells.length ||
|
||||
headerRow.blockquoteDepth !== delimiterRow.blockquoteDepth
|
||||
) {
|
||||
index += 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
tableLines.add(index - 1)
|
||||
tableLines.add(index)
|
||||
|
||||
let rowIndex = index + 1
|
||||
|
||||
for (; rowIndex < lines.length; rowIndex += 1) {
|
||||
const bodyRow = parseMarkdownTableRow(lines[rowIndex])
|
||||
|
||||
if (!bodyRow || bodyRow.blockquoteDepth !== delimiterRow.blockquoteDepth) {
|
||||
break
|
||||
}
|
||||
|
||||
tableLines.add(rowIndex)
|
||||
}
|
||||
|
||||
index = rowIndex
|
||||
}
|
||||
|
||||
return lines.filter((_, index) => !tableLines.has(index)).join('\n')
|
||||
}
|
||||
|
||||
function normalizeLineBreaks(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/(\p{L})-\n(\p{L})/gu, '$1$2')
|
||||
.replace(PUNCTUATED_PARAGRAPH_BREAK_RE, '$1$2 ')
|
||||
.replace(PARAGRAPH_BREAK_RE, '. ')
|
||||
.replace(SOFT_BREAK_RE, ' ')
|
||||
}
|
||||
|
||||
export function sanitizeTextForSpeech(text: string): string {
|
||||
return normalizeLineBreaks(text)
|
||||
return normalizeLineBreaks(stripMarkdownTables(text))
|
||||
.replace(FENCED_CODE_RE, CODE_BLOCK_SUMMARY)
|
||||
.replace(THINKING_PREFIX_RE, ' ')
|
||||
.replace(MARKDOWN_LINK_RE, '$1')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue