Merge pull request 'fix(llm): 強化 OpenCode JSON 回應解析' (#17) from develop into master
CD / 計算版本號 (push) Successful in 1s
CD / 發布專案 (push) Successful in 4s

Reviewed-on: #17
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #17.
This commit is contained in:
2026-06-17 07:15:15 +00:00
4 changed files with 88 additions and 1 deletions
+4
View File
@@ -203,6 +203,10 @@ export async function mergeInstructionText(existingText, sourceText, relPath, ai
try { try {
const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks }); const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks });
if (aiMerged == null) {
warn(`[merge] ${relPath} AI result unavailable; using deterministic merge`);
return deterministic;
}
if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) { if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) {
return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged; return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged;
} }
+9
View File
@@ -211,6 +211,15 @@ describe('commitAndPush', () => {
assert.ok(result.includes('extra block')); assert.ok(result.includes('extra block'));
}); });
it('uses deterministic instruction merge when AI returns no usable result', async () => {
const aiMergeAssistant = async () => null;
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
assert.ok(result.includes('repo block'));
assert.ok(result.includes('source block'));
});
it('exits when AI output drops a block', async () => { it('exits when AI output drops a block', async () => {
const originalExit = process.exit; const originalExit = process.exit;
let exitCode = null; let exitCode = null;
+61 -1
View File
@@ -111,9 +111,69 @@ export async function chat(systemPrompt, userContent) {
export async function chatJSON(systemPrompt, userContent) { export async function chatJSON(systemPrompt, userContent) {
const text = await chat(systemPrompt, userContent); const text = await chat(systemPrompt, userContent);
try { try {
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim()); return JSON.parse(extractJSONText(text));
} catch (e) { } catch (e) {
line(`[LLM] JSON 解析失敗: ${e.message}`); line(`[LLM] JSON 解析失敗: ${e.message}`);
return []; return [];
} }
} }
function stripOuterFence(text) {
return String(text)
.trim()
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
.replace(/```$/, '')
.trim();
}
function extractBalancedJSON(text, startIndex) {
const source = String(text);
const open = source[startIndex];
const close = open === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const ch = source[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return source.slice(startIndex, i + 1);
}
}
return null;
}
function extractJSONText(text) {
const stripped = stripOuterFence(text);
try {
JSON.parse(stripped);
return stripped;
} catch {}
for (let i = 0; i < stripped.length; i++) {
if (stripped[i] !== '[' && stripped[i] !== '{') continue;
const candidate = extractBalancedJSON(stripped, i);
if (!candidate) continue;
try {
JSON.parse(candidate);
return candidate;
} catch {}
}
return stripped;
}
+14
View File
@@ -208,6 +208,20 @@ describe('chatJSON', async () => {
assert.deepEqual(result, [{ level: 'info' }]); assert.deepEqual(result, [{ level: 'info' }]);
}); });
it('extracts JSON array from surrounding prose', async () => {
process.env.OPENAI_API_KEY = 'sk-test';
mockAxiosPost([makeOkResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.')]);
const result = await chatJSON('sys', 'user');
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
});
it('extracts JSON object from surrounding prose', async () => {
process.env.OPENAI_API_KEY = 'sk-test';
mockAxiosPost([makeOkResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}')]);
const result = await chatJSON('sys', 'user');
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
});
it('returns [] when JSON is invalid', async () => { it('returns [] when JSON is invalid', async () => {
process.env.OPENAI_API_KEY = 'sk-test'; process.env.OPENAI_API_KEY = 'sk-test';
mockAxiosPost([makeOkResponse('not json')]); mockAxiosPost([makeOkResponse('not json')]);