Files
knowledge-governance/internal/httpapi/qa_quality.go
T
姚章浩 533db535f1
build / build (push) Successful in 1m12s
feat(qaops): 接入真实模型补缺任务与总表看板
按已确认需求提供独立安全模型配置、真实探测、正式字典字段评估与原问题译中,持久批次支持只补空白、暂停恢复、有限重试、幂等和逐字段依据。覆盖固定规则、本地语言、组织关联与主表有效看板共用治理侧读模型,保留源生产和冻结历史。

验证:完整 make verify 退出0,702项Go与329项前端通过;默认118项外部/隔离集成明确跳过,另跑完整镜像及组织隔离PG竞态。12并发、输入漂移、过期租约、暂停/恢复及有限重试通过。用户授权的百炼 deepseek-v4.1-flash 完成真实后台与浏览器评估/翻译;552行、16列、11块看板及完整PNG、独立配置页测试/保存、重开恢复和零JS异常通过。仅非业务样本外发,自动处理关闭,原payload不变。

上线前治理库已做导出快照原生备份并真实恢复,60表行数与17个QA表内容哈希一致;不修改DobotAI生产或隧道。本提交时尚未部署,线上验收须独立完成。
2026-10-19 14:29:49 +08:00

256 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"knowledge-governance/internal/dobotaiops"
"knowledge-governance/internal/qaopsquality"
"knowledge-governance/internal/strictjson"
"net/http"
"strconv"
"time"
)
type QAQualityStore interface {
ObserveKeys(context.Context, []dobotaiops.QualityKey) (map[string]qaopsquality.View, error)
Observe(context.Context, []dobotaiops.Record) (map[string]qaopsquality.View, error)
Save(context.Context, dobotaiops.Record, qaopsquality.SaveRequest, string) (qaopsquality.Receipt, error)
Columns(context.Context, []dobotaiops.QualityKey) ([]string, error)
History(context.Context, string, int64, int64) (qaopsquality.History, error)
Snapshot(context.Context, string, int64) (qaopsquality.Snapshot, error)
}
type storedQAQualityReader interface {
ReadStoredQuality(context.Context, []dobotaiops.QualityKey) (map[string]qaopsquality.View, error)
}
func qaQualityViews(ctx context.Context, c Config, records []dobotaiops.Record) (map[string]qaopsquality.View, error) {
if reader, ok := c.QAOps.(storedQAQualityReader); ok {
keys := make([]dobotaiops.QualityKey, 0, len(records))
for _, r := range records {
keys = append(keys, dobotaiops.QualityKey{ID: r.ID, InputHash: r.QualityInputHash})
}
return reader.ReadStoredQuality(ctx, keys)
}
return c.QAQuality.Observe(ctx, records)
}
type qaQualityReader interface {
ReadQualityScope(context.Context, dobotaiops.RecordQuery) (dobotaiops.QualityScope, error)
}
func writeQualityError(w http.ResponseWriter, err error) {
status, code, message := 503, "quality_unavailable", "质量留档/复核存储不可用,未将失败伪装为空值或零统计"
switch {
case errors.Is(err, qaopsquality.ErrInvalid) || errors.Is(err, dobotaiops.ErrInvalidQuery):
status, code, message = 400, "invalid_quality_request", "质量操作参数无效:请核对结论、理由、版本和筛选范围(最多5000条/32MiB)"
case errors.Is(err, qaopsquality.ErrConflict):
status, code, message = 409, "quality_conflict", "原始问答或人工复核版本已变化,请重新读取问答后核对;未覆盖现有结论"
case errors.Is(err, qaopsquality.ErrSnapshotMissing):
status, code, message = 409, "quality_input_metadata_only", "此版本仅留存指纹与复检元数据,未采集原文;未编造空问题或空答案"
case errors.Is(err, qaopsquality.ErrNotFound):
status, code, message = 404, "quality_input_not_found", "未找到当前授权范围的输入版本"
}
writeJSON(w, status, map[string]string{"error": message, "code": code})
}
func enrichQAQualityRecord(ctx context.Context, c Config, item *dobotaiops.Record) error {
if c.QAQuality == nil {
return nil
}
views, err := qaQualityViews(ctx, c, []dobotaiops.Record{*item})
if err != nil {
return err
}
v, ok := views[item.ID]
if !ok {
return qaopsquality.ErrUnavailable
}
item.Quality, err = json.Marshal(v)
return err
}
func enrichQAQualityPage(ctx context.Context, c Config, page *dobotaiops.RecordPage) error {
if c.QAQuality == nil {
return nil
}
views, err := qaQualityViews(ctx, c, page.Items)
if err != nil {
return err
}
for i := range page.Items {
v, ok := views[page.Items[i].ID]
if !ok {
return qaopsquality.ErrUnavailable
}
page.Items[i].Quality, err = json.Marshal(v)
if err != nil {
return err
}
}
columns, err := c.QAQuality.Columns(ctx, page.ScopeQuality)
if err != nil {
return err
}
dobotaiops.AddRecordColumns(page, columns...)
return nil
}
func walkQualityJSON(d *json.Decoder) error { return strictjson.Walk(d) }
func decodeQualityJSON(w http.ResponseWriter, r *http.Request, target any, required ...string) error {
if r.URL.RawQuery != "" {
return qaopsquality.ErrInvalid
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
if err != nil || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return qaopsquality.ErrInvalid
}
var top map[string]json.RawMessage
if json.Unmarshal(raw, &top) != nil || top == nil {
return qaopsquality.ErrInvalid
}
for _, key := range required {
if _, ok := top[key]; !ok {
return qaopsquality.ErrInvalid
}
}
check := json.NewDecoder(bytes.NewReader(raw))
if walkQualityJSON(check) != nil {
return qaopsquality.ErrInvalid
}
var extra any
if check.Decode(&extra) != io.EOF {
return qaopsquality.ErrInvalid
}
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if d.Decode(target) != nil || d.Decode(&extra) != io.EOF {
return qaopsquality.ErrInvalid
}
return nil
}
func qaQualityReview(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-quality") {
return
}
var request qaopsquality.SaveRequest
if decodeQualityJSON(w, r, &request, "input_hash", "input_epoch", "base_revision", "nonce", "fields", "reason") != nil || qaopsquality.Validate(request) != nil {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
if c.QAQuality == nil || c.QAOps == nil || c.QAOpsTenantKey == "" {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
record, err := c.QAOps.GetRecord(r.Context(), c.QAOpsTenantKey, r.PathValue("id"))
if err != nil {
qaOpsError(w, err)
return
}
identity, _ := sessionFrom(r.Context())
receipt, err := c.QAQuality.Save(r.Context(), record, request, identity.Username)
if err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": receipt})
}
func qaQualityQuery(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) || !authorizeQAIntent(w, r, "qa-quality") {
return
}
var request struct {
Start string `json:"start"`
End string `json:"end"`
Keyword string `json:"keyword"`
}
if decodeQualityJSON(w, r, &request) != nil {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
window, err := dobotaiops.NewWindow(c.QAOpsTenantKey, request.Start, request.End, 90*24*time.Hour, c.Now())
if err != nil {
writeQualityError(w, err)
return
}
reader, ok := c.QAOps.(qaQualityReader)
if !ok || c.QAQuality == nil {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
scope, err := reader.ReadQualityScope(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: request.Keyword})
if err != nil {
qaOpsError(w, err)
return
}
var views map[string]qaopsquality.View
if stored, ok := c.QAOps.(storedQAQualityReader); ok {
views, err = stored.ReadStoredQuality(r.Context(), scope.Keys)
} else {
views, err = c.QAQuality.ObserveKeys(r.Context(), scope.Keys)
}
if err != nil || len(views) != len(scope.Keys) {
if err == nil {
err = qaopsquality.ErrUnavailable
}
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": map[string]any{"summary": qaopsquality.Summarize(views), "start": window.Start, "end": window.End, "keyword": request.Keyword, "as_of": scope.AsOf, "ai_state": "not_configured", "scope": "whole_filter"}})
}
func qaQualityHistory(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) {
return
}
if c.QAQuality == nil {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
values := r.URL.Query()
for key, v := range values {
if (key != "review_before" && key != "check_before") || len(v) != 1 {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
}
cursors := map[string]int64{}
for _, key := range []string{"review_before", "check_before"} {
raw := values.Get(key)
if raw == "" {
continue
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
cursors[key] = n
}
value, err := c.QAQuality.History(r.Context(), r.PathValue("id"), cursors["review_before"], cursors["check_before"])
if err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": value})
}
func qaQualitySnapshot(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) {
return
}
epoch, err := strconv.ParseInt(r.PathValue("epoch"), 10, 64)
if err != nil || epoch < 1 || r.URL.RawQuery != "" {
writeQualityError(w, qaopsquality.ErrInvalid)
return
}
if c.QAQuality == nil {
writeQualityError(w, qaopsquality.ErrUnavailable)
return
}
value, err := c.QAQuality.Snapshot(r.Context(), r.PathValue("id"), epoch)
if err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, 200, map[string]any{"data": value})
}