intelligent_test_service.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  7. "log/slog"
  8. "regexp"
  9. "strings"
  10. "sync"
  11. "time"
  12. "unicode/utf8"
  13. )
  14. var ErrIntelligentTestNotFound = infraerrors.NotFound("INTELLIGENT_TEST_NOT_FOUND", "test result not found")
  15. var ErrIntelligentTestForbidden = infraerrors.Forbidden("INTELLIGENT_TEST_FORBIDDEN", "administrator access required")
  16. var ErrIntelligentTestConflict = infraerrors.Conflict("INTELLIGENT_TEST_CONFLICT", "idempotency key was already used with different parameters")
  17. func intelligentTestBad(message string) error {
  18. return infraerrors.BadRequest("INTELLIGENT_TEST_INVALID", message)
  19. }
  20. type IntelligentTestService struct {
  21. repo IntelligentTestRepository
  22. runner IntelligentTestRunner
  23. evaluators map[string]IntelligentTestEvaluator
  24. cancel context.CancelFunc
  25. mu sync.Mutex
  26. wg sync.WaitGroup
  27. }
  28. func NewIntelligentTestService(repo IntelligentTestRepository, runner *AccountTestService) *IntelligentTestService {
  29. return &IntelligentTestService{repo: repo, runner: runner, evaluators: DefaultIntelligentTestEvaluators()}
  30. }
  31. func (s *IntelligentTestService) authorize(ctx context.Context, actor int64) error {
  32. ok, err := s.repo.IsAdmin(ctx, actor)
  33. if err != nil {
  34. return err
  35. }
  36. if !ok {
  37. return ErrIntelligentTestForbidden
  38. }
  39. return nil
  40. }
  41. func normalizeIntelligentFilter(f IntelligentTestFilter) IntelligentTestFilter {
  42. if f.Page < 1 {
  43. f.Page = 1
  44. }
  45. if f.PageSize < 1 {
  46. f.PageSize = 24
  47. }
  48. if f.PageSize > 100 {
  49. f.PageSize = 100
  50. }
  51. return f
  52. }
  53. func (s *IntelligentTestService) Accounts(ctx context.Context, actor int64, f IntelligentTestFilter) (*IntelligentTestAccounts, error) {
  54. if err := s.authorize(ctx, actor); err != nil {
  55. return nil, err
  56. }
  57. return s.repo.Accounts(ctx, normalizeIntelligentFilter(f))
  58. }
  59. func (s *IntelligentTestService) Records(ctx context.Context, actor int64, f IntelligentTestFilter) (*IntelligentTestRecords, error) {
  60. if err := s.authorize(ctx, actor); err != nil {
  61. return nil, err
  62. }
  63. return s.repo.Records(ctx, normalizeIntelligentFilter(f))
  64. }
  65. func (s *IntelligentTestService) Get(ctx context.Context, actor, id int64) (*IntelligentTestRecord, error) {
  66. if err := s.authorize(ctx, actor); err != nil {
  67. return nil, err
  68. }
  69. record, err := s.repo.Get(ctx, id)
  70. if err == nil && record.ResultImage == "" && strings.Contains(record.Result, "<svg") {
  71. record.ResultImage, _, _ = PrepareIntelligentSVGPreview(record.Result)
  72. }
  73. return record, err
  74. }
  75. func (s *IntelligentTestService) Settings(ctx context.Context, actor int64) ([]IntelligentTestSetting, error) {
  76. if err := s.authorize(ctx, actor); err != nil {
  77. return nil, err
  78. }
  79. return s.repo.Settings(ctx)
  80. }
  81. func (s *IntelligentTestService) UpdateSetting(ctx context.Context, actor int64, setting *IntelligentTestSetting) error {
  82. if err := s.authorize(ctx, actor); err != nil {
  83. return err
  84. }
  85. if setting == nil || !regexp.MustCompile(`^[a-z][a-z0-9_]{1,63}$`).MatchString(setting.TestType) {
  86. return intelligentTestBad("invalid test type")
  87. }
  88. cfg := &setting.Config
  89. cfg.Execution = nil // Only a runner may produce an execution snapshot.
  90. if err := validateIntelligentTestConfig(cfg, s.evaluators); err != nil {
  91. return err
  92. }
  93. return s.repo.UpdateSetting(ctx, actor, setting)
  94. }
  95. func validateIntelligentTestConfig(cfg *IntelligentTestConfig, evaluators map[string]IntelligentTestEvaluator) error {
  96. if err := validateIntelligentTextModel(cfg.Model); err != nil {
  97. return intelligentTestBad(err.Error())
  98. }
  99. cfg.Prompt = strings.TrimSpace(cfg.Prompt)
  100. cfg.Model = strings.TrimSpace(cfg.Model)
  101. if len(cfg.Prompt) == 0 || utf8.RuneCountInString(cfg.Prompt) > 16000 || utf8.RuneCountInString(cfg.Model) > 200 || utf8.RuneCountInString(cfg.ExpectedAnswer) > 200 || utf8.RuneCountInString(cfg.AnswerUnit) > 32 {
  102. return intelligentTestBad("prompt/model/answer length is invalid")
  103. }
  104. if cfg.TimeoutSeconds < 30 || cfg.TimeoutSeconds > 600 {
  105. return intelligentTestBad("timeout_seconds must be 30–600")
  106. }
  107. if _, ok := evaluators[cfg.Evaluator]; !ok {
  108. return intelligentTestBad("unknown evaluator")
  109. }
  110. if cfg.Evaluator == "exact_answer" && strings.TrimSpace(cfg.ExpectedAnswer) == "" {
  111. return intelligentTestBad("expected_answer is required")
  112. }
  113. if cfg.AnswerType != "" && cfg.AnswerType != "auto" && cfg.AnswerType != "number" && cfg.AnswerType != "text" {
  114. return intelligentTestBad("invalid answer_type")
  115. }
  116. if cfg.AnswerFormat != "" && cfg.AnswerFormat != "answer_line" && cfg.AnswerFormat != "free_text" {
  117. return intelligentTestBad("invalid answer_format")
  118. }
  119. if cfg.AnswerUnitMode != "" && cfg.AnswerUnitMode != "none" && cfg.AnswerUnitMode != "configured" && cfg.AnswerUnitMode != "legacy" {
  120. return intelligentTestBad("invalid answer_unit_mode")
  121. }
  122. if cfg.Evaluator == "exact_answer" && cfg.AnswerUnitMode == "configured" && strings.TrimSpace(cfg.AnswerUnit) == "" {
  123. return intelligentTestBad("请选择不接受单位,或填写允许的单位")
  124. }
  125. if cfg.Evaluator == "exact_answer" && cfg.AnswerType == "number" {
  126. if _, ok := normalizeIntelligentNumber(cfg.ExpectedAnswer, intelligentAnswerUnit(*cfg)); !ok {
  127. return intelligentTestBad("数值题的标准答案不是有效数值")
  128. }
  129. }
  130. return nil
  131. }
  132. func (s *IntelligentTestService) Enqueue(ctx context.Context, actor int64, req IntelligentTestEnqueue) (*IntelligentTestEnqueued, error) {
  133. if err := s.authorize(ctx, actor); err != nil {
  134. return nil, err
  135. }
  136. if !regexp.MustCompile(`^[A-Za-z0-9_-]{16,100}$`).MatchString(req.IdempotencyKey) {
  137. return nil, intelligentTestBad("idempotency_key must contain 16–100 letters, digits, underscores or hyphens")
  138. }
  139. if len(req.AccountIDs) < 1 || len(req.AccountIDs) > 100 || len(req.TestTypes) < 1 || len(req.TestTypes) > 10 || len(req.AccountIDs)*len(req.TestTypes) > 200 {
  140. return nil, intelligentTestBad("select 1–100 accounts and at most 200 tests")
  141. }
  142. seen := map[int64]bool{}
  143. for _, id := range req.AccountIDs {
  144. if id < 1 || seen[id] {
  145. return nil, intelligentTestBad("account IDs must be positive and unique")
  146. }
  147. seen[id] = true
  148. }
  149. types := map[string]bool{}
  150. for _, kind := range req.TestTypes {
  151. if types[kind] {
  152. return nil, intelligentTestBad("test types must be unique")
  153. }
  154. types[kind] = true
  155. }
  156. for kind, model := range req.Models {
  157. if err := validateIntelligentTextModel(model); err != nil {
  158. return nil, intelligentTestBad(err.Error())
  159. }
  160. if !types[kind] || utf8.RuneCountInString(strings.TrimSpace(model)) > 200 {
  161. return nil, intelligentTestBad("model overrides must target selected test types and be at most 200 characters")
  162. }
  163. }
  164. return s.repo.Enqueue(ctx, actor, req)
  165. }
  166. func (s *IntelligentTestService) Capabilities(ctx context.Context, user int64, f IntelligentTestFilter) ([]AccountCapability, int64, error) {
  167. return s.repo.Capabilities(ctx, user, normalizeIntelligentFilter(f))
  168. }
  169. func (s *IntelligentTestService) PublicRecords(ctx context.Context, user int64, f IntelligentTestFilter) (*PublicAccountTests, error) {
  170. return s.repo.PublicRecords(ctx, user, normalizeIntelligentFilter(f))
  171. }
  172. func (s *IntelligentTestService) PublicGet(ctx context.Context, user, id int64) (*PublicAccountTest, error) {
  173. record, err := s.repo.PublicGet(ctx, user, id)
  174. if err == nil && record.ResultImage == "" && strings.Contains(record.Result, "<svg") {
  175. record.ResultImage, _, _ = PrepareIntelligentSVGPreview(record.Result)
  176. }
  177. return record, err
  178. }
  179. func (s *IntelligentTestService) Start() {
  180. if s == nil {
  181. return
  182. }
  183. s.mu.Lock()
  184. defer s.mu.Unlock()
  185. if s.cancel != nil {
  186. return
  187. }
  188. ctx, cancel := context.WithCancel(context.Background())
  189. s.cancel = cancel
  190. for i := 0; i < 2; i++ {
  191. s.wg.Add(1)
  192. go func() {
  193. defer s.wg.Done()
  194. ticker := time.NewTicker(2 * time.Second)
  195. defer ticker.Stop()
  196. for {
  197. select {
  198. case <-ctx.Done():
  199. return
  200. case <-ticker.C:
  201. s.work(ctx)
  202. }
  203. }
  204. }()
  205. }
  206. }
  207. func (s *IntelligentTestService) Stop() {
  208. if s == nil {
  209. return
  210. }
  211. s.mu.Lock()
  212. if s.cancel != nil {
  213. s.cancel()
  214. }
  215. s.mu.Unlock()
  216. s.wg.Wait()
  217. }
  218. func (s *IntelligentTestService) work(ctx context.Context) {
  219. claimCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
  220. record, err := s.repo.Claim(claimCtx)
  221. cancel()
  222. if err != nil {
  223. if ctx.Err() == nil {
  224. slog.Error("intelligent test claim failed", "error", err)
  225. }
  226. return
  227. }
  228. if record == nil {
  229. return
  230. }
  231. s.run(ctx, record)
  232. saveCtx, saveCancel := context.WithTimeout(context.Background(), 10*time.Second)
  233. defer saveCancel()
  234. if record.Status == "queued" {
  235. if err := s.repo.DeferForCapacity(saveCtx, record); err != nil {
  236. slog.Error("intelligent capacity requeue failed", "id", record.ID, "error", err)
  237. }
  238. return
  239. }
  240. if err := s.repo.Finish(saveCtx, record); err != nil {
  241. slog.Error("intelligent test result save failed; lease recovery will record interruption", "id", record.ID, "error", err)
  242. }
  243. }
  244. func (s *IntelligentTestService) run(ctx context.Context, r *IntelligentTestRecord) {
  245. start := time.Now()
  246. defer func() {
  247. r.DurationMS = time.Since(start).Milliseconds()
  248. if p := recover(); p != nil {
  249. r.Status = "failed"
  250. r.ErrorMessage = "test runner failed unexpectedly"
  251. slog.Error("intelligent test panic", "id", r.ID, "type", fmt.Sprintf("%T", p))
  252. }
  253. if r.Status != "completed" && r.Status != "queued" && r.Status != "running" {
  254. r.Evaluation = newIntelligentAssessment("")
  255. r.Evaluation["execution_status"], r.Evaluation["answer_verdict"], r.Evaluation["format_verdict"] = r.Status, "not_evaluated", "not_evaluated"
  256. }
  257. }()
  258. if r.ConfigSnapshot == nil {
  259. r.Status = "failed"
  260. r.ErrorMessage = "missing test configuration snapshot"
  261. return
  262. }
  263. runCtx, cancel := context.WithTimeout(ctx, time.Duration(r.ConfigSnapshot.TimeoutSeconds)*time.Second)
  264. defer cancel()
  265. err := s.runner.RunIntelligentTest(runCtx, r)
  266. if errors.Is(err, ErrIntelligentAccountBusy) {
  267. r.Status = "queued"
  268. r.QueueReason = "等待账号空闲,不占用额外业务并发"
  269. var wait *TestAdmissionWaitError
  270. if errors.As(err, &wait) {
  271. r.AvailableAt = &wait.Until
  272. r.QueueReason = wait.Reason + ",任务延后执行"
  273. }
  274. return
  275. }
  276. if runCtx.Err() != nil {
  277. r.Status = "failed"
  278. r.ErrorMessage = "测试超时或服务中断;未自动重试以避免重复计费"
  279. return
  280. }
  281. if err != nil {
  282. if r.Status == "running" || r.Status == "" {
  283. r.Status = "failed"
  284. }
  285. if r.ErrorMessage == "" {
  286. r.ErrorMessage = err.Error()
  287. }
  288. r.Evaluation = newIntelligentAssessment(r.ConfigSnapshot.Evaluator)
  289. r.Evaluation["execution_status"], r.Evaluation["answer_verdict"], r.Evaluation["format_verdict"] = "failed", "not_evaluated", "not_evaluated"
  290. return
  291. }
  292. evaluator, ok := s.evaluators[r.ConfigSnapshot.Evaluator]
  293. if !ok {
  294. r.Status = "failed"
  295. r.ErrorMessage = "evaluator unavailable"
  296. return
  297. }
  298. evaluated := evaluator.Evaluate(r.Result, *r.ConfigSnapshot)
  299. r.Status = evaluated.Status
  300. r.Score = evaluated.Score
  301. r.ResultImage = evaluated.Image
  302. r.Evaluation = evaluated.Detail
  303. }
  304. func (s *IntelligentTestService) PreviewEvaluation(ctx context.Context, actor int64, output string, cfg IntelligentTestConfig) (*IntelligentTestRecord, error) {
  305. if err := s.authorize(ctx, actor); err != nil {
  306. return nil, err
  307. }
  308. if len(output) > 512<<10 {
  309. return nil, intelligentTestBad("试判输出不能超过 512 KiB")
  310. }
  311. if err := validateIntelligentTestConfig(&cfg, s.evaluators); err != nil {
  312. return nil, err
  313. }
  314. e := s.evaluators[cfg.Evaluator].Evaluate(output, cfg)
  315. return &IntelligentTestRecord{Status: e.Status, Score: e.Score, Result: output, ResultImage: e.Image, Evaluation: e.Detail}, nil
  316. }
  317. func (s *IntelligentTestService) Cancel(ctx context.Context, actor, id int64) (*IntelligentTestRecord, error) {
  318. if err := s.authorize(ctx, actor); err != nil {
  319. return nil, err
  320. }
  321. return s.repo.Cancel(ctx, actor, id)
  322. }
  323. func (s *IntelligentTestService) Reevaluate(ctx context.Context, actor, id int64) (*IntelligentTestRecord, error) {
  324. if err := s.authorize(ctx, actor); err != nil {
  325. return nil, err
  326. }
  327. return s.repo.Reevaluate(ctx, actor, id, func(r *IntelligentTestRecord) error {
  328. if r.ConfigSnapshot == nil || r.RawTruncated || strings.TrimSpace(r.Result) == "" {
  329. return intelligentTestBad("原始答复或配置不完整,无法自动重评")
  330. }
  331. if r.Status != "success" && r.Status != "completed" && r.Status != "suspected_degradation" && !(r.Status == "failed" && r.Evaluation["method"] != nil && r.ErrorMessage == "") {
  332. return intelligentTestBad("只能重评已经完成且具有完整答复的记录")
  333. }
  334. evaluator, ok := s.evaluators[r.ConfigSnapshot.Evaluator]
  335. if !ok {
  336. return intelligentTestBad("评估器不可用")
  337. }
  338. e := evaluator.Evaluate(r.Result, *r.ConfigSnapshot)
  339. r.Status, r.Score, r.ResultImage, r.Evaluation = e.Status, e.Score, e.Image, e.Detail
  340. return nil
  341. })
  342. }