ManimCat / src /services /patch-response-parser.ts
littlebrian's picture
Sync from enhance: d795216 feat: implement patch response parser for search and replace functionality
9bd4242
Raw
History Blame Contribute Delete
4.86 kB
import { createLogger } from '../utils/logger'
const logger = createLogger('PatchResponseParser')
export interface SearchReplacePatch {
originalSnippet: string
replacementSnippet: string
}
export interface SearchReplacePatchSet {
patches: SearchReplacePatch[]
}
type NoopBehavior = 'skip' | 'error'
export interface ParseSearchReplacePatchResponseOptions {
responseLabel: string
blockLabel: string
emptyError: string
noPatchBlocksError: string
noopBehavior?: NoopBehavior
noopError?: string
allowMalformedTrailingBlock?: boolean
}
function stripCodeFence(text: string): string {
return text
.replace(/^```(?:json|text|python)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim()
}
function trimPatchBoundary(text: string): string {
return text
.replace(/^\s*\r?\n/, '')
.replace(/\r?\n\s*$/, '')
.replace(/\r\n/g, '\n')
}
function buildMissingMarkerError(blockLabel: string, marker: string): string {
return `${blockLabel} missing ${marker} marker`
}
function warnMalformedTrailingBlock(
responseLabel: string,
blockIndex: number,
missingMarker: string,
parsedPatchCount: number,
remainderPreview: string
): void {
logger.warn(`${responseLabel} ignored malformed trailing patch block`, {
blockIndex,
missingMarker,
parsedPatchCount,
remainderPreview
})
}
export function parseSearchReplacePatchResponse(
text: string,
options: ParseSearchReplacePatchResponseOptions
): SearchReplacePatchSet {
const normalized = stripCodeFence(text)
if (!normalized) {
throw new Error(options.emptyError)
}
if (/^\s*<!DOCTYPE\s+html/i.test(normalized) || /^\s*<html/i.test(normalized)) {
throw new Error(`${options.responseLabel} response was HTML, not patch text`)
}
const patches: SearchReplacePatch[] = []
let skippedNoopCount = 0
let blockCount = 0
let cursor = 0
const hasPatchMarkers =
normalized.includes('[[PATCH]]') ||
normalized.includes('[[SEARCH]]') ||
normalized.includes('[[REPLACE]]') ||
normalized.includes('[[END]]')
while (true) {
const patchStart = normalized.indexOf('[[PATCH]]', cursor)
if (patchStart < 0) {
break
}
blockCount += 1
const searchStart = normalized.indexOf('[[SEARCH]]', patchStart + '[[PATCH]]'.length)
if (searchStart < 0) {
if (options.allowMalformedTrailingBlock && patches.length > 0) {
warnMalformedTrailingBlock(
options.responseLabel,
blockCount,
'[[SEARCH]]',
patches.length,
normalized.slice(patchStart, patchStart + 300)
)
break
}
throw new Error(buildMissingMarkerError(options.blockLabel, '[[SEARCH]]'))
}
const replaceStart = normalized.indexOf('[[REPLACE]]', searchStart + '[[SEARCH]]'.length)
if (replaceStart < 0) {
if (options.allowMalformedTrailingBlock && patches.length > 0) {
warnMalformedTrailingBlock(
options.responseLabel,
blockCount,
'[[REPLACE]]',
patches.length,
normalized.slice(patchStart, patchStart + 300)
)
break
}
throw new Error(buildMissingMarkerError(options.blockLabel, '[[REPLACE]]'))
}
const endStart = normalized.indexOf('[[END]]', replaceStart + '[[REPLACE]]'.length)
if (endStart < 0) {
if (options.allowMalformedTrailingBlock && patches.length > 0) {
warnMalformedTrailingBlock(
options.responseLabel,
blockCount,
'[[END]]',
patches.length,
normalized.slice(patchStart, patchStart + 300)
)
break
}
throw new Error(buildMissingMarkerError(options.blockLabel, '[[END]]'))
}
const originalSnippet = trimPatchBoundary(
normalized.slice(searchStart + '[[SEARCH]]'.length, replaceStart)
)
const replacementSnippet = trimPatchBoundary(
normalized.slice(replaceStart + '[[REPLACE]]'.length, endStart)
)
if (!originalSnippet.trim()) {
throw new Error(`${options.blockLabel} missing SEARCH content`)
}
if (originalSnippet === replacementSnippet) {
if (options.noopBehavior === 'skip') {
skippedNoopCount += 1
cursor = endStart + '[[END]]'.length
continue
}
throw new Error(options.noopError || `${options.responseLabel} patch produced no change`)
}
patches.push({ originalSnippet, replacementSnippet })
cursor = endStart + '[[END]]'.length
}
if (skippedNoopCount > 0) {
logger.warn(`${options.responseLabel} skipped no-op SEARCH/REPLACE blocks`, {
skippedNoopCount
})
}
if (blockCount > 0) {
return { patches }
}
if (hasPatchMarkers) {
throw new Error(`${options.responseLabel} markers detected but no complete patch block could be parsed`)
}
throw new Error(options.noPatchBlocksError)
}