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