feat: integrate AI candidate review and automatic publication

This commit is contained in:
2026-10-09 22:33:38 +08:00
parent 04dbeb2ac6
commit 86f1e7a897
46 changed files with 2460 additions and 2362 deletions
+201
View File
@@ -0,0 +1,201 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"git.sundynix.cn/Blizzard/Veyra/internal/secret"
"github.com/google/uuid"
"go.uber.org/zap"
)
type CandidateAnalysisRepository interface {
Policy(context.Context) (model.AIReviewPolicy, error)
SavePolicy(context.Context, model.AIReviewPolicy, string, string) (model.AIReviewPolicy, error)
Claim(context.Context, model.AIReviewPolicy) (model.CandidateRevision, string, error)
Complete(context.Context, model.AIReviewPolicy, model.CandidateRevision, string, model.ProviderConfig, model.ProviderConfig, *model.AIConclusion, *model.AIConclusion, string) error
List(context.Context) ([]model.CandidateAnalysis, error)
}
type AnalysisClient interface {
Analyze(context.Context, model.ProviderConfig, string, json.RawMessage) (model.AIConclusion, error)
}
type CandidateAnalysisService struct {
repo CandidateAnalysisRepository
providers ProviderRepository
vault *secret.Vault
client AnalysisClient
development bool
wake chan struct{}
}
func NewCandidateAnalysisService(repo CandidateAnalysisRepository, providers ProviderRepository, vault *secret.Vault, client AnalysisClient, development bool) *CandidateAnalysisService {
return &CandidateAnalysisService{repo: repo, providers: providers, vault: vault, client: client, development: development, wake: make(chan struct{}, 1)}
}
func (s *CandidateAnalysisService) authorize(actor model.User) error {
if actor.Role != "ADMIN" || actor.Status != "ACTIVE" {
return apperror.ErrForbidden
}
if s == nil || s.repo == nil || !s.development {
return apperror.ErrUnavailable
}
return nil
}
func (s *CandidateAnalysisService) Policy(ctx context.Context, actor model.User) (model.AIReviewPolicy, error) {
if err := s.authorize(actor); err != nil {
return model.AIReviewPolicy{}, err
}
return s.repo.Policy(ctx)
}
func (s *CandidateAnalysisService) List(ctx context.Context, actor model.User) ([]model.CandidateAnalysis, error) {
if err := s.authorize(actor); err != nil {
return nil, err
}
return s.repo.List(ctx)
}
func (s *CandidateAnalysisService) Wake(actor model.User) error {
if err := s.authorize(actor); err != nil {
return err
}
select {
case s.wake <- struct{}{}:
default:
}
return nil
}
func (s *CandidateAnalysisService) SavePolicy(ctx context.Context, actor model.User, p model.AIReviewPolicy, requestID string) (model.AIReviewPolicy, error) {
if err := s.authorize(actor); err != nil {
return p, err
}
if p.Version < 1 {
return p, apperror.ErrInvalidInput
}
if _, err := uuid.Parse(requestID); err != nil {
return p, apperror.ErrInvalidInput
}
if p.Enabled {
for _, key := range []string{"model_primary", "model_review"} {
c, err := s.providers.Get(ctx, key)
if err != nil || !c.Enabled || c.ModelName == "" || c.BaseURL == "" || c.RequiresOpenAIAuth && !c.HasAPIKey {
return p, apperror.ErrInvalidInput
}
}
}
saved, err := s.repo.SavePolicy(ctx, p, actor.ID, requestID)
if err == nil {
_ = s.Wake(actor)
}
return saved, err
}
func ValidateAIConclusion(a model.AIConclusion, c model.CandidateRevision) error {
if a.SchemaVersion != "candidate_analysis.v1" || a.Symbol != c.Snapshot.Symbol || a.Exchange != c.Snapshot.Exchange || (a.Decision != "APPROVE" && a.Decision != "REJECT") || !validAIText(a.Summary, 1000) || !validAIText(a.Risks, 1000) || a.EvidenceIndices == nil || a.MissingInformation == nil || len(a.EvidenceIndices) > len(c.Snapshot.Evidence) || len(a.MissingInformation) > 12 {
return errors.New("MODEL_SCHEMA_INVALID")
}
if a.Decision == "APPROVE" && len(a.EvidenceIndices) == 0 {
return errors.New("MODEL_EVIDENCE_INVALID")
}
seen := map[int]bool{}
for _, index := range a.EvidenceIndices {
if index < 0 || index >= len(c.Snapshot.Evidence) || seen[index] {
return errors.New("MODEL_EVIDENCE_INVALID")
}
seen[index] = true
}
for _, text := range a.MissingInformation {
if !validAIText(text, 300) {
return errors.New("MODEL_SCHEMA_INVALID")
}
}
return nil
}
func validAIText(s string, max int) bool {
return strings.TrimSpace(s) != "" && utf8.RuneCountInString(s) <= max
}
func (s *CandidateAnalysisService) ProcessOne(ctx context.Context) error {
p, err := s.repo.Policy(ctx)
if err != nil {
return err
}
if !p.Enabled {
return apperror.ErrNotFound
}
primary, err := s.providers.Get(ctx, "model_primary")
if err != nil {
return err
}
review, err := s.providers.Get(ctx, "model_review")
if err != nil {
return err
}
c, token, err := s.repo.Claim(ctx, p)
if err != nil {
return err
}
var a, b *model.AIConclusion
message := ""
if err = model.ValidateDraft(model.CandidateDraftInput{Content: c.Snapshot, ObservedAt: c.ObservedAt, ValidUntil: c.ValidUntil}, time.Now().UTC()); err != nil {
message = "候选时效或价格规则未通过"
} else {
// The input omits user/account data, reviewer identities and credentials.
input := map[string]any{"stage": "analysis", "prompt_version": "candidate_analysis.v1", "content": c.Snapshot, "observed_at": c.ObservedAt, "valid_until": c.ValidUntil, "screening": c.Screening}
for index, config := range []model.ProviderConfig{primary, review} {
key, e := s.vault.Decrypt(config.EncryptedAPIKey, config.ConfigKey)
if e != nil {
message = "MODEL_CREDENTIAL_UNAVAILABLE"
break
}
raw, e := json.Marshal(input)
if e != nil {
message = "MODEL_INPUT_INVALID"
break
}
result, e := s.client.Analyze(ctx, config, key, raw)
if e == nil {
e = ValidateAIConclusion(result, c)
}
if e != nil {
message = e.Error()
break
}
if index == 0 {
a = &result
input["stage"] = "review"
input["primary_analysis"] = result
} else {
b = &result
}
}
}
return s.repo.Complete(ctx, p, c, token, primary, review, a, b, message)
}
func (s *CandidateAnalysisService) Run(ctx context.Context, logger *zap.Logger) {
if s == nil || s.repo == nil || s.providers == nil || s.client == nil || !s.development {
return
}
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
for i := 0; i < 4; i++ {
if ctx.Err() != nil {
return
}
if err := s.ProcessOne(ctx); err != nil {
if !errors.Is(err, apperror.ErrNotFound) {
logger.Warn("AI candidate worker could not process task")
}
break
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
case <-s.wake:
}
}
}
@@ -0,0 +1,21 @@
package service
import (
"git.sundynix.cn/Blizzard/Veyra/internal/model"
"testing"
)
func TestAIConclusionValidation(t *testing.T) {
c := model.CandidateRevision{Snapshot: model.CandidateContent{Symbol: "000042", Exchange: "SZSE", Evidence: []model.CandidateEvidence{{SourceURL: "https://example.com"}}}}
valid := model.AIConclusion{SchemaVersion: "candidate_analysis.v1", Symbol: "000042", Exchange: "SZSE", Decision: "APPROVE", Summary: "日线研究", Risks: "历史样本有限", EvidenceIndices: []int{0}, MissingInformation: []string{"未提供公告"}}
if err := ValidateAIConclusion(valid, c); err != nil {
t.Fatal(err)
}
for _, change := range []func(*model.AIConclusion){func(a *model.AIConclusion) { a.Symbol = "000043" }, func(a *model.AIConclusion) { a.Decision = "BUY" }, func(a *model.AIConclusion) { a.EvidenceIndices = []int{1} }, func(a *model.AIConclusion) { a.EvidenceIndices = []int{0, 0} }, func(a *model.AIConclusion) { a.EvidenceIndices = []int{} }, func(a *model.AIConclusion) { a.Risks = "" }, func(a *model.AIConclusion) { a.MissingInformation = nil }} {
a := valid
change(&a)
if ValidateAIConclusion(a, c) == nil {
t.Fatal("invalid conclusion accepted")
}
}
}
@@ -0,0 +1,43 @@
package service
import (
"context"
"strings"
"time"
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
"golang.org/x/crypto/bcrypt"
)
type DevelopmentUserCredentialRepository interface {
ChangeDevelopmentUserCredential(context.Context, string, string, string, time.Time) (string, error)
}
type DevelopmentUserCredentialService struct {
repo DevelopmentUserCredentialRepository
development bool
}
func NewDevelopmentUserCredentialService(repo DevelopmentUserCredentialRepository, development bool) *DevelopmentUserCredentialService {
return &DevelopmentUserCredentialService{repo: repo, development: development}
}
// Set is an explicit local/test maintenance operation for an existing active USER.
// It preserves identity and account ownership and revokes previous sessions.
func (s *DevelopmentUserCredentialService) Set(ctx context.Context, previousLogin, login, password string) (string, error) {
if !s.development {
return "", apperror.ErrForbidden
}
if s.repo == nil {
return "", apperror.ErrUnavailable
}
previousLogin = strings.ToLower(strings.TrimSpace(previousLogin))
login = strings.ToLower(strings.TrimSpace(login))
if !loginPattern.MatchString(previousLogin) || !loginPattern.MatchString(login) || len(password) < 4 || len(password) > 72 {
return "", apperror.ErrInvalidInput
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", apperror.ErrUnavailable
}
return s.repo.ChangeDevelopmentUserCredential(ctx, previousLogin, login, string(hash), time.Now().UTC())
}
+19 -1
View File
@@ -112,7 +112,7 @@ func (s *ProviderService) Save(ctx context.Context, actor model.User, key, reque
if input.WireAPI != "responses" && input.WireAPI != "chat_completions" {
return c, apperror.ErrInvalidInput
}
if utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.ModelName) > 120 || len(input.BaseURL) > 2048 || len(input.ModelCatalogURL) > 2048 || input.TimeoutSeconds < 1 || input.TimeoutSeconds > 30 {
if utf8.RuneCountInString(input.Name) > 80 || utf8.RuneCountInString(input.ModelName) > 120 || len(input.BaseURL) > 2048 || len(input.ModelCatalogURL) > 2048 || input.TimeoutSeconds < 1 || input.TimeoutSeconds > 120 {
return c, apperror.ErrInvalidInput
}
if input.BaseURL != "" {
@@ -173,3 +173,21 @@ func (s *ProviderService) Save(ctx context.Context, actor model.User, key, reque
}
return s.repo.Save(ctx, c, input.ExpectedVersion, actor.ID, requestID, input.APIKey != nil || input.ClearAPIKey)
}
// CopyPrimaryToReview lets a single endpoint/key configure a separate review call.
// Credentials stay in the server vault and are rebound to the destination config.
func (s *ProviderService) CopyPrimaryToReview(ctx context.Context, actor model.User, requestID string, expected int64) (model.ProviderConfig, error) {
if err := s.authorize(actor); err != nil {
return model.ProviderConfig{}, err
}
primary, err := s.repo.Get(ctx, "model_primary")
if err != nil {
return model.ProviderConfig{}, err
}
key, err := s.vault.Decrypt(primary.EncryptedAPIKey, primary.ConfigKey)
if err != nil {
return model.ProviderConfig{}, err
}
disabled, auth := primary.DisableResponseStorage, primary.RequiresOpenAIAuth
return s.Save(ctx, actor, "model_review", requestID, ProviderInput{Name: primary.Name, BaseURL: primary.BaseURL, ModelName: primary.ModelName, WireAPI: primary.WireAPI, ModelCatalogURL: primary.ModelCatalogURL, DisableResponseStorage: &disabled, RequiresOpenAIAuth: &auth, Enabled: primary.Enabled, TimeoutSeconds: primary.TimeoutSeconds, APIKey: &key, ExpectedVersion: expected})
}