wkf/salvageTruncatedArray.mjs

// salvageTruncatedArray.mjs — 截斷JSON陣列之前段搶救
//
// 【為何需要】免費模型常在長陣列輸出中途被截斷(無閉合括號), 整包JSON.parse必失敗,
//   但前面k個元素是完整的。使用端殷鑑(2026-08-07~08): 兩日22次OUTPUT_VALIDATION_FAILED,
//   preview首元素皆完整合法——整批成果被截斷尾巴拖垮, 其中一輪3/3批全滅、整輪0產出。
//   字元掃描(含字串與跳脫感知)找出「最後一個完整的頂層元素」, 補上`]`後重新解析——
//   搶救回來的是**完整合法**的前段元素, 不是半成品。
//
// 【定位: 獨立工具, 不併入extractJsonLoose預設行為】「截斷」有兩種同樣合法的策略:
//   ①判失敗換家重產(全涵蓋驗證, 如逐篇摘要——缺篇的輸出本身即不完整);
//   ②搶救前段部分接受(批次萃取——避免全有全無, 一批30項救回25項遠勝重跑)。
//   套件提供工具, 策略由呼叫端選: extractJsonLoose檔頭明示之opt.parse擴充點即為此而留。
//
// 【只搶救「掃到結尾仍未閉合」的截斷】陣列有正常閉合但parse失敗屬別種毛病
//   (非法字元/壞跳脫), 補`]`救不了反而可能拼出語意錯誤的結果, 一律不碰回null。
//
// 【與REST截斷判定之銜接(2026-09-24起)】REST文字類轉接器於validate之前判定截斷且預設失敗;
//   工作流callAi之acceptTruncated預設為「有自訂parse且非rawText」, 故以本工具組成自訂parse注入即同意
//   接受截斷內容(既有用法不必改), 結果之truncated:true可辨識救回者為半批; 直接呼叫dispatchAiFallback或轉接器、
//   且把本工具寫在validate裡者須自給acceptTruncated:true(1.0.37起, 否則截斷在validate之前即判失敗;
//   安裝方實例: w-knowledge-extract 1.0.1之callJson因此失去搶救, 2026-09-24回報)。
//   CLI類拿不到終止訊號, 其截斷只能靠本工具於解析時發現。
//
// 【已知限制(2026-09-24複審指出, 讀碼確認)】只記錄頂層「物件」元素之結束(`}`), 故元素非物件之陣列
//   (如["a","b",…)救不回; 自第一個`[`起算, 故外包物件(如{"items":[…)會救回內層陣列(形狀改變,
//   呼叫端check須能接受), 前言含`[`(如「[注意]」)則誤判為已閉合而回null。


/**
 * 自截斷的JSON陣列文字搶救出前段完整元素(頂層元素須為物件)
 *
 * 特點:
 * 自第一個`[`起逐字元掃描(字串與跳脫感知),追蹤「最後一個完整結束的頂層物件元素」,
 * 補上`]`重新解析——回傳的每個元素皆完整合法,不是半成品;
 * 陣列有正常閉合(=非截斷)、無`[`、或救回後為空陣列,一律回null不硬救;
 * 典型用法:extractJsonLoose回null時之後備,或組成自訂parse注入callAiWithFallback
 *
 * @param {String} text 輸入模型輸出文字字串
 * @returns {Array|null} 回傳搶救出之非空陣列,無法搶救回null
 * @example
 *
 * import salvageTruncatedArray from './src/wkf/salvageTruncatedArray.mjs'
 *
 * console.log(salvageTruncatedArray('[{"a":1},{"b":2},{"c":'))
 * // => [ { a: 1 }, { b: 2 } ]
 *
 * console.log(salvageTruncatedArray('[{"a":1}]')) //有閉合=非截斷, 不在搶救範圍
 * // => null
 *
 */
function salvageTruncatedArray(text) {
    let s = String(text || '')
    let start = s.indexOf('[')
    if (start < 0) {
        return null
    }
    let depth = 0
    let inStr = false
    let esc = false
    let lastComplete = -1
    for (let i = start; i < s.length; i++) {
        let ch = s[i]
        if (esc) {
            esc = false
            continue
        }
        if (inStr) {
            if (ch === '\\') {
                esc = true
            }
            else if (ch === '"') {
                inStr = false
            }
            continue
        }
        if (ch === '"') {
            inStr = true
            continue
        }
        if (ch === '[' || ch === '{') {
            depth++
        }
        else if (ch === ']' || ch === '}') {
            depth--
            if (depth === 1 && ch === '}') {
                lastComplete = i //頂層元素(物件)完整結束
            }
            if (depth === 0) {
                return null //陣列有閉合=非截斷, 不在搶救範圍
            }
        }
    }
    if (lastComplete < 0) {
        return null
    }
    try {
        let r = JSON.parse(s.slice(start, lastComplete + 1) + ']')
        return (Array.isArray(r) && r.length > 0) ? r : null
    }
    catch (e) {
        return null
    }
}


export default salvageTruncatedArray