AI Gateway Go 0.10.0 源码快照 + 旗舰版需求规划报告
M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package factcheck
|
||||
|
||||
import (
|
||||
"aigateway.local/core/internal/identity"
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AdminHTTPHandler struct {
|
||||
service *Service
|
||||
identity *identity.Service
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *AdminHTTPHandler {
|
||||
h := &AdminHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()}
|
||||
h.mux.HandleFunc("GET /api/v1/admin/fact-check/settings", h.getSettings)
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/fact-check/settings", h.saveSettings)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/fact-check/policies", h.policies)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/fact-check/policies", h.savePolicy)
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/fact-check/policies/{id}", h.savePolicy)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/fact-check/policies/{id}", h.deletePolicy)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/fact-check/events", h.events)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/fact-check/events/{id}", h.event)
|
||||
return h
|
||||
}
|
||||
func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) }
|
||||
func (h *AdminHTTPHandler) account(w http.ResponseWriter, r *http.Request, manage bool) (identity.Account, bool) {
|
||||
a, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, 401, "登录状态无效")
|
||||
return a, false
|
||||
}
|
||||
permission := identity.PermissionKnowledgeRead
|
||||
if manage {
|
||||
permission = identity.PermissionKnowledgeManage
|
||||
}
|
||||
if !identity.HasPermission(a, permission) {
|
||||
apiresponse.Error(w, 403, "缺少事实核验管理权限")
|
||||
return a, false
|
||||
}
|
||||
return a, true
|
||||
}
|
||||
func decodeFact(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(target); err != nil {
|
||||
apiresponse.Error(w, 400, "请求格式无效")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func factError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apiresponse.Error(w, 404, "资源不存在")
|
||||
return
|
||||
}
|
||||
apiresponse.Error(w, 400, err.Error())
|
||||
}
|
||||
func (h *AdminHTTPHandler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.account(w, r, false); !ok {
|
||||
return
|
||||
}
|
||||
x, err := h.service.Settings(r.Context())
|
||||
if err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, x)
|
||||
}
|
||||
func (h *AdminHTTPHandler) saveSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var x Settings
|
||||
if !decodeFact(w, r, &x) {
|
||||
return
|
||||
}
|
||||
x, err := h.service.SaveSettings(r.Context(), x, a.ID)
|
||||
if err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, x)
|
||||
}
|
||||
func (h *AdminHTTPHandler) policies(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.account(w, r, false); !ok {
|
||||
return
|
||||
}
|
||||
x, err := h.service.Policies(r.Context())
|
||||
if err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]any{"items": x})
|
||||
}
|
||||
func (h *AdminHTTPHandler) savePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var x Policy
|
||||
if !decodeFact(w, r, &x) {
|
||||
return
|
||||
}
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
x.ID = id
|
||||
}
|
||||
x, err := h.service.SavePolicy(r.Context(), x, a.ID)
|
||||
if err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, x)
|
||||
}
|
||||
func (h *AdminHTTPHandler) deletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.account(w, r, true); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.DeletePolicy(r.Context(), r.PathValue("id")); err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"deleted": true})
|
||||
}
|
||||
func (h *AdminHTTPHandler) events(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.account(w, r, false); !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
verdict := strings.TrimSpace(r.URL.Query().Get("verdict"))
|
||||
x, err := h.service.Events(r.Context(), verdict, limit)
|
||||
if err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]any{"items": x})
|
||||
}
|
||||
func (h *AdminHTTPHandler) event(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.account(w, r, false); !ok {
|
||||
return
|
||||
}
|
||||
x, err := h.service.Event(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
factError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, x)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package factcheck
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// EvidenceHit is a single retrieved knowledge-base excerpt supplied to the
|
||||
// verifier. The title/content shape mirrors the workbench retriever output.
|
||||
type EvidenceHit struct {
|
||||
DocumentTitle string `json:"document_title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// EvidenceRetriever fetches supporting excerpts from a knowledge base. The
|
||||
// workbench PostgreSQLRetriever satisfies it (through a small adapter), so
|
||||
// fact-check reuses the exact retrieval path application prompts already use.
|
||||
type EvidenceRetriever interface {
|
||||
Search(context.Context, string, string, int) ([]EvidenceHit, error)
|
||||
}
|
||||
|
||||
// Verifier performs the model-backed fact-check call and returns the raw model
|
||||
// output text. The engine is responsible for parsing it into claims and a
|
||||
// verdict. The workbench runtime implements it by routing a non-streaming chat
|
||||
// completion through the same governed gateway, reusing the caller's own
|
||||
// credential headers.
|
||||
type Verifier interface {
|
||||
Verify(context.Context, string, string, string, time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// VerifierFunc adapts a function to the Verifier interface.
|
||||
type VerifierFunc func(context.Context, string, string, string, time.Duration) (string, error)
|
||||
|
||||
func (f VerifierFunc) Verify(ctx context.Context, model, system, user string, timeout time.Duration) (string, error) {
|
||||
return f(ctx, model, system, user, timeout)
|
||||
}
|
||||
|
||||
// Engine executes fact-check policies against assistant answers and records the
|
||||
// outcome in fact_check_events. It is deliberately side-effect safe: Check
|
||||
// never returns an error a caller must propagate — callers treat any failure as
|
||||
// "fact-check skipped" and keep serving the chat.
|
||||
type Engine struct {
|
||||
pool *pgxpool.Pool
|
||||
retriever EvidenceRetriever
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewEngine(pool *pgxpool.Pool, retriever EvidenceRetriever, logger *slog.Logger) *Engine {
|
||||
return &Engine{pool: pool, retriever: retriever, logger: logger}
|
||||
}
|
||||
|
||||
// Check verifies one assistant answer against the configured knowledge bases
|
||||
// and persists a fact_check_events row. The returned Event has a zero ID when
|
||||
// fact-checking is not configured or no policy applies; callers should skip
|
||||
// quietly in that case.
|
||||
func (e *Engine) Check(ctx context.Context, requestID, question, answer string, verifier Verifier) (Event, error) {
|
||||
if e == nil || e.retriever == nil || verifier == nil || strings.TrimSpace(answer) == "" {
|
||||
return Event{}, nil
|
||||
}
|
||||
settings, err := e.checkSettings(ctx)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("read fact-check settings: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(settings.Model) == "" {
|
||||
return Event{}, nil // not configured; skip without noise
|
||||
}
|
||||
policy, err := e.enabledPolicy(ctx)
|
||||
if err != nil {
|
||||
return Event{}, err
|
||||
}
|
||||
if policy.ID == "" {
|
||||
return Event{}, nil
|
||||
}
|
||||
started := time.Now()
|
||||
evidence := e.gatherEvidence(ctx, policy, question)
|
||||
if len(evidence) == 0 {
|
||||
// Nothing to check against; record an uncertain event so the admin can
|
||||
// see retrieval produced no evidence rather than silently passing.
|
||||
return e.record(ctx, Event{
|
||||
PolicyID: &policy.ID, RequestID: requestID, Model: settings.Model,
|
||||
Mode: policy.Mode, Action: policy.Action, Verdict: "uncertain",
|
||||
LatencyMS: int(time.Since(started).Milliseconds()), Question: question, Answer: answer,
|
||||
Claims: json.RawMessage("[]"), Evidence: evidenceJSON(evidence),
|
||||
})
|
||||
}
|
||||
system, user := buildVerifyPrompt(question, answer, evidence, policy.MaxClaims)
|
||||
raw, err := verifier.Verify(ctx, settings.Model, system, user, time.Duration(settings.TimeoutSeconds)*time.Second)
|
||||
if err != nil {
|
||||
return e.record(ctx, Event{
|
||||
PolicyID: &policy.ID, RequestID: requestID, Model: settings.Model,
|
||||
Mode: policy.Mode, Action: policy.Action, Verdict: "error",
|
||||
LatencyMS: int(time.Since(started).Milliseconds()), Question: question, Answer: answer,
|
||||
Claims: json.RawMessage("[]"), Evidence: evidenceJSON(evidence), Error: err.Error(),
|
||||
})
|
||||
}
|
||||
verdict, score, claims := parseVerdict(raw, policy.SupportThreshold)
|
||||
return e.record(ctx, Event{
|
||||
PolicyID: &policy.ID, RequestID: requestID, Model: settings.Model,
|
||||
Mode: policy.Mode, Action: policy.Action, Verdict: verdict, SupportScore: score,
|
||||
LatencyMS: int(time.Since(started).Milliseconds()), Question: question, Answer: answer,
|
||||
Claims: claimsJSON(claims), Evidence: evidenceJSON(evidence),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) checkSettings(ctx context.Context) (Settings, error) {
|
||||
var x Settings
|
||||
err := e.pool.QueryRow(ctx, `SELECT f.provider_id::text,coalesce(p.code,''),f.model,f.timeout_seconds,f.updated_at FROM gateway.fact_check_settings f LEFT JOIN gateway.providers p ON p.id=f.provider_id WHERE singleton`).Scan(&x.ProviderID, &x.ProviderCode, &x.Model, &x.TimeoutSeconds, &x.UpdatedAt)
|
||||
return x, err
|
||||
}
|
||||
|
||||
func (e *Engine) enabledPolicy(ctx context.Context) (Policy, error) {
|
||||
policy, err := scanPolicy(e.pool.QueryRow(ctx, policySelect+` WHERE enabled ORDER BY scope LIMIT 1`))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Policy{}, nil
|
||||
}
|
||||
return policy, err
|
||||
}
|
||||
|
||||
// gatherEvidence retrieves up to a bounded number of excerpts across the
|
||||
// policy's knowledge bases. Retrieval failures never abort the check — a
|
||||
// broken knowledge base just contributes no evidence.
|
||||
func (e *Engine) gatherEvidence(ctx context.Context, policy Policy, question string) []EvidenceHit {
|
||||
maxHits := policy.MaxClaims * 2
|
||||
if maxHits < 4 {
|
||||
maxHits = 4
|
||||
}
|
||||
if maxHits > 20 {
|
||||
maxHits = 20
|
||||
}
|
||||
evidence := []EvidenceHit{}
|
||||
for _, kbID := range policy.KnowledgeBaseIDs {
|
||||
hits, err := e.retriever.Search(ctx, kbID, question, policy.TopK)
|
||||
if err != nil {
|
||||
if e.logger != nil {
|
||||
e.logger.Warn("fact-check evidence retrieval failed", "knowledge_base_id", kbID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, hit := range hits {
|
||||
if len(evidence) >= maxHits {
|
||||
break
|
||||
}
|
||||
evidence = append(evidence, EvidenceHit{DocumentTitle: hit.DocumentTitle, Content: hit.Content})
|
||||
}
|
||||
if len(evidence) >= maxHits {
|
||||
break
|
||||
}
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
|
||||
func (e *Engine) record(ctx context.Context, event Event) (Event, error) {
|
||||
if event.ID == "" {
|
||||
id, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return event, err
|
||||
}
|
||||
event.ID = id
|
||||
}
|
||||
_, err := e.pool.Exec(ctx, `INSERT INTO gateway.fact_check_events(id,policy_id,request_id,model,mode,action,verdict,support_score,latency_ms,question,answer,claims,evidence,error) VALUES($1,nullif($2,'')::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,$14)`,
|
||||
event.ID, ptrString(event.PolicyID), event.RequestID, event.Model, event.Mode, event.Action, event.Verdict, event.SupportScore, event.LatencyMS, event.Question, event.Answer, event.Claims, event.Evidence, event.Error)
|
||||
return event, err
|
||||
}
|
||||
|
||||
func ptrString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
type verifyClaim struct {
|
||||
Claim string `json:"claim"`
|
||||
Verdict string `json:"verdict"`
|
||||
EvidenceIndex []int `json:"evidence_index"`
|
||||
}
|
||||
|
||||
type verifyResult struct {
|
||||
Verdict string `json:"verdict"`
|
||||
SupportScore float64 `json:"support_score"`
|
||||
Claims []verifyClaim `json:"claims"`
|
||||
}
|
||||
|
||||
func buildVerifyPrompt(question, answer string, evidence []EvidenceHit, maxClaims int) (string, string) {
|
||||
if maxClaims < 1 {
|
||||
maxClaims = 8
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString("问题:\n")
|
||||
buffer.WriteString(question)
|
||||
buffer.WriteString("\n\n回答:\n")
|
||||
buffer.WriteString(answer)
|
||||
buffer.WriteString("\n\n参考资料:\n")
|
||||
for i, hit := range evidence {
|
||||
fmt.Fprintf(&buffer, "[资料%d] %s\n%s\n\n", i, hit.DocumentTitle, hit.Content)
|
||||
}
|
||||
system := `你是企业知识库事实核查引擎。你需要逐条判断"回答"中的关键陈述是否能被"参考资料"支持。
|
||||
输出必须是严格 JSON,不要输出任何其他内容,格式:
|
||||
{"verdict":"supported|unsupported|uncertain","support_score":0-100,"claims":[{"claim":"...","verdict":"supported|unsupported|uncertain","evidence_index":[0,1]}]}
|
||||
- verdict:所有关键陈述均被参考资料支持→supported;存在明确被资料否定或资料完全无法支撑的关键陈述→unsupported;资料不足无法判断→uncertain。
|
||||
- support_score:被支持的陈述占比(0-100)。
|
||||
- claims:从回答中提取的关键陈述,最多 ` + fmt.Sprint(maxClaims) + ` 条。evidence_index 列出支撑该陈述的资料编号(从0开始);无支撑填[]。
|
||||
- 只依据参考资料判断,不要使用你自己的世界知识。
|
||||
- 回答为空或不包含可核查陈述时,verdict 输出 uncertain,claims 输出 []。`
|
||||
return system, buffer.String()
|
||||
}
|
||||
|
||||
func parseVerdict(raw string, threshold int) (string, *int, []verifyClaim) {
|
||||
var result verifyResult
|
||||
if extracted := extractJSON(raw); json.Unmarshal([]byte(extracted), &result) != nil {
|
||||
return "error", nil, []verifyClaim{}
|
||||
}
|
||||
claims := result.Claims
|
||||
if claims == nil {
|
||||
claims = []verifyClaim{}
|
||||
}
|
||||
verdict := strings.ToLower(strings.TrimSpace(result.Verdict))
|
||||
var score *int
|
||||
if result.SupportScore > 0 {
|
||||
value := int(result.SupportScore)
|
||||
if value > 100 {
|
||||
value = 100
|
||||
}
|
||||
score = &value
|
||||
}
|
||||
total := len(claims)
|
||||
supported := 0
|
||||
for _, c := range claims {
|
||||
switch strings.ToLower(strings.TrimSpace(c.Verdict)) {
|
||||
case "supported":
|
||||
supported++
|
||||
case "unsupported", "uncertain":
|
||||
default:
|
||||
c.Verdict = "uncertain"
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
percent := supported * 100 / total
|
||||
if score == nil {
|
||||
score = &percent
|
||||
}
|
||||
if verdict != "supported" && verdict != "unsupported" && verdict != "uncertain" {
|
||||
switch {
|
||||
case percent >= threshold:
|
||||
verdict = "supported"
|
||||
case supported > 0:
|
||||
verdict = "uncertain"
|
||||
default:
|
||||
verdict = "unsupported"
|
||||
}
|
||||
}
|
||||
} else if verdict != "supported" && verdict != "unsupported" && verdict != "uncertain" {
|
||||
verdict = "uncertain"
|
||||
}
|
||||
return verdict, score, claims
|
||||
}
|
||||
|
||||
// extractJSON returns the text between the first '{' and the last '}', stripping
|
||||
// markdown code fences models sometimes wrap around their JSON output.
|
||||
func extractJSON(raw string) string {
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start < 0 || end < start {
|
||||
return ""
|
||||
}
|
||||
return raw[start : end+1]
|
||||
}
|
||||
|
||||
func claimsJSON(claims []verifyClaim) json.RawMessage {
|
||||
if claims == nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
encoded, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func evidenceJSON(evidence []EvidenceHit) json.RawMessage {
|
||||
if evidence == nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
encoded, err := json.Marshal(evidence)
|
||||
if err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package factcheck
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVerdictSupported(t *testing.T) {
|
||||
raw := "```json\n{\"verdict\":\"supported\",\"support_score\":88,\"claims\":[{\"claim\":\"a\",\"verdict\":\"supported\",\"evidence_index\":[0]}]}\n```"
|
||||
verdict, score, claims := parseVerdict(raw, 70)
|
||||
if verdict != "supported" {
|
||||
t.Fatalf("expected supported, got %q", verdict)
|
||||
}
|
||||
if score == nil || *score != 88 {
|
||||
t.Fatalf("expected score 88, got %v", score)
|
||||
}
|
||||
if len(claims) != 1 {
|
||||
t.Fatalf("expected 1 claim, got %d", len(claims))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVerdictDerivesFromClaims(t *testing.T) {
|
||||
// Model returns no top-level verdict; the engine must derive it from claims.
|
||||
raw := `{"claims":[{"claim":"x","verdict":"unsupported","evidence_index":[]}]}`
|
||||
verdict, _, _ := parseVerdict(raw, 70)
|
||||
if verdict != "unsupported" {
|
||||
t.Fatalf("expected unsupported, got %q", verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVerdictEmptyClaimsUncertain(t *testing.T) {
|
||||
raw := `{"verdict":"","support_score":0,"claims":[]}`
|
||||
verdict, _, _ := parseVerdict(raw, 70)
|
||||
if verdict != "uncertain" {
|
||||
t.Fatalf("expected uncertain, got %q", verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVerdictGarbageIsError(t *testing.T) {
|
||||
verdict, _, _ := parseVerdict("this is not json at all", 70)
|
||||
if verdict != "error" {
|
||||
t.Fatalf("expected error, got %q", verdict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVerdictClampsScore(t *testing.T) {
|
||||
raw := `{"verdict":"supported","support_score":150,"claims":[{"claim":"a","verdict":"supported","evidence_index":[0]}]}`
|
||||
_, score, _ := parseVerdict(raw, 70)
|
||||
if score == nil || *score > 100 {
|
||||
t.Fatalf("score should be clamped to 100, got %v", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSONStripsFences(t *testing.T) {
|
||||
raw := "以下是结果:\n```json\n{\"a\":1}\n```\n完"
|
||||
if got := extractJSON(raw); got != `{"a":1}` {
|
||||
t.Fatalf("expected {\"a\":1}, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package factcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("fact-check resource not found")
|
||||
|
||||
type Service struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} }
|
||||
|
||||
type Settings struct {
|
||||
ProviderID *string `json:"provider_id"`
|
||||
ProviderCode string `json:"provider_code"`
|
||||
Model string `json:"model"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Service) Settings(ctx context.Context) (Settings, error) {
|
||||
var x Settings
|
||||
err := s.pool.QueryRow(ctx, `SELECT f.provider_id::text,coalesce(p.code,''),f.model,f.timeout_seconds,f.updated_at FROM gateway.fact_check_settings f LEFT JOIN gateway.providers p ON p.id=f.provider_id WHERE singleton`).Scan(&x.ProviderID, &x.ProviderCode, &x.Model, &x.TimeoutSeconds, &x.UpdatedAt)
|
||||
return x, err
|
||||
}
|
||||
func (s *Service) SaveSettings(ctx context.Context, x Settings, actor string) (Settings, error) {
|
||||
x.Model = strings.TrimSpace(x.Model)
|
||||
if x.TimeoutSeconds < 3 || x.TimeoutSeconds > 60 {
|
||||
return Settings{}, errors.New("超时必须在 3-60 秒之间")
|
||||
}
|
||||
if x.ProviderID != nil && strings.TrimSpace(*x.ProviderID) == "" {
|
||||
x.ProviderID = nil
|
||||
}
|
||||
if x.ProviderID != nil && x.Model == "" {
|
||||
return Settings{}, errors.New("配置供应商时模型不能为空")
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `UPDATE gateway.fact_check_settings SET provider_id=$1,model=$2,timeout_seconds=$3,updated_by=$4,updated_at=clock_timestamp() WHERE singleton`, x.ProviderID, x.Model, x.TimeoutSeconds, actor)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return s.Settings(ctx)
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
||||
SupportThreshold int `json:"support_threshold"`
|
||||
EvidenceThreshold float64 `json:"evidence_threshold"`
|
||||
TopK int `json:"top_k"`
|
||||
MaxClaims int `json:"max_claims"`
|
||||
Revision int64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func validatePolicy(x *Policy) error {
|
||||
x.Scope = strings.ToLower(strings.TrimSpace(x.Scope))
|
||||
if x.Scope == "" {
|
||||
x.Scope = "global"
|
||||
}
|
||||
if x.Mode == "" {
|
||||
x.Mode = "async"
|
||||
}
|
||||
if x.Action == "" {
|
||||
x.Action = "observe"
|
||||
}
|
||||
if x.Mode != "async" && x.Mode != "sync" {
|
||||
return errors.New("模式无效")
|
||||
}
|
||||
if x.Action != "observe" && x.Action != "annotate" && x.Action != "block" {
|
||||
return errors.New("处置动作无效")
|
||||
}
|
||||
if x.Mode == "async" && x.Action != "observe" {
|
||||
return errors.New("异步模式只能观察")
|
||||
}
|
||||
if x.Enabled && len(x.KnowledgeBaseIDs) == 0 {
|
||||
return errors.New("启用策略前至少选择一个知识库")
|
||||
}
|
||||
if x.TopK == 0 {
|
||||
x.TopK = 4
|
||||
}
|
||||
if x.MaxClaims == 0 {
|
||||
x.MaxClaims = 8
|
||||
}
|
||||
if x.SupportThreshold == 0 {
|
||||
x.SupportThreshold = 70
|
||||
}
|
||||
if x.EvidenceThreshold == 0 {
|
||||
x.EvidenceThreshold = .35
|
||||
}
|
||||
if x.TopK < 1 || x.TopK > 10 || x.MaxClaims < 1 || x.MaxClaims > 20 || x.SupportThreshold < 0 || x.SupportThreshold > 100 || x.EvidenceThreshold < 0 || x.EvidenceThreshold > 1 {
|
||||
return errors.New("事实核验阈值无效")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const policySelect = `SELECT id::text,scope,enabled,mode,action,knowledge_base_ids::text[],support_threshold,evidence_threshold,top_k,max_claims,revision,created_at,updated_at FROM gateway.fact_check_policies`
|
||||
|
||||
func scanPolicy(row pgx.Row) (Policy, error) {
|
||||
var x Policy
|
||||
err := row.Scan(&x.ID, &x.Scope, &x.Enabled, &x.Mode, &x.Action, &x.KnowledgeBaseIDs, &x.SupportThreshold, &x.EvidenceThreshold, &x.TopK, &x.MaxClaims, &x.Revision, &x.CreatedAt, &x.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = ErrNotFound
|
||||
}
|
||||
return x, err
|
||||
}
|
||||
func (s *Service) Policies(ctx context.Context) ([]Policy, error) {
|
||||
rows, err := s.pool.Query(ctx, policySelect+` ORDER BY scope`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Policy{}
|
||||
for rows.Next() {
|
||||
x, err := scanPolicy(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, x)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s *Service) SavePolicy(ctx context.Context, x Policy, actor string) (Policy, error) {
|
||||
if err := validatePolicy(&x); err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
if x.KnowledgeBaseIDs == nil {
|
||||
x.KnowledgeBaseIDs = []string{}
|
||||
}
|
||||
if x.ID == "" {
|
||||
x.ID, _ = platformid.NewUUID()
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO gateway.fact_check_policies(id,scope,enabled,mode,action,knowledge_base_ids,support_threshold,evidence_threshold,top_k,max_claims,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, x.ID, x.Scope, x.Enabled, x.Mode, x.Action, x.KnowledgeBaseIDs, x.SupportThreshold, x.EvidenceThreshold, x.TopK, x.MaxClaims, actor)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
} else {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway.fact_check_policies SET scope=$2,enabled=$3,mode=$4,action=$5,knowledge_base_ids=$6,support_threshold=$7,evidence_threshold=$8,top_k=$9,max_claims=$10,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, x.ID, x.Scope, x.Enabled, x.Mode, x.Action, x.KnowledgeBaseIDs, x.SupportThreshold, x.EvidenceThreshold, x.TopK, x.MaxClaims)
|
||||
if err != nil {
|
||||
return Policy{}, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return Policy{}, ErrNotFound
|
||||
}
|
||||
}
|
||||
return scanPolicy(s.pool.QueryRow(ctx, policySelect+` WHERE id=$1`, x.ID))
|
||||
}
|
||||
func (s *Service) DeletePolicy(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.fact_check_policies WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
PolicyID *string `json:"policy_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Model string `json:"model"`
|
||||
Mode string `json:"mode"`
|
||||
Action string `json:"action"`
|
||||
Verdict string `json:"verdict"`
|
||||
SupportScore *int `json:"support_score"`
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Question string `json:"question,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Claims json.RawMessage `json:"claims,omitempty"`
|
||||
Evidence json.RawMessage `json:"evidence,omitempty"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const eventSelect = `SELECT id::text,policy_id::text,request_id,model,mode,action,verdict,support_score,latency_ms,question,answer,claims,evidence,error,created_at FROM gateway.fact_check_events`
|
||||
|
||||
func scanEvent(row pgx.Row) (Event, error) {
|
||||
var x Event
|
||||
err := row.Scan(&x.ID, &x.PolicyID, &x.RequestID, &x.Model, &x.Mode, &x.Action, &x.Verdict, &x.SupportScore, &x.LatencyMS, &x.Question, &x.Answer, &x.Claims, &x.Evidence, &x.Error, &x.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = ErrNotFound
|
||||
}
|
||||
return x, err
|
||||
}
|
||||
func (s *Service) Events(ctx context.Context, verdict string, limit int) ([]Event, error) {
|
||||
if limit < 1 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
query := eventSelect
|
||||
args := []any{}
|
||||
if verdict != "" {
|
||||
args = append(args, verdict)
|
||||
query += ` WHERE verdict=$1`
|
||||
}
|
||||
args = append(args, limit)
|
||||
query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args))
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Event{}
|
||||
for rows.Next() {
|
||||
x, err := scanEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x.Question = ""
|
||||
x.Answer = ""
|
||||
x.Claims = nil
|
||||
x.Evidence = nil
|
||||
items = append(items, x)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
func (s *Service) Event(ctx context.Context, id string) (Event, error) {
|
||||
return scanEvent(s.pool.QueryRow(ctx, eventSelect+` WHERE id=$1`, id))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package factcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"aigateway.local/core/internal/platform/config"
|
||||
"aigateway.local/core/internal/platform/database"
|
||||
)
|
||||
|
||||
func TestFactCheckPostgreSQLLifecycle(t *testing.T) {
|
||||
databaseURL := os.Getenv("FACTCHECK_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("FACTCHECK_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
const actorID = "33333333-3333-4333-8333-333333333333"
|
||||
const knowledgeBaseID = "44444444-4444-4444-8444-444444444444"
|
||||
const eventID = "55555555-5555-4555-8555-555555555555"
|
||||
_, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role)
|
||||
VALUES($1,'factcheck-test-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.fact_check_events WHERE id=$1`, eventID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.fact_check_policies WHERE scope='global'`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.knowledge_bases WHERE id=$1`, knowledgeBaseID)
|
||||
_, _ = pool.Exec(ctx, `UPDATE gateway.fact_check_settings SET provider_id=NULL,model='',updated_by=NULL`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM gateway.admin_accounts WHERE id=$1`, actorID)
|
||||
}
|
||||
cleanup()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role)
|
||||
VALUES($1,'factcheck-test-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
_, err = pool.Exec(ctx, `INSERT INTO gateway.knowledge_bases(id,name,retrieval_mode,chunk_size,chunk_overlap,created_by)
|
||||
VALUES($1,'factcheck-test-kb','postgres_fts',800,100,$2)`, knowledgeBaseID, actorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := NewService(pool)
|
||||
settings, err := service.SaveSettings(ctx, Settings{TimeoutSeconds: 17}, actorID)
|
||||
if err != nil || settings.TimeoutSeconds != 17 || settings.ProviderID != nil {
|
||||
t.Fatalf("settings=%#v err=%v", settings, err)
|
||||
}
|
||||
policy, err := service.SavePolicy(ctx, Policy{
|
||||
Scope: "global",
|
||||
Enabled: true,
|
||||
Mode: "sync",
|
||||
Action: "annotate",
|
||||
KnowledgeBaseIDs: []string{knowledgeBaseID},
|
||||
}, actorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if policy.Revision != 1 || policy.TopK != 4 || policy.SupportThreshold != 70 {
|
||||
t.Fatalf("unexpected policy defaults: %#v", policy)
|
||||
}
|
||||
policy.Action = "block"
|
||||
updated, err := service.SavePolicy(ctx, policy, actorID)
|
||||
if err != nil || updated.Revision != 2 || updated.Action != "block" {
|
||||
t.Fatalf("updated=%#v err=%v", updated, err)
|
||||
}
|
||||
|
||||
claims := json.RawMessage(`[{"text":"Go gateway"}]`)
|
||||
evidence := json.RawMessage(`[{"source":"kb"}]`)
|
||||
_, err = pool.Exec(ctx, `INSERT INTO gateway.fact_check_events
|
||||
(id,policy_id,request_id,model,mode,action,verdict,support_score,latency_ms,question,answer,claims,evidence)
|
||||
VALUES($1,$2,'factcheck-request','test-model','sync','block','supported',92,12,'question','answer',$3,$4)`,
|
||||
eventID, policy.ID, claims, evidence)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := service.Events(ctx, "supported", 10)
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatalf("events=%#v err=%v", events, err)
|
||||
}
|
||||
if events[0].Question != "" || events[0].Answer != "" || events[0].Claims != nil {
|
||||
t.Fatalf("event list leaked payload: %#v", events[0])
|
||||
}
|
||||
detail, err := service.Event(ctx, eventID)
|
||||
if err != nil || detail.Question != "question" || len(detail.Evidence) == 0 {
|
||||
t.Fatalf("detail=%#v err=%v", detail, err)
|
||||
}
|
||||
if err = service.DeletePolicy(ctx, policy.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user