mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
fix: add configurable model approval retries
This commit is contained in:
1 parent
23446606a4
commit
d2e7e3a8a7
8 files changed
+464
-11
No files matched your search
+21
-5
@@ -305,6 +305,8 @@ const (
|
||||
settingJudgeProfileID = "llm_judge_profile_id"
|
||||
settingJudgePrompt = "llm_judge_prompt"
|
||||
settingJudgeTimeoutSecs = "llm_judge_timeout_seconds"
|
||||
settingJudgeRetryCount = "llm_judge_retry_count"
|
||||
settingJudgeRetryIntervalMS = "llm_judge_retry_interval_ms"
|
||||
settingJudgeFailAction = "llm_judge_fail_action"
|
||||
settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds"
|
||||
settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action"
|
||||
@@ -327,7 +329,9 @@ type JudgeConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ProfileID int64 `json:"profile_id"` // 0 = follow active/default
|
||||
Prompt string `json:"prompt"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // per attempt
|
||||
RetryCount int `json:"retry_count"` // additional attempts; 0 = disabled
|
||||
RetryIntervalMS int `json:"retry_interval_ms"`
|
||||
FailAction string `json:"fail_action"` // allow|ask|deny
|
||||
AskTimeoutSeconds int `json:"ask_timeout_seconds"`
|
||||
AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny
|
||||
@@ -342,6 +346,8 @@ func (i *Interceptor) judgeConfig() JudgeConfig {
|
||||
Enabled: i.db.GetBool(settingJudgeEnabled, false),
|
||||
ProfileID: int64(i.getSettingInt(settingJudgeProfileID, 0)),
|
||||
TimeoutSeconds: i.getSettingInt(settingJudgeTimeoutSecs, defaultJudgeTimeoutSecs),
|
||||
RetryCount: min(max(i.getSettingInt(settingJudgeRetryCount, 0), 0), MaxJudgeRetryCount),
|
||||
RetryIntervalMS: min(max(i.getSettingInt(settingJudgeRetryIntervalMS, defaultJudgeRetryIntervalMS), 0), MaxJudgeRetryIntervalMS),
|
||||
FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"),
|
||||
AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs),
|
||||
AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"),
|
||||
@@ -395,6 +401,9 @@ func (i *Interceptor) GetJudgeConfig() JudgeConfig { return i.judgeConfig() }
|
||||
// SetJudgeConfig persists the judge configuration. An empty Prompt clears the
|
||||
// override (the built-in template is used again).
|
||||
func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
|
||||
if err := c.ValidateRetry(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := i.db.SetBool(settingJudgeEnabled, c.Enabled); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -413,6 +422,12 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
|
||||
if err := i.db.SetSetting(settingJudgeTimeoutSecs, strconv.Itoa(c.TimeoutSeconds)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := i.db.SetSetting(settingJudgeRetryCount, strconv.Itoa(c.RetryCount)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := i.db.SetSetting(settingJudgeRetryIntervalMS, strconv.Itoa(c.RetryIntervalMS)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := i.db.SetSetting(settingJudgeFailAction, c.FailAction); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -432,7 +447,8 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
|
||||
// returns (Decision, true) when the judge produced a terminal verdict, or
|
||||
// (Decision{}, false) when the fallback is disabled or not wired (caller then
|
||||
// keeps the current behavior: allow). On model error or an unparseable reply it
|
||||
// falls back to the configured FailAction. Ask verdicts carry the human-approval
|
||||
// retries up to RetryCount times, then falls back to the configured FailAction.
|
||||
// Ask verdicts carry the human-approval
|
||||
// timeout so the existing HandleAsk consumes them unchanged.
|
||||
func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.RawMessage) (Decision, bool) {
|
||||
cfg := i.judgeConfig()
|
||||
@@ -444,13 +460,13 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
}
|
||||
|
||||
cctx := ctx
|
||||
cancel := func() {}
|
||||
if cfg.TimeoutSeconds > 0 {
|
||||
var cancel context.CancelFunc
|
||||
cctx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
input, contextErr := BuildReviewInput(cctx, tool, arguments)
|
||||
cancel() // Capture once; each model attempt gets its own timeout below.
|
||||
cfg.Prompt = EffectiveJudgePrompt(cfg.Prompt)
|
||||
var out Decision
|
||||
var err error
|
||||
@@ -461,7 +477,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
out = Decision{Action: "ask", ModelFallback: true, Message: "审查上下文不完整,需要人工确认:" + contextErr.Error()}
|
||||
} else {
|
||||
modelInput, _ = json.Marshal(input)
|
||||
out, err = rv(cctx, cfg.ProfileID, cfg.Prompt, input)
|
||||
out, err = reviewWithRetry(ctx, cfg, rv, input)
|
||||
}
|
||||
if err != nil {
|
||||
out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxJudgeRetryCount = 20
|
||||
MaxJudgeRetryIntervalMS = 3600_000
|
||||
defaultJudgeRetryIntervalMS = 1000
|
||||
)
|
||||
|
||||
// ValidateRetry rejects invalid settings before any judge configuration is saved.
|
||||
func (c JudgeConfig) ValidateRetry() error {
|
||||
if c.RetryCount < 0 || c.RetryCount > MaxJudgeRetryCount {
|
||||
return fmt.Errorf("retry_count 必须在 0 到 %d 之间", MaxJudgeRetryCount)
|
||||
}
|
||||
if c.RetryIntervalMS < 0 || c.RetryIntervalMS > MaxJudgeRetryIntervalMS {
|
||||
return fmt.Errorf("retry_interval_ms 必须在 0 到 %d 之间", MaxJudgeRetryIntervalMS)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reviewWithRetry retries failed classifications, including invalid verdicts.
|
||||
// The captured input stays identical across attempts. A valid deny/ask verdict
|
||||
// is final, and parent cancellation interrupts both requests and the retry wait.
|
||||
// Provider-level connection/empty-response retries still run within each attempt.
|
||||
func reviewWithRetry(ctx context.Context, cfg JudgeConfig, rv Reviewer, input ReviewInput) (out Decision, err error) {
|
||||
for attempt := 0; attempt <= cfg.RetryCount; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return out, ctx.Err()
|
||||
}
|
||||
if attempt > 0 && cfg.RetryIntervalMS > 0 {
|
||||
timer := time.NewTimer(time.Duration(cfg.RetryIntervalMS) * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return out, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return out, ctx.Err()
|
||||
}
|
||||
attemptCtx := ctx
|
||||
cancel := func() {}
|
||||
if cfg.TimeoutSeconds > 0 {
|
||||
attemptCtx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second)
|
||||
}
|
||||
out, err = rv(attemptCtx, cfg.ProfileID, cfg.Prompt, input)
|
||||
if attemptCtx.Err() != nil {
|
||||
err = attemptCtx.Err()
|
||||
}
|
||||
cancel()
|
||||
if err == nil {
|
||||
switch out.Action {
|
||||
case "allow", "ask", "deny":
|
||||
return out, nil
|
||||
default:
|
||||
err = fmt.Errorf("模型输出无法解析")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package intercept
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func retryTestInterceptor(t *testing.T, settings map[string]string) *Interceptor {
|
||||
t.Helper()
|
||||
d, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
if _, err := d.Exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range settings {
|
||||
if _, err := d.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return New(&db.DB{DB: d})
|
||||
}
|
||||
|
||||
func TestJudgeRetryConfigDefaultsAndBounds(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
settings map[string]string
|
||||
count, interval int
|
||||
}{
|
||||
{"legacy", nil, 0, 1000},
|
||||
{"configured", map[string]string{settingJudgeRetryCount: "3", settingJudgeRetryIntervalMS: "25"}, 3, 25},
|
||||
{"immediate", map[string]string{settingJudgeRetryCount: "1", settingJudgeRetryIntervalMS: "0"}, 1, 0},
|
||||
{"malformed", map[string]string{settingJudgeRetryCount: "bad", settingJudgeRetryIntervalMS: "bad"}, 0, 1000},
|
||||
{"negative", map[string]string{settingJudgeRetryCount: "-1", settingJudgeRetryIntervalMS: "-1"}, 0, 0},
|
||||
{"excessive", map[string]string{settingJudgeRetryCount: "999", settingJudgeRetryIntervalMS: "999999999"}, 20, 3600000},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := retryTestInterceptor(t, tc.settings).GetJudgeConfig()
|
||||
if cfg.RetryCount != tc.count || cfg.RetryIntervalMS != tc.interval {
|
||||
t.Fatalf("config=%+v", cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRetriesBeforeApplyingFailurePolicy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, failAction, verdict string
|
||||
failures, wantCalls int
|
||||
wantAction string
|
||||
wantFallback bool
|
||||
}{
|
||||
{"recover", "deny", "allow", 2, 3, "allow", false},
|
||||
{"valid_deny_is_final", "allow", "deny", 0, 1, "deny", false},
|
||||
{"valid_ask_is_final", "allow", "ask", 0, 1, "ask", false},
|
||||
{"exhaust_allow", "allow", "allow", 9, 3, "allow", true},
|
||||
{"exhaust_ask", "ask", "allow", 9, 3, "ask", true},
|
||||
{"exhaust_deny", "deny", "allow", 9, 3, "deny", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
i := retryTestInterceptor(t, map[string]string{
|
||||
settingJudgeEnabled: "true", settingJudgeRetryCount: "2", settingJudgeRetryIntervalMS: "0", settingJudgeFailAction: tc.failAction,
|
||||
})
|
||||
calls := 0
|
||||
var first ReviewInput
|
||||
i.SetReviewer(func(ctx context.Context, id int64, prompt string, input ReviewInput) (Decision, error) {
|
||||
calls++
|
||||
if ctx.Err() != nil || prompt == "" {
|
||||
t.Fatal("invalid request context")
|
||||
}
|
||||
if calls == 1 {
|
||||
first = input
|
||||
} else if !reflect.DeepEqual(input, first) {
|
||||
t.Fatal("review input changed on retry")
|
||||
}
|
||||
out := Decision{Action: tc.verdict, ProfileID: 42, Message: "review result"}
|
||||
if calls <= tc.failures {
|
||||
return out, errors.New("temporary model failure")
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
got, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{"command":"echo ok"}`))
|
||||
if !ok || calls != tc.wantCalls || got.Action != tc.wantAction || got.ModelFallback != tc.wantFallback || got.ProfileID != 42 {
|
||||
t.Fatalf("calls=%d decision=%+v ok=%v", calls, got, ok)
|
||||
}
|
||||
if got.RuleID != 0 || len(got.ModelInput) == 0 || got.ModelInputDigest == "" || got.ConfigDigest == "" {
|
||||
t.Fatalf("missing audit data: %+v", got)
|
||||
}
|
||||
if got.Action == "ask" && (!got.TimeoutEnabled || got.TimeoutSeconds != 300 || got.TimeoutAction != "deny") {
|
||||
t.Fatal("human approval timeout lost")
|
||||
}
|
||||
if tc.wantFallback && !strings.Contains(got.Message, "temporary model failure") {
|
||||
t.Fatal("last failure lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRetriesInvalidVerdict(t *testing.T) {
|
||||
for _, invalid := range []string{"", "invalid"} {
|
||||
calls := 0
|
||||
out, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return Decision{Action: invalid}, nil
|
||||
}
|
||||
return Decision{Action: "deny"}, nil
|
||||
}, ReviewInput{})
|
||||
if err != nil || out.Action != "deny" || calls != 2 {
|
||||
t.Fatalf("calls=%d out=%+v err=%v", calls, out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeLegacyConfigDoesNotRetry(t *testing.T) {
|
||||
i := retryTestInterceptor(t, map[string]string{settingJudgeEnabled: "true", settingJudgeFailAction: "deny"})
|
||||
calls := 0
|
||||
i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
calls++
|
||||
return Decision{}, errors.New("failed")
|
||||
})
|
||||
got, _ := i.Judge(t.Context(), "Bash", json.RawMessage(`{"command":"echo ok"}`))
|
||||
if calls != 1 || got.Action != "deny" || !got.ModelFallback {
|
||||
t.Fatalf("calls=%d out=%+v", calls, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRetryTimeoutGetsFreshContext(t *testing.T) {
|
||||
calls := 0
|
||||
var previous context.Context
|
||||
out, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1, TimeoutSeconds: 1}, func(ctx context.Context, _ int64, _ string, _ ReviewInput) (Decision, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
previous = ctx
|
||||
<-ctx.Done()
|
||||
return Decision{}, ctx.Err()
|
||||
}
|
||||
if previous.Err() != context.DeadlineExceeded || ctx.Err() != nil {
|
||||
t.Fatal("retry reused expired context")
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) < 500*time.Millisecond {
|
||||
t.Fatal("retry has no fresh timeout")
|
||||
}
|
||||
return Decision{Action: "allow"}, nil
|
||||
}, ReviewInput{})
|
||||
if calls != 2 || err != nil || out.Action != "allow" {
|
||||
t.Fatalf("calls=%d out=%+v err=%v", calls, out, err)
|
||||
}
|
||||
if previous.Err() == nil {
|
||||
t.Fatal("attempt context was not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRetryCancellationInterruptsWait(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
started := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
calls := 0
|
||||
go func() {
|
||||
_, err := reviewWithRetry(ctx, JudgeConfig{RetryCount: 20, RetryIntervalMS: 3600000}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
calls++
|
||||
close(started)
|
||||
return Decision{}, errors.New("failed")
|
||||
}, ReviewInput{})
|
||||
done <- err
|
||||
}()
|
||||
<-started
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||
t.Fatalf("calls=%d err=%v", calls, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cancellation did not interrupt retry wait")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRetryDoesNotCallCanceledOrDisabledReviewer(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
_, err := reviewWithRetry(ctx, JudgeConfig{RetryCount: 2}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
t.Fatal("called after cancellation")
|
||||
return Decision{}, nil
|
||||
}, ReviewInput{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i := retryTestInterceptor(t, nil)
|
||||
i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
t.Fatal("called disabled reviewer")
|
||||
return Decision{}, nil
|
||||
})
|
||||
if _, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{}`)); ok {
|
||||
t.Fatal("disabled judge returned verdict")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRetryWaitsBetweenFailures(t *testing.T) {
|
||||
var first time.Time
|
||||
calls := 0
|
||||
_, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1, RetryIntervalMS: 20}, func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
first = time.Now()
|
||||
return Decision{}, errors.New("failed")
|
||||
}
|
||||
if time.Since(first) < 20*time.Millisecond {
|
||||
t.Fatal("retry skipped configured interval")
|
||||
}
|
||||
return Decision{Action: "allow"}, nil
|
||||
}, ReviewInput{})
|
||||
if err != nil || calls != 2 {
|
||||
t.Fatalf("calls=%d err=%v", calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeInvalidArgumentsDoNotCallReviewer(t *testing.T) {
|
||||
i := retryTestInterceptor(t, map[string]string{settingJudgeEnabled: "true", settingJudgeRetryCount: "2"})
|
||||
i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) {
|
||||
t.Fatal("invalid input must go straight to human review")
|
||||
return Decision{}, nil
|
||||
})
|
||||
got, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{broken`))
|
||||
if !ok || got.Action != "ask" || !got.ModelFallback {
|
||||
t.Fatalf("out=%+v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeRejectsInvalidRetryBeforeWritingSettings(t *testing.T) {
|
||||
for _, cfg := range []JudgeConfig{{RetryCount: -1}, {RetryCount: 21}, {RetryIntervalMS: -1}, {RetryIntervalMS: 3600001}} {
|
||||
if err := New(nil).SetJudgeConfig(cfg); err == nil {
|
||||
t.Fatalf("accepted invalid retry: %+v", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,6 +474,10 @@ func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request)
|
||||
writeErr(w, 400, "incomplete_script_action 必须是 ask 或 model")
|
||||
return
|
||||
}
|
||||
if err := req.ValidateRetry(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.m.interceptor.SetJudgeConfig(req); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) {
|
||||
@@ -20,7 +25,7 @@ func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pg.Close() })
|
||||
keys := []string{"llm_judge_enabled", "llm_judge_profile_id", "llm_judge_prompt", "llm_judge_timeout_seconds", "llm_judge_fail_action", "llm_judge_ask_timeout_seconds", "llm_judge_ask_timeout_action", "llm_judge_incomplete_script_action"}
|
||||
keys := []string{"llm_judge_enabled", "llm_judge_profile_id", "llm_judge_prompt", "llm_judge_timeout_seconds", "llm_judge_retry_count", "llm_judge_retry_interval_ms", "llm_judge_fail_action", "llm_judge_ask_timeout_seconds", "llm_judge_ask_timeout_action", "llm_judge_incomplete_script_action"}
|
||||
for _, key := range keys {
|
||||
value, exists, err := pg.GetSetting(key)
|
||||
if err != nil {
|
||||
@@ -47,14 +52,80 @@ func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) {
|
||||
{`,"incomplete_script_action":"model"`, "model", 200},
|
||||
{`,"incomplete_script_action":"invalid"`, "model", 400},
|
||||
} {
|
||||
req := httptest.NewRequest("PUT", "/api/intercept/judge-config", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny"`+tc.option+`}`))
|
||||
req := httptest.NewRequest("PUT", "/api/intercept/judge-config", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny","retry_count":2,"retry_interval_ms":17`+tc.option+`}`))
|
||||
w := httptest.NewRecorder()
|
||||
s.interceptSetJudgeConfig(w, req)
|
||||
if w.Code != tc.code {
|
||||
t.Fatalf("option=%s status=%d body=%s", tc.option, w.Code, w.Body.String())
|
||||
}
|
||||
if cfg := s.m.interceptor.GetJudgeConfig(); cfg.RetryCount != 2 || cfg.RetryIntervalMS != 17 {
|
||||
t.Fatalf("retry configuration not persisted: %+v", cfg)
|
||||
}
|
||||
if got := s.m.interceptor.GetJudgeConfig().IncompleteScriptAction; got != tc.want {
|
||||
t.Fatalf("option=%s action=%s want=%s", tc.option, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJudgeConfigRejectsInvalidRetryBeforeSaving(t *testing.T) {
|
||||
// No manager is needed: invalid input must be rejected before database writes.
|
||||
s := &Server{}
|
||||
for _, fields := range []string{
|
||||
`"retry_count":-1`, `"retry_count":21`, `"retry_count":1.5`,
|
||||
`"retry_interval_ms":-1`, `"retry_interval_ms":3600001`, `"retry_interval_ms":1.5`,
|
||||
} {
|
||||
t.Run(fields, func(t *testing.T) {
|
||||
req := httptest.NewRequest("PUT", "/api/intercept/judge", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny",`+fields+`}`))
|
||||
w := httptest.NewRecorder()
|
||||
s.interceptSetJudgeConfig(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var judgeConfigSQLiteNow sync.Once
|
||||
|
||||
func TestJudgeConfigRetryRoundTrip(t *testing.T) {
|
||||
// Exercise production settings SQL without requiring an external PG service.
|
||||
judgeConfigSQLiteNow.Do(func() {
|
||||
sqlite.MustRegisterDeterministicScalarFunction("now", 0, func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) {
|
||||
return "2026-01-01 00:00:00", nil
|
||||
})
|
||||
})
|
||||
sqldb, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqldb.Close() })
|
||||
if _, err := sqldb.Exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pg := &db.DB{DB: sqldb}
|
||||
s := &Server{m: &Manager{pg: pg, interceptor: intercept.New(pg)}}
|
||||
for _, tc := range []struct{ count, interval int }{{2, 17}, {20, 3600000}, {0, 0}} {
|
||||
cfg := s.m.interceptor.GetJudgeConfig()
|
||||
cfg.RetryCount, cfg.RetryIntervalMS = tc.count, tc.interval
|
||||
body, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.interceptSetJudgeConfig(w, httptest.NewRequest("PUT", "/api/intercept/judge", strings.NewReader(string(body))))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// Construct a fresh interceptor to prove persistence, rather than cache reuse.
|
||||
s.m.interceptor = intercept.New(pg)
|
||||
w = httptest.NewRecorder()
|
||||
s.interceptGetJudgeConfig(w, httptest.NewRequest("GET", "/api/intercept/judge", nil))
|
||||
var got intercept.JudgeConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.RetryCount != tc.count || got.RetryIntervalMS != tc.interval {
|
||||
t.Fatalf("saved=%+v got=%+v", cfg, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field as FormField, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -200,6 +201,8 @@ const defaultJudge = (): JudgeConfig => ({
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
retry_count: 0,
|
||||
retry_interval_ms: 1000,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
@@ -207,7 +210,7 @@ const defaultJudge = (): JudgeConfig => ({
|
||||
});
|
||||
|
||||
function JudgeCard() {
|
||||
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
|
||||
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge);
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -376,7 +379,7 @@ function JudgeCard() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="模型判定超时(秒)">
|
||||
<Field label="每次模型判定超时(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -387,7 +390,48 @@ function JudgeCard() {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="模型失败时(出错 / 超时 / 无法解析)">
|
||||
<FieldGroup>
|
||||
<FormField>
|
||||
<FieldLabel htmlFor="judge-retry-count">失败重试次数</FieldLabel>
|
||||
<Input
|
||||
id="judge-retry-count"
|
||||
type="number"
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.retry_count}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isInteger(n) && n >= 0 && n <= 20) patch({ retry_count: n });
|
||||
}}
|
||||
aria-describedby="judge-retry-count-hint"
|
||||
/>
|
||||
<FieldDescription id="judge-retry-count-hint">
|
||||
首次审批失败后额外重试 0–20 次,0 表示不重试。出错、超时或裁决无法解析时重试;正常放行、转人工或拦截后立即结束。
|
||||
</FieldDescription>
|
||||
</FormField>
|
||||
<FormField data-disabled={cfg.retry_count === 0}>
|
||||
<FieldLabel htmlFor="judge-retry-interval">重试间隔(毫秒)</FieldLabel>
|
||||
<Input
|
||||
id="judge-retry-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
max={3600000}
|
||||
step={1}
|
||||
value={cfg.retry_interval_ms}
|
||||
disabled={cfg.retry_count === 0}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isInteger(n) && n >= 0 && n <= 3600000) patch({ retry_interval_ms: n });
|
||||
}}
|
||||
aria-describedby="judge-retry-interval-hint"
|
||||
/>
|
||||
<FieldDescription id="judge-retry-interval-hint">
|
||||
默认 1000 毫秒,0 表示立即重试。每次审批均有独立超时,全部尝试失败后执行下方失败策略。模型配置中的建连和空响应重试仍在每次审批内生效。
|
||||
</FieldDescription>
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
<Field label="模型失败时(重试耗尽后)">
|
||||
<Select value={cfg.fail_action} onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -2511,6 +2511,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
retry_count: 0,
|
||||
retry_interval_ms: 1000,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
|
||||
@@ -1361,7 +1361,9 @@ export interface JudgeConfig {
|
||||
enabled: boolean;
|
||||
profile_id: number; // 0 = 跟随激活/默认配置
|
||||
prompt: string; // 判定提示词;GET 未设置时后端回填内置模板全文
|
||||
timeout_seconds: number; // 模型调用超时
|
||||
timeout_seconds: number; // 每次模型审批调用的超时
|
||||
retry_count: number; // 审批失败后的额外重试次数,0 = 不重试
|
||||
retry_interval_ms: number; // 审批重试间隔(毫秒),0 = 立即重试
|
||||
fail_action: "allow" | "ask" | "deny"; // 模型出错/超时/不可解析时的回退
|
||||
ask_timeout_seconds: number; // 模型判 ask 转人工后的审批等待超时
|
||||
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
||||
|
||||
Reference in new issue
Block a user