fix(analyst): repos как строка или массив — устойчивый парсинг вердикта
This commit is contained in:
@@ -54,7 +54,7 @@ type AnalystResponse struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
Repo string `json:"repo"` // одиночный репо (обратная совместимость)
|
Repo string `json:"repo"` // одиночный репо (обратная совместимость)
|
||||||
Repos []string `json:"repos"` // список репо (основной)
|
Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке
|
||||||
Why string `json:"why"`
|
Why string `json:"why"`
|
||||||
AC string `json:"ac"`
|
AC string `json:"ac"`
|
||||||
Questions []string `json:"questions"`
|
Questions []string `json:"questions"`
|
||||||
@@ -62,6 +62,30 @@ type AnalystResponse struct {
|
|||||||
AbortReason string `json:"abort_reason"`
|
AbortReason string `json:"abort_reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reposList нормализует поле repos: модель может вернуть либо массив
|
||||||
|
// ["a","b"], либо строку "a,b" (иногда с пробелами). Пустое значение → nil.
|
||||||
|
func (r *AnalystResponse) reposList() []string {
|
||||||
|
if r.Repos == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal(r.Repos, &arr); err == nil {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(r.Repos, &s); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Decide реализует core.Decider через открытый код.
|
// Decide реализует core.Decider через открытый код.
|
||||||
func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft storage.Task, force bool) (core.Decision, error) {
|
func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft storage.Task, force bool) (core.Decision, error) {
|
||||||
agent := a.Agent
|
agent := a.Agent
|
||||||
@@ -141,10 +165,10 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor
|
|||||||
if ar.Repo != "" {
|
if ar.Repo != "" {
|
||||||
dec.Draft.Repo = ar.Repo
|
dec.Draft.Repo = ar.Repo
|
||||||
}
|
}
|
||||||
if len(ar.Repos) > 0 {
|
if repos := ar.reposList(); len(repos) > 0 {
|
||||||
dec.Draft.Repos = ar.Repos
|
dec.Draft.Repos = repos
|
||||||
// Синхронизируем одиночный repo для старых потребителей.
|
// Синхронизируем одиночный repo для старых потребителей.
|
||||||
dec.Draft.Repo = strings.Join(ar.Repos, ",")
|
dec.Draft.Repo = strings.Join(repos, ",")
|
||||||
}
|
}
|
||||||
if ar.Why != "" {
|
if ar.Why != "" {
|
||||||
dec.Draft.Why = ar.Why
|
dec.Draft.Why = ar.Why
|
||||||
@@ -220,9 +244,9 @@ func formatVerdict(ar *AnalystResponse) string {
|
|||||||
b.WriteString(", repo=")
|
b.WriteString(", repo=")
|
||||||
b.WriteString(ar.Repo)
|
b.WriteString(ar.Repo)
|
||||||
}
|
}
|
||||||
if len(ar.Repos) > 0 {
|
if repos := ar.reposList(); len(repos) > 0 {
|
||||||
b.WriteString(", repos=[")
|
b.WriteString(", repos=[")
|
||||||
b.WriteString(strings.Join(ar.Repos, ", "))
|
b.WriteString(strings.Join(repos, ", "))
|
||||||
b.WriteString("]")
|
b.WriteString("]")
|
||||||
}
|
}
|
||||||
if ar.Why != "" {
|
if ar.Why != "" {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package analyst
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -218,6 +219,27 @@ func TestAskEmptyReplyAndQuestions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDecideProposeStringRepos — модель вернула repos строкой (а не массивом):
|
||||||
|
// парсер должен нормализовать и не падать.
|
||||||
|
func TestDecideProposeStringRepos(t *testing.T) {
|
||||||
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
RC: 0,
|
||||||
|
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"repos\":\"tools/calc, tools/ui\"}"}}`,
|
||||||
|
}}, Worktree: "/tmp"}
|
||||||
|
|
||||||
|
history := []core.Message{{Role: "user", Content: "Сделай калькулятор"}}
|
||||||
|
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decide err: %v", err)
|
||||||
|
}
|
||||||
|
if dec.Phase != "propose" {
|
||||||
|
t.Errorf("Phase = %q, want propose", dec.Phase)
|
||||||
|
}
|
||||||
|
if dec.Draft.Repos[0] != "tools/calc" || dec.Draft.Repos[1] != "tools/ui" {
|
||||||
|
t.Errorf("Repos = %#v, want [tools/calc tools/ui]", dec.Draft.Repos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFormatVerdict — человекочитаемое описание вердикта аналитика.
|
// TestFormatVerdict — человекочитаемое описание вердикта аналитика.
|
||||||
func TestFormatVerdict(t *testing.T) {
|
func TestFormatVerdict(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -231,7 +253,7 @@ func TestFormatVerdict(t *testing.T) {
|
|||||||
Phase: "propose",
|
Phase: "propose",
|
||||||
Title: "Калькулятор",
|
Title: "Калькулятор",
|
||||||
Goal: "Сделать веб-калькулятор",
|
Goal: "Сделать веб-калькулятор",
|
||||||
Repos: []string{"tools/calc", "tools/ui"},
|
Repos: json.RawMessage(`["tools/calc","tools/ui"]`),
|
||||||
Why: "Нужен для учёта",
|
Why: "Нужен для учёта",
|
||||||
AC: "Работает + - * /",
|
AC: "Работает + - * /",
|
||||||
ChatReply: "Готово!",
|
ChatReply: "Готово!",
|
||||||
|
|||||||
Reference in New Issue
Block a user