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:
}
}
}