85 lines
3.0 KiB
Go
85 lines
3.0 KiB
Go
// Package opencode — запуск opencode-субагентов и разбор их вердиктов.
|
||
//
|
||
// Единственный компонент, умеющий запускать opencode run и парсить вывод.
|
||
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
||
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
||
// - ExtractJSON: fenced ```json``` → первый {...}
|
||
package opencode
|
||
|
||
import (
|
||
"encoding/json"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
var (
|
||
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
||
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
||
)
|
||
|
||
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
||
// Поток — NDJSON: последний парт {"type":"text","part":{"text":...}} и есть вердикт.
|
||
// Если JSON-партов нет (например, простой текст) — возвращает исходную строку.
|
||
func ExtractVerdict(out string) string {
|
||
out = strings.TrimSpace(out)
|
||
if out == "" {
|
||
return ""
|
||
}
|
||
var last string
|
||
for _, line := range strings.Split(out, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if !strings.HasPrefix(line, "{") {
|
||
continue
|
||
}
|
||
var obj struct {
|
||
Type string `json:"type"`
|
||
Part struct {
|
||
Text string `json:"text"`
|
||
} `json:"part"`
|
||
}
|
||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||
continue
|
||
}
|
||
if obj.Type == "text" && obj.Part.Text != "" {
|
||
last = obj.Part.Text
|
||
}
|
||
}
|
||
if last != "" {
|
||
return strings.TrimSpace(last)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ExtractJSON извлекает JSON из ответа модели: сначала fenced ```json```,
|
||
// затем первый {...}. Возвращает (nil, false), если JSON нет или невалиден.
|
||
// Это НЕ ошибка — вызывающий решает, как деградировать (класс O3 ErrParse).
|
||
func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
|
||
if strings.TrimSpace(text) == "" {
|
||
return nil, false
|
||
}
|
||
for _, f := range fenceRe.FindAllStringSubmatch(text, -1) {
|
||
if len(f) > 1 {
|
||
var obj map[string]json.RawMessage
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(f[1])), &obj); err == nil && obj != nil {
|
||
return obj, true
|
||
}
|
||
}
|
||
}
|
||
if m := jsonBlockRe.FindString(text); m != "" {
|
||
var obj map[string]json.RawMessage
|
||
if err := json.Unmarshal([]byte(m), &obj); err == nil && obj != nil {
|
||
return obj, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
|
||
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
|
||
func stripFence(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
|
||
s = strings.TrimSpace(f[1])
|
||
}
|
||
return strings.TrimSpace(s)
|
||
} |