feat: integrate AI candidate review and automatic publication
This commit is contained in:
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user