feat: integrate AI candidate review and automatic publication
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func testCandidateAI(t *testing.T, ctx context.Context, pool *pgxpool.Pool, admin model.User) {
|
||||
// Start with isolated candidates to avoid consuming other scenario fixtures.
|
||||
_, err := pool.Exec(ctx, `UPDATE veyra_candidate_revision SET valid_until=now()-interval '1 second' WHERE status='PENDING_REVIEW'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providers := repository.NewProviderRepository(pool)
|
||||
configs := map[string]model.ProviderConfig{}
|
||||
for _, key := range []string{"model_primary", "model_review"} {
|
||||
previous, e := providers.Get(ctx, key)
|
||||
if e != nil && !errors.Is(e, apperror.ErrNotFound) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p := model.ProviderConfig{ConfigKey: key, Name: "Fixture", BaseURL: "https://example.com/v1", ModelName: "fixture", Enabled: true, TimeoutSeconds: 15, LastTestStatus: "NOT_TESTED", WireAPI: "responses", DisableResponseStorage: true}
|
||||
saved, e := providers.Save(ctx, p, previous.Version, admin.ID, uuid.NewString(), false)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
configs[key] = saved
|
||||
}
|
||||
repo := repository.NewCandidateAnalysisRepository(pool)
|
||||
policy, err := repo.Policy(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy.Enabled = true
|
||||
policy.AutoPublish = true
|
||||
policy, err = repo.SavePolicy(ctx, policy, admin.ID, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidateRepo := repository.NewCandidateRepository(pool)
|
||||
create := func() model.CandidateRevision {
|
||||
input := model.CandidateDraftInput{Content: model.CandidateContent{Symbol: "000042", Exchange: "SZSE", Name: "Fixture", StrategyCode: "trend_following", Title: "Fixture", Thesis: "Daily crossing", Evidence: []model.CandidateEvidence{{SourceURL: "https://example.com/bars", Locator: "bar:today", Summary: "Daily input"}}, CounterEvidence: "Historical data", Invalidation: "Trend invalid", ReferencePlan: model.CandidateReferencePlan{EntryLow: "10.00", EntryHigh: "11.00", StopLoss: "9.00", TargetPrice: "12.00"}}, ObservedAt: time.Now().Add(-time.Hour), ValidUntil: time.Now().Add(time.Hour)}
|
||||
c, e := candidateRepo.Create(ctx, input, admin.ID, uuid.NewString(), "", 0)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c, e = candidateRepo.Transition(ctx, c.ID, "SUBMIT", model.CandidateActionInput{ExpectedVersion: c.Version, Reason: "fixture"}, admin.ID, uuid.NewString())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return c
|
||||
}
|
||||
c := create()
|
||||
claimed, token, err := repo.Claim(ctx, policy)
|
||||
if err != nil || claimed.ID != c.ID {
|
||||
t.Fatal("claim failed", err)
|
||||
}
|
||||
if _, _, err := repo.Claim(ctx, policy); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("duplicate claim allowed", err)
|
||||
}
|
||||
approved := model.AIConclusion{SchemaVersion: "candidate_analysis.v1", Symbol: c.Snapshot.Symbol, Exchange: c.Snapshot.Exchange, Decision: "APPROVE", Summary: "Evidence supported", Risks: "Historical sample", EvidenceIndices: []int{0}, MissingInformation: []string{"No fundamentals"}}
|
||||
if err = repo.Complete(ctx, policy, claimed, token, configs["model_primary"], configs["model_review"], &approved, &approved, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.Complete(ctx, policy, claimed, token, configs["model_primary"], configs["model_review"], &approved, &approved, ""); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("completed claim was replayed", err)
|
||||
}
|
||||
detail, err := candidateRepo.Detail(ctx, c.ID, false)
|
||||
if err != nil || detail.Status != "PUBLISHED" || detail.AIAnalysis == nil || detail.AIAnalysis.Status != "PUBLISHED" {
|
||||
t.Fatal("automatic publication/analysis missing", err)
|
||||
}
|
||||
if _, _, err := repo.Claim(ctx, policy); !errors.Is(err, apperror.ErrNotFound) {
|
||||
t.Fatal("published task reclaimed")
|
||||
}
|
||||
// Changing policy during inference invalidates the output, even when both models approve.
|
||||
c = create()
|
||||
claimed, token, err = repo.Claim(ctx, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldPolicy := policy
|
||||
policy.AutoPublish = false
|
||||
policy, err = repo.SavePolicy(ctx, policy, admin.ID, uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.Complete(ctx, oldPolicy, claimed, token, configs["model_primary"], configs["model_review"], &approved, &approved, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err = candidateRepo.Detail(ctx, c.ID, true)
|
||||
if err != nil || detail.Status != "PENDING_REVIEW" {
|
||||
t.Fatal("stale policy published")
|
||||
}
|
||||
// Manual mode stores approved analysis but never publishes.
|
||||
claimed, token, err = repo.Claim(ctx, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.Complete(ctx, policy, claimed, token, configs["model_primary"], configs["model_review"], &approved, &approved, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err = candidateRepo.Detail(ctx, c.ID, true)
|
||||
if err != nil || detail.Status != "PENDING_REVIEW" || detail.AIAnalysis == nil {
|
||||
t.Fatal("manual mode violated")
|
||||
}
|
||||
// Worker makes two independent calls; a rejection cannot auto publish.
|
||||
c = create()
|
||||
vault, _ := secret.NewVault("")
|
||||
fake := &rejectModel{}
|
||||
worker := service.NewCandidateAnalysisService(repo, providers, vault, fake, true)
|
||||
if err = worker.ProcessOne(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err = candidateRepo.Detail(ctx, c.ID, true)
|
||||
if err != nil || detail.Status != "PENDING_REVIEW" || fake.calls != 2 {
|
||||
t.Fatal("model rejection not honored")
|
||||
}
|
||||
results, err := repo.List(ctx)
|
||||
if err != nil || len(results) != 4 {
|
||||
t.Fatal("durable results missing", err, len(results))
|
||||
}
|
||||
}
|
||||
|
||||
type rejectModel struct{ calls int }
|
||||
|
||||
func (f *rejectModel) Analyze(_ context.Context, _ model.ProviderConfig, _ string, input json.RawMessage) (model.AIConclusion, error) {
|
||||
f.calls++
|
||||
var payload struct {
|
||||
Content model.CandidateContent `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &payload); err != nil {
|
||||
return model.AIConclusion{}, err
|
||||
}
|
||||
return model.AIConclusion{SchemaVersion: "candidate_analysis.v1", Symbol: payload.Content.Symbol, Exchange: payload.Content.Exchange, Decision: "REJECT", Summary: "Insufficient evidence", Risks: "Missing data", EvidenceIndices: []int{}, MissingInformation: []string{"Context"}}, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/repository"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/service"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func testDevelopmentUserCredentialChange(t *testing.T, ctx context.Context, pool *pgxpool.Pool, auth *service.AuthService) {
|
||||
t.Helper()
|
||||
user, err := auth.ProvisionUser(ctx, "maintenance_user", "a-maintenance-password", "Maintenance", "USER")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := auth.Login(ctx, "maintenance_user", "a-maintenance-password", "app")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := repository.NewIdentityRepository(pool)
|
||||
updater := service.NewDevelopmentUserCredentialService(repo, true)
|
||||
if _, err := service.NewDevelopmentUserCredentialService(repo, false).Set(ctx, "maintenance_user", "maintenance_new", "tiny"); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("production maintenance allowed")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "operator", "maintenance_new", "tiny"); !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("administrator changed through USER command")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "maintenance_user", "maintenance_new", "abc"); !errors.Is(err, apperror.ErrInvalidInput) {
|
||||
t.Fatal("invalid password accepted")
|
||||
}
|
||||
if _, err := updater.Set(ctx, "maintenance_user", "bob", "tiny"); !errors.Is(err, apperror.ErrConflict) {
|
||||
t.Fatal("duplicate login accepted")
|
||||
}
|
||||
if _, err := auth.Authenticate(ctx, session.Token, "app"); err != nil {
|
||||
t.Fatal("failed change revoked session")
|
||||
}
|
||||
id, err := updater.Set(ctx, "maintenance_user", "maintenance_new", "tiny")
|
||||
if err != nil || id != user.ID {
|
||||
t.Fatal("user identity not preserved")
|
||||
}
|
||||
if _, err := auth.Authenticate(ctx, session.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("old session remained valid")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "maintenance_user", "a-maintenance-password", "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("old credentials remained valid")
|
||||
}
|
||||
login, err := auth.Login(ctx, "maintenance_new", "tiny", "app")
|
||||
if err != nil || login.User.ID != user.ID {
|
||||
t.Fatal("new credentials failed")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "maintenance_new", "tiny", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) && !errors.Is(err, apperror.ErrForbidden) {
|
||||
t.Fatal("USER authenticated as administrator")
|
||||
}
|
||||
_, credential, err := repo.FindCredential(ctx, "maintenance_new")
|
||||
if err != nil || credential.PasswordHash == "tiny" {
|
||||
t.Fatal("password stored without hashing")
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,8 @@ func TestIdentityAccountLifecycle(t *testing.T) {
|
||||
testScreeningLifecycle(t, ctx, pool, adminUser, u1)
|
||||
testTradeLedger(t, ctx, pool, auth)
|
||||
testAdminCredentialChange(t, ctx, pool, auth)
|
||||
testDevelopmentUserCredentialChange(t, ctx, pool, auth)
|
||||
testCandidateAI(t, ctx, pool, adminUser)
|
||||
}
|
||||
|
||||
type allowLogin struct{}
|
||||
|
||||
Reference in New Issue
Block a user