Files
knowledge-governance/internal/httpapi/qa_quality.go
T
姚章浩 87f97a2f9b
build / build (push) Successful in 1m7s
feat(qaops): 完成详情下拉与每周自动保存草稿
详情使用共享主表字典,稀疏修改所选字段并保留原始问答及旧复核审计。
月周统计提供文本和下拉双入口;每租户每周一份持久草稿,按技能六节双语模板真实预填。
加入幂等自动保存、冲突保留、未知响应重试、切周及离开保护和schema58非破坏降级保护。

验证:完整make verify、隔离PostgreSQL全mirror竞态回归、真实恢复库浏览器验收通过。
浏览器实测字段精确读回、月周双输入、模板预填、MD/CSV、自动保存、冲突、失败重试与API重启恢复。
未连接或改写DobotAI生产源库,未运行付费模型;提交时尚未部署。
2026-10-20 05:44:02 +08:00

259 lines
8.4 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 {
return decodeGovernanceJSON(w, r, target, 64<<10, required...)
}
func decodeGovernanceJSON(w http.ResponseWriter, r *http.Request, target any, maxBytes int64, required ...string) error {
if r.URL.RawQuery != "" {
return qaopsquality.ErrInvalid
}
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBytes))
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})
}