Reject unsupported approval claims in material checkpoints
Why: A source-valid analysis in the approved A01 sample promoted a requirement into an approved decision. Evidence: The observed claim and six display fields failed before the guard, then passed with negative/question and mixed-clause regressions; full runmgr tests passed. Impact: Keep exact material records while rejecting model-authored affirmative approval assertions before persistence; final model quality remains unverified. Refs: Agent-System-Backend#193; Agent-System-Dev#512
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Approval is a high-consequence status. A source-backed material_record is
|
||||
// rendered from the exact quote by the host; free-form model analysis cannot
|
||||
// turn an ordinary requirement or suggestion into an approved decision.
|
||||
// This narrow language guard is not a general semantic verifier.
|
||||
var affirmativeApprovalTerms = []string{
|
||||
"已批准", "已经批准", "已获批", "审批通过", "批复同意", "已通过审批",
|
||||
}
|
||||
|
||||
var approvalImmediateQualifiers = []string{
|
||||
"是否", "不等于", "不代表", "并非", "不是", "尚待确认是否", "需要确认是否",
|
||||
}
|
||||
|
||||
func approvalIsQuestionOrNegation(prefix string) bool {
|
||||
for _, qualifier := range approvalImmediateQualifiers {
|
||||
if strings.HasSuffix(prefix, qualifier) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, framing := range []string{"写成", "说成", "当作", "视为", "认定为"} {
|
||||
if !strings.HasSuffix(prefix, framing) {
|
||||
continue
|
||||
}
|
||||
for _, prevention := range []string{"防止", "避免", "不要", "不能", "不得"} {
|
||||
if strings.Contains(prefix, prevention) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAffirmativeApprovalClaim(text string) bool {
|
||||
for _, clause := range strings.FieldsFunc(text, func(r rune) bool {
|
||||
return strings.ContainsRune("。;;,,!??!\n::", r)
|
||||
}) {
|
||||
for _, term := range affirmativeApprovalTerms {
|
||||
for start := 0; start < len(clause); {
|
||||
at := strings.Index(clause[start:], term)
|
||||
if at < 0 {
|
||||
break
|
||||
}
|
||||
at += start
|
||||
prefix := []rune(clause[:at])
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[len(prefix)-16:]
|
||||
}
|
||||
if !approvalIsQuestionOrNegation(string(prefix)) {
|
||||
return true
|
||||
}
|
||||
start = at + len(term)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateMaterialApprovalClaims(candidate core.WorkCheckpointCandidate) error {
|
||||
for _, text := range []string{candidate.Focus, candidate.Remaining, candidate.NextStep} {
|
||||
if hasAffirmativeApprovalClaim(text) {
|
||||
return fmt.Errorf("checkpoint.claim: approval needs explicit source record")
|
||||
}
|
||||
}
|
||||
for _, finding := range candidate.Findings {
|
||||
if finding.Kind == "material_record" {
|
||||
continue // The host replaces all model-authored display text with exact quotes.
|
||||
}
|
||||
for _, text := range []string{finding.Text, finding.Impact, finding.Assumptions} {
|
||||
if hasAffirmativeApprovalClaim(text) {
|
||||
return fmt.Errorf("checkpoint.claim: approval needs explicit source record")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaterialApprovalClaims(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
field string
|
||||
value string
|
||||
wantReject bool
|
||||
}{
|
||||
{"approved text", "analysis", "text", "预算是已批准决定", true},
|
||||
{"observed A01 approval error", "analysis", "text", "需求.txt 的总预算50万元、12项需求范围与3项外部接口依赖均写为规定/包含,是已批准决定;接口交付日期尚未确认", true},
|
||||
{"approved impact", "analysis", "impact", "审批通过,可执行", true},
|
||||
{"approved assumption", "provisional", "assumptions", "批复同意", true},
|
||||
{"approved focus", "material_record", "focus", "预算已获批", true},
|
||||
{"approved remaining", "material_record", "remaining", "已通过审批", true},
|
||||
{"approved next step", "material_record", "nextStep", "预算已经批准,可以推进", true},
|
||||
{"approval incomplete", "analysis", "text", "审批尚未完成", false},
|
||||
{"approval question", "analysis", "text", "是否已批准需核对", false},
|
||||
{"prevent false approval", "analysis", "text", "防止把50万写成已批准", false},
|
||||
{"unrelated prevention does not negate approval", "analysis", "text", "防止风险且预算已批准", true},
|
||||
{"unrelated question does not negate approval", "analysis", "text", "是否要修订且预算已批准", true},
|
||||
{"later denial does not erase prior assertion", "analysis", "text", "预算已批准但会议说尚未批准", true},
|
||||
{"not an approved increase", "analysis", "text", "不等于已批准增加经费", false},
|
||||
{"record is overwritten", "material_record", "text", "上线六月已经批准", false},
|
||||
{"user question", "needs_user", "text", "请确认预算是否已批准", false},
|
||||
{"user action assertion", "needs_user", "text", "预算已批准,请确认下一步", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := checkpointEnvironment(t)
|
||||
cp := validCheckpoint(c, "approval-claim")
|
||||
cp.Findings[0].Kind = tc.kind
|
||||
if tc.kind == "analysis" || tc.kind == "provisional" {
|
||||
cp.Findings[0].Assumptions = "材料来源仍需核对"
|
||||
}
|
||||
switch tc.field {
|
||||
case "text":
|
||||
cp.Findings[0].Text = tc.value
|
||||
case "impact":
|
||||
cp.Findings[0].Impact = tc.value
|
||||
case "assumptions":
|
||||
cp.Findings[0].Assumptions = tc.value
|
||||
case "focus":
|
||||
cp.Focus = tc.value
|
||||
case "remaining":
|
||||
cp.Remaining = tc.value
|
||||
case "nextStep":
|
||||
cp.NextStep = tc.value
|
||||
}
|
||||
err := c.PublishWorkCheckpoint(cp)
|
||||
if tc.wantReject && (err == nil || !strings.Contains(err.Error(), "checkpoint.claim")) {
|
||||
t.Fatalf("affirmative approval accepted: %v", err)
|
||||
}
|
||||
if !tc.wantReject && err != nil {
|
||||
t.Fatalf("negative, question, or verbatim source rejected: %v", err)
|
||||
}
|
||||
if !tc.wantReject && tc.kind == "material_record" {
|
||||
card, getErr := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
|
||||
if getErr != nil {
|
||||
t.Fatal(getErr)
|
||||
}
|
||||
if len(card.Task.Findings) != 1 || card.Task.Findings[0].Kind != "material_record" || strings.Contains(card.Task.Findings[0].Text, "已经批准") {
|
||||
t.Fatal("model paraphrase escaped material_record canonicalization")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ func (c *runCtx) PublishWorkCheckpoint(candidate core.WorkCheckpointCandidate) e
|
||||
if err := core.ValidateWorkCheckpoint(candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateMaterialApprovalClaims(candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, _ := json.Marshal(candidate)
|
||||
sum := sha256.Sum256(raw)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -60,6 +60,36 @@ func TestMaterialRecordCannotAddAnUnstatedApproval(t *testing.T) {
|
||||
t.Fatalf("model interpretation was published as a material record: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCheckpointRejectsUnsupportedApproval(t *testing.T) {
|
||||
c := checkpointEnvironment(t)
|
||||
before, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventsBefore, err := c.mgr.st.EventsAfter(c.run.ConversationID, 0, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cp := validCheckpoint(c, "unsupported-approval")
|
||||
cp.Findings[0].Kind = "analysis"
|
||||
cp.Findings[0].Text = "需求规定50万元,是已批准决定"
|
||||
cp.Findings[0].Assumptions = "把需求条目作为项目基线"
|
||||
if err = c.PublishWorkCheckpoint(cp); err == nil || !strings.Contains(err.Error(), "checkpoint.claim") {
|
||||
t.Fatalf("unsupported approval was published: %v", err)
|
||||
}
|
||||
after, err := c.mgr.st.GetCard(c.run.Owner, c.task.cardID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventsAfter, err := c.mgr.st.EventsAfter(c.run.ConversationID, 0, 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Task.Revision != before.Task.Revision || len(after.Task.Findings) != len(before.Task.Findings) || len(eventsAfter) != len(eventsBefore) {
|
||||
t.Fatal("rejected approval changed the card or replay events")
|
||||
}
|
||||
}
|
||||
func TestTaskCheckpointPersistentIdempotentAndCorrected(t *testing.T) {
|
||||
c := checkpointEnvironment(t)
|
||||
cp := validCheckpoint(c, "1")
|
||||
|
||||
@@ -38,7 +38,7 @@ func checkpointEnabled(rc RunCtx) bool {
|
||||
return true
|
||||
}
|
||||
func checkpointDecl() agentllm.ToolDecl {
|
||||
return agentllm.ToolDecl{Name: workCheckpointTool, Description: "将本次材料整理中已形成的有用发现及时保存到同一任务卡。只引用已提供的 sourceId/blockIndex 和原文片段;material_record由宿主展示经核验的原文引用,不采用模型转述或影响判断;归纳、推算或判断用analysis/provisional并写明assumptions。长材料在形成阶段结果时发布,不额外调用摘要模型;不要为了进度凑发现。普通发布最多6次,每次只提交1至3条新增发现;原文quote必须是同一段落连续出现的短句,不能拼接或用省略号。需要更正以前发现时用 corrects 填其 findingKey。此工具不创建确认按钮,也不表示暂停执行。", Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["checkpointId","phaseId","findings"],"properties":{"checkpointId":{"type":"string"},"phaseId":{"type":"string","enum":["reading","crosscheck","summarizing","draft-ready"]},"focus":{"type":"string"},"remaining":{"type":"string"},"nextStep":{"type":"string"},"findings":{"type":"array","minItems":1,"maxItems":3,"items":{"type":"object","additionalProperties":false,"required":["findingKey","text","kind","sources"],"properties":{"findingKey":{"type":"string"},"text":{"type":"string"},"impact":{"type":"string"},"kind":{"type":"string","enum":["material_record","analysis","provisional","needs_user"]},"assumptions":{"type":"string"},"corrects":{"type":"string"},"sources":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["sourceId","blockIndex","quote"],"properties":{"sourceId":{"type":"string"},"blockIndex":{"type":"integer","minimum":0},"quote":{"type":"string","description":"原文连续短句,勿拼接或用省略号"}}}}}}}}}`)}
|
||||
return agentllm.ToolDecl{Name: workCheckpointTool, Description: "将本次材料整理中已形成的有用发现及时保存到同一任务卡。只引用已提供的 sourceId/blockIndex 和原文片段;material_record由宿主展示经核验的原文引用,不采用模型转述或影响判断;归纳、推算或判断用analysis/provisional并写明assumptions。审批/批准等肯定状态仅作明确原文的material_record;分析、影响、前提和任务状态不得把要求或建议写成已批准,不确定时用needs_user请用户核实。长材料在形成阶段结果时发布,不额外调用摘要模型;不要为了进度凑发现。普通发布最多6次,每次只提交1至3条新增发现;原文quote必须是同一段落连续出现的短句,不能拼接或用省略号。需要更正以前发现时用 corrects 填其 findingKey。此工具不创建确认按钮,也不表示暂停执行。", Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"required":["checkpointId","phaseId","findings"],"properties":{"checkpointId":{"type":"string"},"phaseId":{"type":"string","enum":["reading","crosscheck","summarizing","draft-ready"]},"focus":{"type":"string"},"remaining":{"type":"string"},"nextStep":{"type":"string"},"findings":{"type":"array","minItems":1,"maxItems":3,"items":{"type":"object","additionalProperties":false,"required":["findingKey","text","kind","sources"],"properties":{"findingKey":{"type":"string"},"text":{"type":"string"},"impact":{"type":"string"},"kind":{"type":"string","enum":["material_record","analysis","provisional","needs_user"]},"assumptions":{"type":"string"},"corrects":{"type":"string"},"sources":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["sourceId","blockIndex","quote"],"properties":{"sourceId":{"type":"string"},"blockIndex":{"type":"integer","minimum":0},"quote":{"type":"string","description":"原文连续短句,勿拼接或用省略号"}}}}}}}}}`)}
|
||||
}
|
||||
func publishCheckpoint(rc RunCtx, args string) agenttools.Result {
|
||||
if !checkpointEnabled(rc) {
|
||||
|
||||
Reference in New Issue
Block a user