65653e5c01
build / build (push) Successful in 1m14s
已覆盖只允许正确或错误,未覆盖可待确认,缺引用不再阻止正确结论。 复用统一事务写入与不可变审计重评旧待确认,保留原问答和人工结论。 验证:全仓 make verify、真实隔离 PostgreSQL 全包与 race 回归通过。
154 lines
7.0 KiB
Go
154 lines
7.0 KiB
Go
package qaopsmirror
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"knowledge-governance/internal/dobotaiops"
|
|
pg "knowledge-governance/internal/platform/postgres"
|
|
"knowledge-governance/internal/qaopsai"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAIManualBatchPersistsProcessesOnlyMissingFieldsAndReplaysWithoutExtraCalls(t *testing.T) {
|
|
pool := mirrorFixture(t)
|
|
ctx := context.Background()
|
|
tenant := "fixture-ai-work"
|
|
keyFile := filepath.Join(t.TempDir(), "master-key")
|
|
if err := os.WriteFile(keyFile, []byte(strings.Repeat("a", 32)), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
messages, _ := body["messages"].([]any)
|
|
probe := len(messages) > 0 && strings.Contains(messages[0].(map[string]any)["content"].(string), "connection probe")
|
|
result := `{"ok":true}`
|
|
if !probe {
|
|
result = `{"fields":{"is_correct":{"value":"否","basis":"semantic_only","reason":"回答未给出所问的连接配置步骤","quotes":[]},"problem_type":{"value":"使用配置","basis":"semantic_only","reason":"问题询问连接配置","quotes":[]},"root_cause":{"value":"","basis":"insufficient_evidence","reason":"无法从现有信息确定根因","quotes":[]}}}`
|
|
}
|
|
if !probe && strings.Contains(messages[0].(map[string]any)["content"].(string), "翻成中文") {
|
|
result = `{"question_chinese":"MG400 如何连接?"}`
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"model": "fixture-actual-model", "choices": []any{map[string]any{"message": map[string]string{"content": result}, "finish_reason": "stop"}}})
|
|
}))
|
|
defer server.Close()
|
|
ai := qaopsai.NewStore(pool, tenant, qaopsai.Options{KeyFile: keyFile, AllowPrivate: true})
|
|
if _, err := ai.SaveSettings(ctx, qaopsai.SaveSettings{BaseURL: server.URL + "/v1", Model: "fixture", KeyAction: "replace", APIKey: "fixture-only"}, "fixture-admin"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
master := NewStore(pool, tenant)
|
|
entry := fixtureEntry("work-record", "fixture answer")
|
|
var wire map[string]any
|
|
_ = json.Unmarshal(entry.Payload, &wire)
|
|
wire["question"] = "How do I connect MG400?"
|
|
wire["master_fields"] = map[string]string{"product_category": "MG400"}
|
|
entry.Payload, _ = json.Marshal(wire)
|
|
job := claimMirrorJob(t, master, pg.NewStore(pool), "work-seed")
|
|
if err := master.HandleSync(ctx, *job, func(context.Context) (Source, error) {
|
|
return &fixtureSource{rows: []dobotaiops.MirrorRecord{entry}}, nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
preview, err := ai.Preview(ctx, qaopsai.Scope{Kind: "evaluation", IDs: []string{"work-record"}}, "fixture-admin")
|
|
if err != nil || preview.Eligible != 1 {
|
|
t.Fatalf("eligible preview not produced: %+v %v", preview, err)
|
|
}
|
|
batch, err := ai.Start(ctx, preview.ID, strings.Repeat("n", 24), "fixture-admin")
|
|
if err != nil || batch.Total != 1 {
|
|
t.Fatal("frozen persistent batch not created", err)
|
|
}
|
|
workerCtx, stopWorker := context.WithCancel(ctx)
|
|
defer stopWorker()
|
|
workerDone := make(chan struct{})
|
|
go func() { ai.Run(workerCtx, func(error) {}); close(workerDone) }()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
current, e := ai.GetBatch(ctx, batch.ID)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if current.Succeeded == 1 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("background loop did not consume work after the initiating request returned")
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
stopWorker()
|
|
select {
|
|
case <-workerDone:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("background loop did not stop cleanly")
|
|
}
|
|
var worked bool
|
|
batch, err = ai.GetBatch(ctx, batch.ID)
|
|
if err != nil || batch.Succeeded != 1 || batch.Queued != 0 || batch.Failed != 0 || calls != 2 {
|
|
t.Fatalf("wrong real work counters: %+v err=%v calls=%d", batch, err, calls)
|
|
}
|
|
replay, err := ai.Start(ctx, preview.ID, strings.Repeat("n", 24), "fixture-admin")
|
|
if err != nil || replay.ID != batch.ID {
|
|
t.Fatal("batch nonce was not idempotent", err)
|
|
}
|
|
worked, err = ai.ProcessOne(ctx)
|
|
if err != nil || worked || calls != 2 {
|
|
t.Fatal("completed work was called again", err)
|
|
}
|
|
projected, err := master.GetMasterRecord(ctx, tenant, "work-record")
|
|
if err != nil || projected.MasterFields["is_correct"] != "否" || projected.MasterFields["problem_type"] != "使用配置" || !strings.Contains(string(projected.BackgroundTasks), "succeeded") {
|
|
t.Fatal("saved model assessment not consumed by master list/detail projection", err)
|
|
}
|
|
projectedViews, err := master.ReadMasterQualityViews(ctx, []dobotaiops.QualityKey{{ID: projected.ID, InputHash: projected.QualityInputHash}})
|
|
if err != nil || projectedViews[projected.ID].Effective.ProblemType != "使用配置" {
|
|
t.Fatal("master quality/dashboard projection does not consume model output", err)
|
|
}
|
|
noMore, err := ai.Preview(ctx, qaopsai.Scope{Kind: "evaluation", IDs: []string{"work-record"}}, "fixture-admin")
|
|
if err != nil || noMore.Eligible != 0 {
|
|
t.Fatal("legitimately empty root was repeatedly requeued", err)
|
|
}
|
|
translationPreview, err := ai.Preview(ctx, qaopsai.Scope{Kind: "translation", IDs: []string{"work-record"}}, "fixture-admin")
|
|
if err != nil || translationPreview.Eligible != 1 {
|
|
t.Fatal("independent translation preview missing", err)
|
|
}
|
|
translatedBatch, err := ai.Start(ctx, translationPreview.ID, strings.Repeat("t", 24), "fixture-admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = pool.Exec(ctx, `UPDATE qa_ops_ai_runtime SET next_request_at=clock_timestamp()-interval '1 second' WHERE tenant_key=$1`, tenant); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
worked, err = ai.ProcessOne(ctx)
|
|
if err != nil || !worked {
|
|
t.Fatal("translation work did not run", err)
|
|
}
|
|
translatedBatch, err = ai.GetBatch(ctx, translatedBatch.ID)
|
|
if err != nil || translatedBatch.Succeeded != 1 || calls != 3 {
|
|
t.Fatalf("translation mixed with analysis counters: %+v %v calls=%d", translatedBatch, err, calls)
|
|
}
|
|
projected, err = master.GetMasterRecord(ctx, tenant, "work-record")
|
|
if err != nil || projected.MasterFields["question_chinese"] != "MG400 如何连接?" || projected.Question != "How do I connect MG400?" {
|
|
t.Fatal("translation not read back or original question overwritten", err)
|
|
}
|
|
page, err := master.ListMasterRecords(ctx, dobotaiops.RecordQuery{Window: dobotaiops.Window{TenantKey: tenant, Start: projected.AskedAt.Add(-time.Second), End: projected.AskedAt.Add(time.Second)}, Keyword: "如何连接", Limit: 100})
|
|
if err != nil || page.Total != 1 || len(page.Items) != 1 {
|
|
t.Fatal("Chinese search does not consume saved translation", err)
|
|
}
|
|
var payload []byte
|
|
if err = pool.QueryRow(ctx, `SELECT payload FROM qa_ops_master_records WHERE tenant_key=$1 AND id='work-record'`, tenant).Scan(&payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var unchanged map[string]any
|
|
_ = json.Unmarshal(payload, &unchanged)
|
|
if unchanged["master_fields"].(map[string]any)["product_category"] != "MG400" || strings.Contains(string(payload), "待确认") {
|
|
t.Fatal("AI overwrote original stored fields instead of using an audited effective layer")
|
|
}
|
|
}
|