mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
fix(intercept): judge decisions were stored as unknown (prefix case mismatch)
Since e233c02 the judge writer prefixes its reason with "[model]" while
interceptSource, the approvalDecisionSource SQL fallback and the archive
restore checked "[Model]", so model-decided rows were inserted with
decision_source='unknown' and missed by the decision_source=model filter.
- db: add ModelReasonPrefix ("[model]") and isModelReason, which also
accepts the pre-e233c02 "[模型]" prefix; readers compare
case-insensitively (lower(reason) LIKE in SQL).
- intercept: the writer builds its reason through JudgeReason, using the
shared constant.
- schema.sql: idempotent backfill of 'unknown' rows without a rule whose
reason carries either prefix.
- tests: un-skip TestInterceptFilterHTTP (it failed only because of
this), make the db filter fixture use the constant, and add a
Postgres-free test pinning the writer output to interceptSource.
Fixes #156
This commit is contained in:
@@ -32,7 +32,7 @@ jobs:
|
||||
- run: go build ./...
|
||||
- run: go vet ./...
|
||||
# Skipped tests, tracked in issue #9:
|
||||
# - TestInterceptFilterHTTP, TestGraphOverviewExpandsAssociatedCompanyScope,
|
||||
# - TestGraphOverviewExpandsAssociatedCompanyScope,
|
||||
# TestCaptureApprovalLifecycle: fail on unmodified upstream code with a
|
||||
# fresh database.
|
||||
# - TestSideUsageRecordedOnceOnConsumerCancellation: concurrency/cancellation
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package db
|
||||
|
||||
// InterceptSource exposes interceptSource to external (db_test) tests.
|
||||
var InterceptSource = interceptSource
|
||||
+19
-3
@@ -37,7 +37,7 @@ type InterceptPending struct {
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Status string `json:"status"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with [Model])
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with ModelReasonPrefix)
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -225,10 +225,26 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
|
||||
)
|
||||
}
|
||||
|
||||
// ModelReasonPrefix starts every reason written by the LLM fallback judge
|
||||
// (intercept.JudgeReason); readers derive decision_source='model' from it.
|
||||
const ModelReasonPrefix = "[model]"
|
||||
|
||||
// legacyModelReasonPrefix is what the judge wrote before the UI translation (e233c02).
|
||||
const legacyModelReasonPrefix = "[模型]"
|
||||
|
||||
// isModelReason reports whether reason was written by the model judge. The
|
||||
// comparison ignores case: readers used to expect "[Model]" while the writer
|
||||
// produced "[model]", so stored rows may carry either spelling.
|
||||
func isModelReason(reason string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(reason), ModelReasonPrefix) ||
|
||||
strings.HasPrefix(reason, legacyModelReasonPrefix)
|
||||
}
|
||||
|
||||
// Keep legacy rows without decision_source consistent with their displayed source.
|
||||
// Mirrors isModelReason; PostgreSQL LIKE is case-sensitive and '[' is literal.
|
||||
const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
||||
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN ip.reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END)`
|
||||
WHEN lower(ip.reason) LIKE '` + ModelReasonPrefix + `%' OR ip.reason LIKE '` + legacyModelReasonPrefix + `%' THEN 'model' ELSE 'unknown' END)`
|
||||
|
||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
||||
@@ -248,7 +264,7 @@ func interceptSource(ruleID int64, reason string) string {
|
||||
if ruleID != 0 {
|
||||
return "rule"
|
||||
}
|
||||
if strings.HasPrefix(reason, "[Model]") {
|
||||
if isModelReason(reason) {
|
||||
return "model"
|
||||
}
|
||||
return "unknown"
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestInterceptApprovalFilters(t *testing.T) {
|
||||
ruleID = rule.ID
|
||||
}
|
||||
if source == "model" {
|
||||
reason = "[Model] fixture"
|
||||
reason = ModelReasonPrefix + " fixture"
|
||||
}
|
||||
id, err := d.CreateDecidedIntercept(ruleID, 0, task, "test", "Bash", []byte(`{"command":"fixture"}`), status, reason)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// The judge writer (package intercept) and the decision_source readers (package
|
||||
// db) must agree on the reason prefix; this pins them together without Postgres.
|
||||
func TestInterceptSourceMatchesJudgeWriter(t *testing.T) {
|
||||
for _, tc := range []struct{ action, message string }{
|
||||
{"allow", ""},
|
||||
{"deny", "destructive command outside the workspace"},
|
||||
{"ask", db.ModelReasonPrefix + " already prefixed"},
|
||||
} {
|
||||
reason := intercept.JudgeReason(tc.action, tc.message)
|
||||
if got := db.InterceptSource(0, reason); got != "model" {
|
||||
t.Fatalf("JudgeReason(%q, %q) = %q classified as %q, want model", tc.action, tc.message, reason, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptSourceLegacyModelPrefix(t *testing.T) {
|
||||
for _, reason := range []string{"[模型] 允许", "[Model] fixture"} {
|
||||
if got := db.InterceptSource(0, reason); got != "model" {
|
||||
t.Fatalf("%q classified as %q, want model", reason, got)
|
||||
}
|
||||
}
|
||||
if got := db.InterceptSource(0, "legacy reason"); got != "unknown" {
|
||||
t.Fatalf("plain reason classified as %q, want unknown", got)
|
||||
}
|
||||
if got := db.InterceptSource(7, "[model] rule wins"); got != "rule" {
|
||||
t.Fatalf("rule row classified as %q, want rule", got)
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -1088,7 +1088,7 @@ CREATE TABLE IF NOT EXISTS intercept_pending (
|
||||
tool_input JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'allowed', 'denied', 'timeout')),
|
||||
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [Model] prefix) for an LLM fallback decision.
|
||||
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [model] prefix) for an LLM fallback decision.
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
@@ -1102,6 +1102,12 @@ ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
|
||||
UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
|
||||
-- Judge decisions were stored as 'unknown' because the readers expected a capitalized
|
||||
-- prefix while the judge wrote '[model]' (and '[模型]' before e233c02). Idempotent:
|
||||
-- once corrected no row matches the WHERE clause again.
|
||||
UPDATE intercept_pending SET decision_source='model'
|
||||
WHERE decision_source='unknown' AND rule_id IS NULL
|
||||
AND (reason LIKE '[model]%' OR reason LIKE '[模型]%');
|
||||
|
||||
-- =====================================================================
|
||||
-- L. Finding persistence
|
||||
|
||||
@@ -677,7 +677,7 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
|
||||
source = "unknown"
|
||||
if row["rule_id"] != nil {
|
||||
source = "rule"
|
||||
} else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[Model]") {
|
||||
} else if reason, _ := row["reason"].(string); isModelReason(reason) {
|
||||
source = "model"
|
||||
}
|
||||
row["decision_source"] = source
|
||||
|
||||
+14
-5
@@ -476,11 +476,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
out.ProfileID = cfg.ProfileID
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
out.ConfigDigest = digestInput(configJSON)
|
||||
if out.Message == "" {
|
||||
out.Message = "[model] " + judgeActionLabel(out.Action)
|
||||
} else if !strings.HasPrefix(out.Message, "[model]") {
|
||||
out.Message = "[model] " + out.Message
|
||||
}
|
||||
out.Message = JudgeReason(out.Action, out.Message)
|
||||
if out.Action == "ask" {
|
||||
out.TimeoutEnabled = true
|
||||
out.TimeoutSeconds = cfg.AskTimeoutSeconds
|
||||
@@ -489,6 +485,19 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
return out, true
|
||||
}
|
||||
|
||||
// JudgeReason is the reason stored for a judge verdict: the model's message, or
|
||||
// a label for the action when it gave none, carrying db.ModelReasonPrefix so
|
||||
// the db readers record decision_source='model'.
|
||||
func JudgeReason(action, message string) string {
|
||||
if message == "" {
|
||||
return db.ModelReasonPrefix + " " + judgeActionLabel(action)
|
||||
}
|
||||
if !strings.HasPrefix(message, db.ModelReasonPrefix) {
|
||||
return db.ModelReasonPrefix + " " + message
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func judgeActionLabel(action string) string {
|
||||
switch action {
|
||||
case "allow":
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
)
|
||||
|
||||
func TestInterceptFilterHTTP(t *testing.T) {
|
||||
t.Skip("known failure with a fresh database, pre-existing on unmodified upstream; tracked in BaaRai/ARTEX#9")
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
|
||||
Reference in New Issue
Block a user