diff --git a/src/tools/council-archive/council-response-extractor.test.ts b/src/tools/council-archive/council-response-extractor.test.ts index 03f2c1aa6..13a6c5f18 100644 --- a/src/tools/council-archive/council-response-extractor.test.ts +++ b/src/tools/council-archive/council-response-extractor.test.ts @@ -84,6 +84,75 @@ describe("extractCouncilResponse", () => { }) }) + describe("#given literal opening AND closing tags appear inside the actual response body", () => { + it("#then extracts the full structural response, not the literal mention", () => { + const text = [ + "", + "## Finding 1: Triplicated Tag Constants", + "- Evidence:", + ' - `council-response-extractor.ts:1-2`: `export const OPENING_TAG = ""`, `export const CLOSING_TAG = ""`', + "## Finding 2: Another issue", + "Some more analysis text here.", + "", + ].join("\n") + const result = extractCouncilResponse(text) + + expect(result).toEqual({ + has_response: true, + response_complete: true, + result: [ + "## Finding 1: Triplicated Tag Constants", + "- Evidence:", + ' - `council-response-extractor.ts:1-2`: `export const OPENING_TAG = ""`, `export const CLOSING_TAG = ""`', + "## Finding 2: Another issue", + "Some more analysis text here.", + ].join("\n"), + }) + }) + }) + + describe("#given multiple literal tag mentions scattered inside the response body", () => { + it("#then extracts the full structural response ignoring all literal mentions", () => { + const text = [ + "preamble text", + "", + "The system uses for opening.", + "And for closing.", + "Also mentions again here.", + "Final analysis paragraph.", + "", + ].join("\n") + const result = extractCouncilResponse(text) + + expect(result).toEqual({ + has_response: true, + response_complete: true, + result: [ + "The system uses for opening.", + "And for closing.", + "Also mentions again here.", + "Final analysis paragraph.", + ].join("\n"), + }) + }) + }) + + describe("#given a complete pair followed by a trailing incomplete opening tag", () => { + it("#then returns the incomplete trailing content as partial response", () => { + const text = [ + "first complete", + "partial trailing content", + ].join("\n") + const result = extractCouncilResponse(text) + + expect(result).toEqual({ + has_response: true, + response_complete: false, + result: "partial trailing content", + }) + }) + }) + describe("#given an empty string", () => { it("#then returns has_response false and null result", () => { const result = extractCouncilResponse("") diff --git a/src/tools/council-archive/council-response-extractor.ts b/src/tools/council-archive/council-response-extractor.ts index 67f0c91fa..6c5aba9a9 100644 --- a/src/tools/council-archive/council-response-extractor.ts +++ b/src/tools/council-archive/council-response-extractor.ts @@ -10,22 +10,104 @@ export interface CouncilResponseExtraction { } export function extractCouncilResponse(fullText: string): CouncilResponseExtraction { - const lastOpenIdx = fullText.lastIndexOf(OPENING_TAG) - if (lastOpenIdx === -1) { - return { has_response: false, response_complete: false, result: null } - } + const lastCloseIdx = findLastStructuralClose(fullText) - const contentStart = lastOpenIdx + OPENING_TAG.length - const closingAfterLastOpen = fullText.indexOf(CLOSING_TAG, contentStart) - - if (closingAfterLastOpen === -1) { - const partial = fullText.slice(contentStart).trim() + if (lastCloseIdx === -1) { + const lastOpenIdx = findLastStructuralOpen(fullText) + if (lastOpenIdx === -1) { + return { has_response: false, response_complete: false, result: null } + } + const partial = fullText.slice(lastOpenIdx + OPENING_TAG.length).trim() return { has_response: true, response_complete: false, result: partial || null } } - const content = fullText.slice(contentStart, closingAfterLastOpen).trim() + const openAfterLastClose = findFirstStructuralOpenAfter(fullText, lastCloseIdx + CLOSING_TAG.length) + if (openAfterLastClose !== -1) { + const partial = fullText.slice(openAfterLastClose + OPENING_TAG.length).trim() + return { has_response: true, response_complete: false, result: partial || null } + } + + const matchingOpenIdx = findLastStructuralOpenBefore(fullText, lastCloseIdx) + if (matchingOpenIdx === -1) { + return { has_response: false, response_complete: false, result: null } + } + + const content = fullText.slice(matchingOpenIdx + OPENING_TAG.length, lastCloseIdx).trim() if (content.length < MIN_RESPONSE_LENGTH) { return { has_response: false, response_complete: true, result: content } } return { has_response: true, response_complete: true, result: content } } + +function isStructuralOpen(text: string, idx: number): boolean { + return idx === 0 || text[idx - 1] === "\n" +} + +function isStructuralClose(text: string, idx: number): boolean { + const afterIdx = idx + CLOSING_TAG.length + return afterIdx === text.length || text[afterIdx] === "\n" +} + +function findLastStructuralClose(text: string): number { + let searchFrom = text.length + while (searchFrom >= 0) { + const idx = text.lastIndexOf(CLOSING_TAG, searchFrom - 1) + if (idx === -1) return -1 + if (isStructuralClose(text, idx)) return idx + searchFrom = idx + } + return -1 +} + +function findLastStructuralOpen(text: string): number { + let searchFrom = text.length + while (searchFrom >= 0) { + const idx = text.lastIndexOf(OPENING_TAG, searchFrom - 1) + if (idx === -1) return -1 + if (isStructuralOpen(text, idx)) return idx + searchFrom = idx + } + return -1 +} + +function findFirstStructuralOpenAfter(text: string, fromIdx: number): number { + let searchFrom = fromIdx + while (searchFrom < text.length) { + const idx = text.indexOf(OPENING_TAG, searchFrom) + if (idx === -1) return -1 + if (isStructuralOpen(text, idx)) return idx + searchFrom = idx + OPENING_TAG.length + } + return -1 +} + +function findLastStructuralOpenBefore(text: string, beforeIdx: number): number { + let searchFrom = beforeIdx + let nestedCloseCount = 0 + + while (searchFrom > 0) { + const openIdx = text.lastIndexOf(OPENING_TAG, searchFrom - 1) + const closeIdx = text.lastIndexOf(CLOSING_TAG, searchFrom - 1) + + if (openIdx === -1 && closeIdx === -1) return -1 + + if (closeIdx > openIdx) { + if (isStructuralClose(text, closeIdx)) { + nestedCloseCount += 1 + } + searchFrom = closeIdx + continue + } + + if (!isStructuralOpen(text, openIdx)) { + searchFrom = openIdx + continue + } + + if (nestedCloseCount === 0) return openIdx + nestedCloseCount -= 1 + searchFrom = openIdx + } + + return -1 +}