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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user