src / utils / qwen-parser.ts
src / utils / qwen-parser.ts
/**
* @file utils/qwen-parser.ts
* Robust parsing helpers for Qwen-style reasoning models (and any model that
* emits `<think>...</think>` blocks). Reasoning tokens are stripped before JSON
* extraction so a chain-of-thought prefix can never break a structured-output
* pipeline.
*/
/** Reasoning effort knob threaded into the LLM layer. */
export type ReasoningEffort = "none" | "low" | "medium" | "high";
const THINK_BLOCK_RE = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
const ORPHAN_THINK_TAG_RE = /<\/?think\b[^>]*>/gi;
/** A reasoning block whose closing tag never arrived (max-token truncation). */
const UNCLOSED_THINK_RE = /<think\b[^>]*>[\s\S]*$/i;
/**
* Removes every `<think>...</think>` block (case-insensitive, multi-line).
* Repeated passes plus an orphan-tag sweep also clean up nested blocks, which a
* single non-greedy regex pass would leave behind.
*
* A reasoning stream can also be cut off mid-block when the max-token limit is
* hit, leaving an opening `<think>` with no closing tag; that trailing remainder
* is removed too, otherwise it would swallow the JSON payload that follows.
*/
export function stripThinkTags(raw: string): string {
if (!raw) return "";
let out = raw;
let prev: string;
do {
prev = out;
out = out.replace(THINK_BLOCK_RE, "");
} while (out !== prev);
// Drop an unterminated reasoning prefix/suffix left by truncation.
out = out.replace(UNCLOSED_THINK_RE, "");
// Sweep up any unbalanced tags a nested or truncated block left behind.
return out.replace(ORPHAN_THINK_TAG_RE, "");
}
/**
* Extracts and joins the inner text of every ` thinking...</think>` block
* (case-insensitive, multi-line). Used to preserve a reasoning model's chain of
* thought in an auditable sidecar instead of letting it leak into the report.
* Returns "" when no think block is present.
*/
export function extractThinkBlocks(raw: string): string {
if (!raw) return "";
const blocks: string[] = [];
const re = /<think\b[^>]*>([\s\S]*?)<\/think>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(raw)) !== null) {
const inner = match[1].trim();
if (inner) blocks.push(inner);
}
return blocks.join("\n\n");
}
/** Strips a single outer ```/```json Markdown fence, matching the legacy paths. */
function stripCodeFences(text: string): string {
return text
.replace(/^```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
}
/**
* Extracts and parses the JSON payload from a raw model completion.
*
* Pipeline: strip `<think>` blocks → strip Markdown fences → slice from the
* first `{`/`[` to the last `}`/`]` → parse. This tolerates leading chatter
* ("Here is the JSON:") and trailing commentary, and cannot be defeated by a
* reasoning prefix that itself happens to contain braces.
*
* @throws descriptive Error when no JSON is present or parsing fails.
*/
export function extractQwenJson<T = unknown>(rawOutput: string): T {
const withoutThink = stripThinkTags(rawOutput ?? "");
const cleaned = stripCodeFences(withoutThink);
if (!cleaned) {
throw new Error(
"extractQwenJson: empty model output after stripping <think> blocks and code fences.",
);
}
const firstObj = cleaned.indexOf("{");
const firstArr = cleaned.indexOf("[");
const starts = [firstObj, firstArr].filter((i) => i >= 0);
if (starts.length === 0) {
throw new Error("extractQwenJson: no JSON object or array found in model output.");
}
const start = Math.min(...starts);
const end = Math.max(cleaned.lastIndexOf("}"), cleaned.lastIndexOf("]"));
if (end < start) {
throw new Error("extractQwenJson: unbalanced JSON boundaries in model output.");
}
const payload = cleaned.slice(start, end + 1);
try {
return JSON.parse(payload) as T;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`extractQwenJson: failed to parse extracted JSON (${reason}).`);
}
}
/**
* @file utils/qwen-parser.ts
* Robust parsing helpers for Qwen-style reasoning models (and any model that
* emits `<think>...</think>` blocks). Reasoning tokens are stripped before JSON
* extraction so a chain-of-thought prefix can never break a structured-output
* pipeline.
*/
/** Reasoning effort knob threaded into the LLM layer. */
export type ReasoningEffort = "none" | "low" | "medium" | "high";
const THINK_BLOCK_RE = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
const ORPHAN_THINK_TAG_RE = /<\/?think\b[^>]*>/gi;
/** A reasoning block whose closing tag never arrived (max-token truncation). */
const UNCLOSED_THINK_RE = /<think\b[^>]*>[\s\S]*$/i;
/**
* Removes every `<think>...</think>` block (case-insensitive, multi-line).
* Repeated passes plus an orphan-tag sweep also clean up nested blocks, which a
* single non-greedy regex pass would leave behind.
*
* A reasoning stream can also be cut off mid-block when the max-token limit is
* hit, leaving an opening `<think>` with no closing tag; that trailing remainder
* is removed too, otherwise it would swallow the JSON payload that follows.
*/
export function stripThinkTags(raw: string): string {
if (!raw) return "";
let out = raw;
let prev: string;
do {
prev = out;
out = out.replace(THINK_BLOCK_RE, "");
} while (out !== prev);
// Drop an unterminated reasoning prefix/suffix left by truncation.
out = out.replace(UNCLOSED_THINK_RE, "");
// Sweep up any unbalanced tags a nested or truncated block left behind.
return out.replace(ORPHAN_THINK_TAG_RE, "");
}
/**
* Extracts and joins the inner text of every ` thinking...</think>` block
* (case-insensitive, multi-line). Used to preserve a reasoning model's chain of
* thought in an auditable sidecar instead of letting it leak into the report.
* Returns "" when no think block is present.
*/
export function extractThinkBlocks(raw: string): string {
if (!raw) return "";
const blocks: string[] = [];
const re = /<think\b[^>]*>([\s\S]*?)<\/think>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(raw)) !== null) {
const inner = match[1].trim();
if (inner) blocks.push(inner);
}
return blocks.join("\n\n");
}
/** Strips a single outer ```/```json Markdown fence, matching the legacy paths. */
function stripCodeFences(text: string): string {
return text
.replace(/^```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
}
/**
* Extracts and parses the JSON payload from a raw model completion.
*
* Pipeline: strip `<think>` blocks → strip Markdown fences → slice from the
* first `{`/`[` to the last `}`/`]` → parse. This tolerates leading chatter
* ("Here is the JSON:") and trailing commentary, and cannot be defeated by a
* reasoning prefix that itself happens to contain braces.
*
* @throws descriptive Error when no JSON is present or parsing fails.
*/
export function extractQwenJson<T = unknown>(rawOutput: string): T {
const withoutThink = stripThinkTags(rawOutput ?? "");
const cleaned = stripCodeFences(withoutThink);
if (!cleaned) {
throw new Error(
"extractQwenJson: empty model output after stripping <think> blocks and code fences.",
);
}
const firstObj = cleaned.indexOf("{");
const firstArr = cleaned.indexOf("[");
const starts = [firstObj, firstArr].filter((i) => i >= 0);
if (starts.length === 0) {
throw new Error("extractQwenJson: no JSON object or array found in model output.");
}
const start = Math.min(...starts);
const end = Math.max(cleaned.lastIndexOf("}"), cleaned.lastIndexOf("]"));
if (end < start) {
throw new Error("extractQwenJson: unbalanced JSON boundaries in model output.");
}
const payload = cleaned.slice(start, end + 1);
try {
return JSON.parse(payload) as T;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`extractQwenJson: failed to parse extracted JSON (${reason}).`);
}
}