mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
fix(media-understanding): parse nested Gemini output JSON (#96432)
This commit is contained in:
parent
31e941c3fc
commit
793b604b23
2 changed files with 211 additions and 8 deletions
100
packages/media-understanding-common/src/output-extract.test.ts
Normal file
100
packages/media-understanding-common/src/output-extract.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Media Understanding Common tests cover provider output extraction behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractGeminiResponse } from "./output-extract.js";
|
||||
|
||||
describe("extractGeminiResponse", () => {
|
||||
it("extracts the response from noisy output with nested JSON objects", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
[
|
||||
"debug: invoking gemini",
|
||||
JSON.stringify({
|
||||
response: "a useful description",
|
||||
usage: {
|
||||
inputTokens: 12,
|
||||
outputTokens: 4,
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
),
|
||||
).toBe("a useful description");
|
||||
});
|
||||
|
||||
it("returns null for an incomplete JSON object", () => {
|
||||
expect(extractGeminiResponse("{")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores unmatched quotes in noisy output before the JSON object", () => {
|
||||
expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores braces inside quoted noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("ignores shell-quoted JSON-like noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat apostrophes inside noisy words as quote delimiters", () => {
|
||||
expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("resynchronizes after an unmatched brace in noisy output", () => {
|
||||
expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok");
|
||||
});
|
||||
|
||||
it("preserves brace-heavy response text", () => {
|
||||
const response = "{".repeat(33);
|
||||
expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response);
|
||||
});
|
||||
|
||||
it("extracts pretty-printed JSON output", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
JSON.stringify(
|
||||
{
|
||||
response: "pretty response",
|
||||
usage: { inputTokens: 12 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
).toBe("pretty response");
|
||||
});
|
||||
|
||||
it("preserves pretty-printed object elements inside arrays", () => {
|
||||
expect(
|
||||
extractGeminiResponse(
|
||||
JSON.stringify(
|
||||
{
|
||||
response: "array response",
|
||||
items: [{ id: 1 }, { id: 2 }],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
).toBe("array response");
|
||||
});
|
||||
|
||||
it("does not accept an inner response from a malformed trailing object", () => {
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe(
|
||||
"good",
|
||||
);
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good");
|
||||
});
|
||||
|
||||
it("ignores a nested response inside an unfinished outer object", () => {
|
||||
expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not promote a child from a malformed outer object", () => {
|
||||
expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good");
|
||||
expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull();
|
||||
expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe(
|
||||
"good",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,16 +3,119 @@
|
|||
/** Parse the last JSON object in a noisy provider output string. */
|
||||
function extractLastJsonObject(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
const start = trimmed.lastIndexOf("{");
|
||||
if (start === -1) {
|
||||
return null;
|
||||
const ranges: Array<{ end: number; start: number }> = [];
|
||||
const starts: number[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let preambleQuote: string | undefined;
|
||||
let preambleEscaped = false;
|
||||
let previousSignificant: string | undefined;
|
||||
let lineHasNonWhitespace = false;
|
||||
let arrayDepth = 0;
|
||||
let candidateHasContent = false;
|
||||
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const character = trimmed[index];
|
||||
if (inString) {
|
||||
if (character === "\n" || character === "\r") {
|
||||
starts.length = 0;
|
||||
inString = false;
|
||||
escaped = false;
|
||||
} else if (escaped) {
|
||||
escaped = false;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starts.length === 0) {
|
||||
if (preambleQuote !== undefined) {
|
||||
if (character === "\n" || character === "\r") {
|
||||
preambleQuote = undefined;
|
||||
preambleEscaped = false;
|
||||
} else if (preambleEscaped) {
|
||||
preambleEscaped = false;
|
||||
} else if (character === "\\") {
|
||||
preambleEscaped = true;
|
||||
} else if (character === preambleQuote) {
|
||||
preambleQuote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
const previous = trimmed[index - 1];
|
||||
if (previous === undefined || /[\s:([{]/.test(previous)) {
|
||||
preambleQuote = character;
|
||||
preambleEscaped = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (character === "{") {
|
||||
arrayDepth = 0;
|
||||
candidateHasContent = false;
|
||||
starts.push(index);
|
||||
}
|
||||
if (!/\s/.test(character)) {
|
||||
previousSignificant = character;
|
||||
lineHasNonWhitespace = true;
|
||||
} else if (character === "\n" || character === "\r") {
|
||||
lineHasNonWhitespace = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const hadCandidateContent = candidateHasContent;
|
||||
if (character === '"') {
|
||||
inString = true;
|
||||
} else if (character === "{") {
|
||||
if (
|
||||
previousSignificant === ":" ||
|
||||
previousSignificant === "[" ||
|
||||
previousSignificant === '"' ||
|
||||
(previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0))
|
||||
) {
|
||||
starts.push(index);
|
||||
} else if (!lineHasNonWhitespace && !hadCandidateContent) {
|
||||
// Only resync at a clean record boundary; otherwise keep malformed
|
||||
// outer objects from promoting diagnostic payloads as valid results.
|
||||
starts.length = 1;
|
||||
starts[0] = index;
|
||||
arrayDepth = 0;
|
||||
candidateHasContent = false;
|
||||
}
|
||||
} else if (character === "}" && starts.length > 0) {
|
||||
const start = starts.pop();
|
||||
if (start !== undefined && starts.length === 0) {
|
||||
ranges.push({ start, end: index });
|
||||
}
|
||||
} else if (character === "[") {
|
||||
arrayDepth += 1;
|
||||
} else if (character === "]" && arrayDepth > 0) {
|
||||
arrayDepth -= 1;
|
||||
}
|
||||
|
||||
if (!/\s/.test(character)) {
|
||||
candidateHasContent = true;
|
||||
previousSignificant = character;
|
||||
lineHasNonWhitespace = true;
|
||||
} else if (character === "\n" || character === "\r") {
|
||||
lineHasNonWhitespace = false;
|
||||
}
|
||||
}
|
||||
const slice = trimmed.slice(start);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
for (let index = ranges.length - 1; index >= 0; index -= 1) {
|
||||
const range = ranges[index];
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(range.start, range.end + 1));
|
||||
} catch {
|
||||
// Ignore malformed objects and try the previous completed range.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract Gemini CLI-style response text from the last JSON object in output. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue