feat(ai): add PromptHash to ProviderError + log on schema violation

promptHashShort(system+"\x00"+user)[:12] computed on ErrSchemaViolation
and attached to ProviderError.PromptHash. research.go schema-violation
log now includes prompt_hash for cross-referencing ai_usage rows.
This commit is contained in:
2026-04-25 18:09:28 +02:00
parent ad1da8be66
commit 66aee62646
3 changed files with 27 additions and 11 deletions

View File

@@ -80,7 +80,11 @@ func (h *ResearchHandler) Research(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, apierror.NewResponse(apierror.BadRequest("rate_limited", "KI rate limit erreicht, bitte kurz warten")))
return
case ai.ErrSchemaViolation:
slog.ErrorContext(ctx, "research schema violation", "market_id", id, "raw", pe.RawOutput, "inner", pe.Inner)
slog.ErrorContext(ctx, "research schema violation",
"market_id", id,
"prompt_hash", pe.PromptHash,
"raw", pe.RawOutput,
"inner", pe.Inner)
c.JSON(http.StatusInternalServerError, apierror.NewResponse(apierror.Internal("Modell hat ungültige Ausgabe geliefert")))
return
case ai.ErrInvalidRequest:

View File

@@ -42,11 +42,12 @@ func (c ErrorCode) String() string {
}
type ProviderError struct {
Code ErrorCode
Message string
Retryable bool
Inner error
RawOutput string
Code ErrorCode
Message string
Retryable bool
Inner error
RawOutput string
PromptHash string // sha256(system+"\x00"+user)[:12], set on ErrSchemaViolation
}
func (e *ProviderError) Error() string {

View File

@@ -2,6 +2,8 @@ package ai
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
@@ -258,12 +260,14 @@ func (p *GeminiProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatRespo
text := resp.Text()
if len(req.JSONSchema) > 0 {
if verr := ValidateSchema(req.JSONSchema, []byte(text)); verr != nil {
promptHash := promptHashShort(req.SystemPrompt, req.UserMessage)
return nil, &ProviderError{
Code: ErrSchemaViolation,
Message: fmt.Sprintf("response does not match schema: %v", verr),
Retryable: true,
Inner: verr,
RawOutput: text,
Code: ErrSchemaViolation,
Message: fmt.Sprintf("response does not match schema: %v", verr),
Retryable: true,
Inner: verr,
RawOutput: text,
PromptHash: promptHash,
}
}
}
@@ -330,6 +334,13 @@ func (p *GeminiProvider) record(ctx context.Context, e UsageEvent) {
_ = p.recorder.Record(ctx, e)
}
// promptHashShort returns the first 12 hex chars of sha256(system+"\x00"+user).
// Used to correlate schema-violation errors with the prompt that produced them.
func promptHashShort(system, user string) string {
h := sha256.Sum256([]byte(system + "\x00" + user))
return hex.EncodeToString(h[:])[:12]
}
// schemaFromMap converts a raw JSON-schema map to genai.Schema for structured output.
func schemaFromMap(m map[string]any) *genai.Schema {
s := &genai.Schema{}