sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package audit
import (
"context"
"errors"
"testing"
"ai-operator/internal/config"
)
type failingRepo struct{}
func (failingRepo) Health(context.Context) error {
return errors.New("secret postgres://u:pass@localhost/db")
}
func (failingRepo) UpsertCall(context.Context, CallRecord) error { return errors.New("write failed") }
func (failingRepo) EndCall(context.Context, string, string) error { return errors.New("write failed") }
func (failingRepo) AddEvent(context.Context, EventRecord) error { return errors.New("write failed") }
func (failingRepo) AddTranscript(context.Context, TranscriptRecord) error {
return errors.New("write failed")
}
func (failingRepo) AddToolAudit(context.Context, ToolAuditRecord) error {
return errors.New("write failed")
}
func (failingRepo) AddKBAudit(context.Context, KBAuditRecord) error {
return errors.New("write failed")
}
func (failingRepo) AddHandoffAudit(context.Context, HandoffAuditRecord) error {
return errors.New("write failed")
}
func (failingRepo) AddProviderAudit(context.Context, ProviderAuditRecord) error {
return errors.New("write failed")
}
func (failingRepo) AddMediaAudit(context.Context, MediaAuditRecord) error {
return errors.New("write failed")
}
func (failingRepo) ExportCall(context.Context, string) (CallAuditExport, error) {
return CallAuditExport{}, errors.New("write failed")
}
func (failingRepo) Prune(context.Context, RetentionPruneRequest) (RetentionPruneResult, error) {
return RetentionPruneResult{}, errors.New("write failed")
}
func TestAuditServiceFailOpen(t *testing.T) {
svc := NewService(failingRepo{}, config.AuditConfig{Enabled: true, Sink: "postgres", FailClosed: false, RedactionEnabled: true, MaxEventMetadataChars: 100, MaxTranscriptChars: 100}, nil)
if err := svc.AddEvent(context.Background(), EventRecord{CallID: "c", EventType: "call.started"}); err != nil {
t.Fatalf("fail-open returned error: %v", err)
}
}
func TestAuditServiceFailClosed(t *testing.T) {
svc := NewService(failingRepo{}, config.AuditConfig{Enabled: true, Sink: "postgres", FailClosed: true, RedactionEnabled: true, MaxEventMetadataChars: 100, MaxTranscriptChars: 100}, nil)
if err := svc.AddEvent(context.Background(), EventRecord{CallID: "c", EventType: "call.started"}); err == nil {
t.Fatal("fail-closed did not return error")
}
}
func TestNoopRepository(t *testing.T) {
svc := NewService(NoopRepository{}, config.AuditConfig{Enabled: false}, nil)
if err := svc.AddTranscript(context.Background(), TranscriptRecord{CallID: "c", Speaker: "user", EventType: "transcript.user.final", Text: "+77771234567"}); err != nil {
t.Fatalf("noop returned error: %v", err)
}
}
+1
View File
@@ -0,0 +1 @@
package audit
+22
View File
@@ -0,0 +1,22 @@
package audit
import "context"
type NoopRepository struct{}
func (NoopRepository) Health(context.Context) error { return nil }
func (NoopRepository) UpsertCall(context.Context, CallRecord) error { return nil }
func (NoopRepository) EndCall(context.Context, string, string) error { return nil }
func (NoopRepository) AddEvent(context.Context, EventRecord) error { return nil }
func (NoopRepository) AddTranscript(context.Context, TranscriptRecord) error { return nil }
func (NoopRepository) AddToolAudit(context.Context, ToolAuditRecord) error { return nil }
func (NoopRepository) AddKBAudit(context.Context, KBAuditRecord) error { return nil }
func (NoopRepository) AddHandoffAudit(context.Context, HandoffAuditRecord) error { return nil }
func (NoopRepository) AddProviderAudit(context.Context, ProviderAuditRecord) error { return nil }
func (NoopRepository) AddMediaAudit(context.Context, MediaAuditRecord) error { return nil }
func (NoopRepository) ExportCall(context.Context, string) (CallAuditExport, error) {
return CallAuditExport{}, nil
}
func (NoopRepository) Prune(context.Context, RetentionPruneRequest) (RetentionPruneResult, error) {
return RetentionPruneResult{DryRun: true, Status: "noop"}, nil
}
+255
View File
@@ -0,0 +1,255 @@
package audit
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type PostgresRepository struct{ pool *pgxpool.Pool }
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{pool: pool}
}
func (r *PostgresRepository) Health(ctx context.Context) error {
var ok bool
return r.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='ai_calls')`).Scan(&ok)
}
func (r *PostgresRepository) UpsertCall(ctx context.Context, c CallRecord) error {
if c.StartedAt.IsZero() {
c.StartedAt = time.Now().UTC()
}
meta := jsonb(c.Metadata)
_, err := r.pool.Exec(ctx, `INSERT INTO ai_calls(call_id,asterisk_channel_id,route,caller_number_masked,language,region_code,state,started_at,metadata)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT(call_id) DO UPDATE SET asterisk_channel_id=EXCLUDED.asterisk_channel_id, route=EXCLUDED.route, caller_number_masked=EXCLUDED.caller_number_masked, language=EXCLUDED.language, region_code=EXCLUDED.region_code, state=EXCLUDED.state, updated_at=now(), metadata=EXCLUDED.metadata`, c.CallID, c.AsteriskChannelID, nonEmpty(c.Route, "unknown"), c.CallerNumberMasked, c.Language, c.RegionCode, c.State, c.StartedAt, meta)
return err
}
func (r *PostgresRepository) EndCall(ctx context.Context, callID string, reason string) error {
_, err := r.pool.Exec(ctx, `UPDATE ai_calls SET ended_at=now(), duration_ms=GREATEST(0, EXTRACT(EPOCH FROM (now()-started_at))*1000)::bigint, end_reason=$2, state='ENDED', updated_at=now() WHERE call_id=$1`, callID, reason)
return err
}
func (r *PostgresRepository) AddEvent(ctx context.Context, e EventRecord) error {
if e.CreatedAt.IsZero() {
e.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_call_events(call_id,event_type,event_source,state_before,state_after,severity,message,reason_code,created_at,metadata) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, e.CallID, e.EventType, e.EventSource, e.StateBefore, e.StateAfter, nonEmpty(e.Severity, "info"), e.Message, e.ReasonCode, e.CreatedAt, jsonb(e.Metadata))
return err
}
func (r *PostgresRepository) AddTranscript(ctx context.Context, t TranscriptRecord) error {
if t.CreatedAt.IsZero() {
t.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_transcript_events(call_id,speaker,event_type,language,text_redacted,text_hash,char_count,redaction_applied,created_at,metadata) VALUES($1,$2,$3,$4,$5,encode(digest($5,'sha256'),'hex'),$6,true,$7,$8)`, t.CallID, t.Speaker, t.EventType, t.Language, t.Text, len([]rune(t.Text)), t.CreatedAt, jsonb(t.Metadata))
return err
}
func (r *PostgresRepository) AddToolAudit(ctx context.Context, t ToolAuditRecord) error {
if t.CreatedAt.IsZero() {
t.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_tool_audit(call_id,tool_call_id,tool_name,state,language,region_code,allowed,denied,reason_code,args_redacted,result_redacted,duration_ms,created_at,metadata) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, t.CallID, t.ToolCallID, t.ToolName, t.State, t.Language, t.RegionCode, t.Allowed, t.Denied, t.ReasonCode, jsonb(t.Args), jsonb(t.Result), t.DurationMS, t.CreatedAt, jsonb(t.Metadata))
return err
}
func (r *PostgresRepository) AddKBAudit(ctx context.Context, k KBAuditRecord) error {
if k.CreatedAt.IsZero() {
k.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_kb_audit(call_id,query_redacted,language,region_code,result_count,top_score,cross_language_fallback_used,citations_count,no_answer,duration_ms,created_at,metadata) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, k.CallID, k.Query, k.Language, k.RegionCode, k.ResultCount, k.TopScore, k.CrossLanguageFallbackUsed, k.CitationsCount, k.NoAnswer, k.DurationMS, k.CreatedAt, jsonb(k.Metadata))
return err
}
func (r *PostgresRepository) AddHandoffAudit(ctx context.Context, h HandoffAuditRecord) error {
if h.CreatedAt.IsZero() {
h.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_handoff_audit(call_id,handoff_id,mode,status,reason_code,transfer_attempted,transfer_succeeded,target_redacted,summary_redacted,created_at,metadata) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, h.CallID, h.HandoffID, h.Mode, h.Status, h.ReasonCode, h.TransferAttempted, h.TransferSucceeded, h.Target, h.Summary, h.CreatedAt, jsonb(h.Metadata))
return err
}
func (r *PostgresRepository) AddProviderAudit(ctx context.Context, p ProviderAuditRecord) error {
if p.CreatedAt.IsZero() {
p.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_provider_audit(call_id,provider,event_type,severity,error_redacted,input_audio_bytes,output_audio_bytes,events_received,events_sent,created_at,metadata) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, p.CallID, p.Provider, p.EventType, nonEmpty(p.Severity, "info"), p.Error, p.InputAudioBytes, p.OutputAudioBytes, p.EventsReceived, p.EventsSent, p.CreatedAt, jsonb(p.Metadata))
return err
}
func (r *PostgresRepository) AddMediaAudit(ctx context.Context, m MediaAuditRecord) error {
if m.CreatedAt.IsZero() {
m.CreatedAt = time.Now().UTC()
}
_, err := r.pool.Exec(ctx, `INSERT INTO ai_media_audit(call_id,event_type,severity,codec,inbound_frames,inbound_bytes,outbound_frames,outbound_bytes,xoff_count,xon_count,error_redacted,created_at,metadata) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, m.CallID, m.EventType, nonEmpty(m.Severity, "info"), m.Codec, m.InboundFrames, m.InboundBytes, m.OutboundFrames, m.OutboundBytes, m.XOffCount, m.XOnCount, m.Error, m.CreatedAt, jsonb(m.Metadata))
return err
}
func (r *PostgresRepository) ExportCall(ctx context.Context, callID string) (CallAuditExport, error) {
ex := CallAuditExport{Events: []EventRecord{}, Transcripts: []TranscriptRecord{}, Tools: []ToolAuditRecord{}, KB: []KBAuditRecord{}, Handoffs: []HandoffAuditRecord{}, Providers: []ProviderAuditRecord{}, Media: []MediaAuditRecord{}}
var c CallRecord
var meta []byte
err := r.pool.QueryRow(ctx, `SELECT call_id,coalesce(asterisk_channel_id,''),route,coalesce(caller_number_masked,''),coalesce(language,''),coalesce(region_code,''),coalesce(state,''),started_at,ended_at,coalesce(duration_ms,0),coalesce(end_reason,''),coalesce(handoff_status,''),metadata FROM ai_calls WHERE call_id=$1`, callID).Scan(&c.CallID, &c.AsteriskChannelID, &c.Route, &c.CallerNumberMasked, &c.Language, &c.RegionCode, &c.State, &c.StartedAt, &c.EndedAt, &c.DurationMS, &c.EndReason, &c.HandoffStatus, &meta)
if err != nil && err != pgx.ErrNoRows {
return ex, err
}
if err == nil {
c.Metadata = unjson(meta)
ex.Call = &c
}
rows, err := r.pool.Query(ctx, `SELECT event_type,event_source,coalesce(state_before,''),coalesce(state_after,''),severity,coalesce(message,''),coalesce(reason_code,''),created_at,metadata FROM ai_call_events WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var e EventRecord
var mb []byte
e.CallID = callID
if err := rows.Scan(&e.EventType, &e.EventSource, &e.StateBefore, &e.StateAfter, &e.Severity, &e.Message, &e.ReasonCode, &e.CreatedAt, &mb); err != nil {
return ex, err
}
e.Metadata = unjson(mb)
ex.Events = append(ex.Events, e)
}
rows, err = r.pool.Query(ctx, `SELECT speaker,event_type,coalesce(language,''),coalesce(text_redacted,''),created_at,metadata FROM ai_transcript_events WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var t TranscriptRecord
var mb []byte
t.CallID = callID
if err := rows.Scan(&t.Speaker, &t.EventType, &t.Language, &t.Text, &t.CreatedAt, &mb); err != nil {
return ex, err
}
t.Metadata = unjson(mb)
ex.Transcripts = append(ex.Transcripts, t)
}
rows, err = r.pool.Query(ctx, `SELECT coalesce(tool_call_id,''),tool_name,coalesce(state,''),coalesce(language,''),coalesce(region_code,''),allowed,denied,coalesce(reason_code,''),args_redacted,result_redacted,coalesce(duration_ms,0),created_at,metadata FROM ai_tool_audit WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var t ToolAuditRecord
var ab, rb, mb []byte
t.CallID = callID
if err := rows.Scan(&t.ToolCallID, &t.ToolName, &t.State, &t.Language, &t.RegionCode, &t.Allowed, &t.Denied, &t.ReasonCode, &ab, &rb, &t.DurationMS, &t.CreatedAt, &mb); err != nil {
return ex, err
}
t.Args = unjson(ab)
t.Result = unjson(rb)
t.Metadata = unjson(mb)
ex.Tools = append(ex.Tools, t)
}
rows, err = r.pool.Query(ctx, `SELECT query_redacted,language,region_code,result_count,coalesce(top_score,0),cross_language_fallback_used,citations_count,no_answer,coalesce(duration_ms,0),created_at,metadata FROM ai_kb_audit WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var k KBAuditRecord
var mb []byte
k.CallID = callID
if err := rows.Scan(&k.Query, &k.Language, &k.RegionCode, &k.ResultCount, &k.TopScore, &k.CrossLanguageFallbackUsed, &k.CitationsCount, &k.NoAnswer, &k.DurationMS, &k.CreatedAt, &mb); err != nil {
return ex, err
}
k.Metadata = unjson(mb)
ex.KB = append(ex.KB, k)
}
rows, err = r.pool.Query(ctx, `SELECT coalesce(handoff_id,''),mode,status,coalesce(reason_code,''),transfer_attempted,transfer_succeeded,coalesce(target_redacted,''),coalesce(summary_redacted,''),created_at,metadata FROM ai_handoff_audit WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var h HandoffAuditRecord
var mb []byte
h.CallID = callID
if err := rows.Scan(&h.HandoffID, &h.Mode, &h.Status, &h.ReasonCode, &h.TransferAttempted, &h.TransferSucceeded, &h.Target, &h.Summary, &h.CreatedAt, &mb); err != nil {
return ex, err
}
h.Metadata = unjson(mb)
ex.Handoffs = append(ex.Handoffs, h)
}
rows, err = r.pool.Query(ctx, `SELECT provider,event_type,severity,coalesce(error_redacted,''),input_audio_bytes,output_audio_bytes,events_received,events_sent,created_at,metadata FROM ai_provider_audit WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var pr ProviderAuditRecord
var mb []byte
pr.CallID = callID
if err := rows.Scan(&pr.Provider, &pr.EventType, &pr.Severity, &pr.Error, &pr.InputAudioBytes, &pr.OutputAudioBytes, &pr.EventsReceived, &pr.EventsSent, &pr.CreatedAt, &mb); err != nil {
return ex, err
}
pr.Metadata = unjson(mb)
ex.Providers = append(ex.Providers, pr)
}
rows, err = r.pool.Query(ctx, `SELECT event_type,severity,coalesce(codec,''),inbound_frames,inbound_bytes,outbound_frames,outbound_bytes,xoff_count,xon_count,coalesce(error_redacted,''),created_at,metadata FROM ai_media_audit WHERE call_id=$1 ORDER BY created_at`, callID)
if err != nil {
return ex, err
}
defer rows.Close()
for rows.Next() {
var m MediaAuditRecord
var mb []byte
m.CallID = callID
if err := rows.Scan(&m.EventType, &m.Severity, &m.Codec, &m.InboundFrames, &m.InboundBytes, &m.OutboundFrames, &m.OutboundBytes, &m.XOffCount, &m.XOnCount, &m.Error, &m.CreatedAt, &mb); err != nil {
return ex, err
}
m.Metadata = unjson(mb)
ex.Media = append(ex.Media, m)
}
return ex, nil
}
func (r *PostgresRepository) Prune(ctx context.Context, req RetentionPruneRequest) (RetentionPruneResult, error) {
if req.Now.IsZero() {
req.Now = time.Now().UTC()
}
if req.RetentionDays <= 0 {
req.RetentionDays = 180
}
if req.TranscriptRetentionDays <= 0 {
req.TranscriptRetentionDays = 30
}
res := RetentionPruneResult{DryRun: req.DryRun, Status: "ok"}
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM ai_calls WHERE started_at < $1`, req.Now.AddDate(0, 0, -req.RetentionDays)).Scan(&res.CallsDeleted)
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM ai_call_events WHERE created_at < $1`, req.Now.AddDate(0, 0, -req.RetentionDays)).Scan(&res.EventsDeleted)
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM ai_transcript_events WHERE created_at < $1`, req.Now.AddDate(0, 0, -req.TranscriptRetentionDays)).Scan(&res.TranscriptsDeleted)
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM ai_tool_audit WHERE created_at < $1`, req.Now.AddDate(0, 0, -req.ToolAuditRetentionDays)).Scan(&res.ToolAuditDeleted)
_, err := r.pool.Exec(ctx, `INSERT INTO ai_audit_retention_runs(dry_run,status,finished_at,calls_deleted,events_deleted,transcripts_deleted,tool_audit_deleted,metadata) VALUES($1,$2,now(),$3,$4,$5,$6,'{}')`, req.DryRun, res.Status, res.CallsDeleted, res.EventsDeleted, res.TranscriptsDeleted, res.ToolAuditDeleted)
if err != nil {
return res, err
}
return res, nil
}
func jsonb(v map[string]any) []byte {
if v == nil {
v = map[string]any{}
}
b, _ := json.Marshal(v)
return b
}
func unjson(b []byte) map[string]any {
out := map[string]any{}
_ = json.Unmarshal(b, &out)
return out
}
func nonEmpty(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
func wrapErr(op string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", op, err)
}
+16
View File
@@ -0,0 +1,16 @@
package redaction
import "regexp"
var (
openAIKeyPattern = regexp.MustCompile(`sk-[A-Za-z0-9_-]{8,}`)
bearerPattern = regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._~+/-]+=*`)
secretKVPattern = regexp.MustCompile(`(?i)(password|secret|api_key|token)=([^\s&]+)`)
databaseURLPattern = regexp.MustCompile(`(?i)(postgres(?:ql)?://[^:\s/]+):([^@\s]+)@`)
emailPattern = regexp.MustCompile(`(?i)\b([A-Z0-9._%+-])([A-Z0-9._%+-]*)(@[A-Z0-9.-]+\.[A-Z]{2,})\b`)
phonePattern = regexp.MustCompile(`(?:\+7|8)\d{10}`)
digits12Pattern = regexp.MustCompile(`\b\d{12}\b`)
digits16Pattern = regexp.MustCompile(`\b\d{16}\b`)
otpPattern = regexp.MustCompile(`(?i)((?:код(?: подтверждения)?|sms|смс|otp)[^0-9]{0,20})(\d{4,8})`)
accountPattern = regexp.MustCompile(`(?i)((?:лицев(?:ой|ого)\s+сч[её]т)\D{0,20})(\d{5,20})`)
)
+136
View File
@@ -0,0 +1,136 @@
package redaction
import (
"fmt"
"strings"
"unicode/utf8"
)
type RedactionResult struct {
Text string
Applied bool
Findings []Finding
}
type Finding struct {
Type string
Count int
}
type Redactor interface {
RedactText(input string) RedactionResult
RedactJSON(input map[string]any) map[string]any
RedactBytesForLog(input []byte) string
}
type DefaultRedactor struct{}
func New() DefaultRedactor { return DefaultRedactor{} }
func (DefaultRedactor) RedactText(input string) RedactionResult {
out := input
counts := map[string]int{}
apply := func(kind string, fn func(string) string) {
before := out
out = fn(out)
if out != before {
counts[kind]++
}
}
apply("database_url", func(s string) string { return databaseURLPattern.ReplaceAllString(s, `${1}:***MASKED***@`) })
apply("api_key", func(s string) string { return openAIKeyPattern.ReplaceAllString(s, `sk-***MASKED***`) })
apply("bearer", func(s string) string { return bearerPattern.ReplaceAllString(s, `Bearer ***MASKED***`) })
apply("secret", func(s string) string { return secretKVPattern.ReplaceAllString(s, `${1}=***MASKED***`) })
apply("email", func(s string) string { return emailPattern.ReplaceAllString(s, `${1}***${3}`) })
apply("otp", func(s string) string {
return otpPattern.ReplaceAllStringFunc(s, func(m string) string {
if strings.Contains(strings.ToLower(m), "услуг") {
return m
}
parts := otpPattern.FindStringSubmatch(m)
if len(parts) != 3 {
return m
}
return parts[1] + "CODE"
})
})
apply("account", func(s string) string {
return accountPattern.ReplaceAllStringFunc(s, func(m string) string {
parts := accountPattern.FindStringSubmatch(m)
if len(parts) != 3 {
return m
}
return parts[1] + maskMiddle(parts[2], 0, 4, "****")
})
})
apply("phone", func(s string) string {
return phonePattern.ReplaceAllStringFunc(s, func(m string) string { return maskMiddle(m, 4, 4, "***") })
})
apply("iin", func(s string) string {
return digits12Pattern.ReplaceAllStringFunc(s, func(m string) string { return maskMiddle(m, 4, 4, "****") })
})
apply("card", func(s string) string {
return digits16Pattern.ReplaceAllStringFunc(s, func(m string) string { return maskMiddle(m, 4, 4, "********") })
})
findings := make([]Finding, 0, len(counts))
for k, v := range counts {
findings = append(findings, Finding{Type: k, Count: v})
}
return RedactionResult{Text: out, Applied: out != input, Findings: findings}
}
func (r DefaultRedactor) RedactJSON(input map[string]any) map[string]any {
return redactMap(r, input)
}
func (DefaultRedactor) RedactBytesForLog(input []byte) string {
return fmt.Sprintf("[bytes:%d redacted]", len(input))
}
func redactMap(r DefaultRedactor, input map[string]any) map[string]any {
out := map[string]any{}
for k, v := range input {
out[k] = redactValue(r, v)
}
return out
}
func redactValue(r DefaultRedactor, v any) any {
switch x := v.(type) {
case string:
return r.RedactText(x).Text
case map[string]any:
return redactMap(r, x)
case []any:
out := make([]any, len(x))
for i := range x {
out[i] = redactValue(r, x[i])
}
return out
case []string:
out := make([]string, len(x))
for i := range x {
out[i] = r.RedactText(x[i]).Text
}
return out
default:
return v
}
}
func maskMiddle(value string, keepStart, keepEnd int, mask string) string {
if !utf8.ValidString(value) {
return "***MASKED***"
}
r := []rune(value)
if keepStart == 0 && keepEnd > 0 {
if len(r) <= keepEnd {
return mask
}
return mask + string(r[len(r)-keepEnd:])
}
if len(r) <= keepStart+keepEnd {
return strings.Repeat("*", len(r))
}
return string(r[:keepStart]) + mask + string(r[len(r)-keepEnd:])
}
+37
View File
@@ -0,0 +1,37 @@
package redaction
import (
"strings"
"testing"
)
func TestRedactTextSensitiveValues(t *testing.T) {
r := New()
in := "phone +77771234567 iin 123456789012 card 4400123412341234 email test@example.com код 123456 sk-secretvalue Bearer abcdef password=qwerty postgres://u:pass@127.0.0.1/db"
out := r.RedactText(in)
for _, raw := range []string{"+77771234567", "123456789012", "4400123412341234", "test@example.com", "123456 sk-secretvalue", "Bearer abcdef", "password=qwerty", ":pass@"} {
if strings.Contains(out.Text, raw) {
t.Fatalf("raw sensitive value still present: %s in %s", raw, out.Text)
}
}
for _, want := range []string{"+777***4567", "1234****9012", "4400********1234", "t***@example.com", "код CODE", "sk-***MASKED***"} {
if !strings.Contains(out.Text, want) {
t.Fatalf("missing masked value %q in %s", want, out.Text)
}
}
}
func TestRedactDoesNotOverRedactOrdinaryNumbers(t *testing.T) {
out := New().RedactText("3 рабочих дня, код услуги 5104")
if !strings.Contains(out.Text, "3 рабочих дня") || !strings.Contains(out.Text, "5104") {
t.Fatalf("over-redacted ordinary text: %s", out.Text)
}
}
func TestRedactJSONRecursive(t *testing.T) {
out := New().RedactJSON(map[string]any{"a": map[string]any{"phone": "+77771234567"}, "b": []any{"test@example.com"}})
s := strings.Join([]string{out["a"].(map[string]any)["phone"].(string), out["b"].([]any)[0].(string)}, " ")
if strings.Contains(s, "+77771234567") || strings.Contains(s, "test@example.com") {
t.Fatalf("recursive redaction failed: %v", out)
}
}
+18
View File
@@ -0,0 +1,18 @@
package audit
import "context"
type Repository interface {
Health(ctx context.Context) error
UpsertCall(ctx context.Context, call CallRecord) error
EndCall(ctx context.Context, callID string, reason string) error
AddEvent(ctx context.Context, event EventRecord) error
AddTranscript(ctx context.Context, transcript TranscriptRecord) error
AddToolAudit(ctx context.Context, tool ToolAuditRecord) error
AddKBAudit(ctx context.Context, kb KBAuditRecord) error
AddHandoffAudit(ctx context.Context, handoff HandoffAuditRecord) error
AddProviderAudit(ctx context.Context, provider ProviderAuditRecord) error
AddMediaAudit(ctx context.Context, media MediaAuditRecord) error
ExportCall(ctx context.Context, callID string) (CallAuditExport, error)
Prune(ctx context.Context, req RetentionPruneRequest) (RetentionPruneResult, error)
}
+1
View File
@@ -0,0 +1 @@
package audit
+137
View File
@@ -0,0 +1,137 @@
package audit
import (
"context"
"log/slog"
"time"
"ai-operator/internal/audit/redaction"
"ai-operator/internal/config"
)
type Service struct {
repo Repository
cfg config.AuditConfig
redactor redaction.DefaultRedactor
logger *slog.Logger
}
func NewService(repo Repository, cfg config.AuditConfig, logger *slog.Logger) *Service {
if repo == nil || !cfg.Enabled {
repo = NoopRepository{}
}
return &Service{repo: repo, cfg: cfg, redactor: redaction.New(), logger: logger}
}
func (s *Service) Health(ctx context.Context) error { return s.repo.Health(ctx) }
func (s *Service) UpsertCall(ctx context.Context, c CallRecord) error {
c.CallerNumberMasked = s.redact(c.CallerNumberMasked, s.cfg.MaxEventMetadataChars)
c.Metadata = s.redactJSON(c.Metadata)
return s.handle("audit upsert call", s.repo.UpsertCall(ctx, c))
}
func (s *Service) EndCall(ctx context.Context, callID, reason string) error {
return s.handle("audit end call", s.repo.EndCall(ctx, callID, s.redact(reason, 512)))
}
func (s *Service) AddEvent(ctx context.Context, e EventRecord) error {
e.Message = s.redact(e.Message, s.cfg.MaxEventMetadataChars)
e.Metadata = s.redactJSON(e.Metadata)
return s.handle("audit event", s.repo.AddEvent(ctx, e))
}
func (s *Service) AddTranscript(ctx context.Context, t TranscriptRecord) error {
if !s.cfg.StoreTranscripts {
return nil
}
if t.EventType == "transcript.user.delta" || t.EventType == "transcript.assistant.delta" {
if !s.cfg.StoreTranscriptDeltas {
return nil
}
}
t.Text = s.redact(t.Text, s.cfg.MaxTranscriptChars)
t.Metadata = s.redactJSON(t.Metadata)
return s.handle("audit transcript", s.repo.AddTranscript(ctx, t))
}
func (s *Service) AddToolAudit(ctx context.Context, t ToolAuditRecord) error {
t.Args = s.redactJSON(t.Args)
t.Result = s.redactJSON(t.Result)
t.Metadata = s.redactJSON(t.Metadata)
return s.handle("audit tool", s.repo.AddToolAudit(ctx, t))
}
func (s *Service) AddKBAudit(ctx context.Context, k KBAuditRecord) error {
k.Query = s.redact(k.Query, s.cfg.MaxTranscriptChars)
k.Metadata = s.redactJSON(k.Metadata)
return s.handle("audit kb", s.repo.AddKBAudit(ctx, k))
}
func (s *Service) AddHandoffAudit(ctx context.Context, h HandoffAuditRecord) error {
h.Target = s.redact(h.Target, 512)
h.Summary = s.redact(h.Summary, s.cfg.MaxEventMetadataChars)
h.Metadata = s.redactJSON(h.Metadata)
return s.handle("audit handoff", s.repo.AddHandoffAudit(ctx, h))
}
func (s *Service) AddProviderAudit(ctx context.Context, p ProviderAuditRecord) error {
if !s.cfg.StoreProviderEvents {
return nil
}
p.Error = s.redact(p.Error, s.cfg.MaxEventMetadataChars)
p.Metadata = s.redactJSON(p.Metadata)
return s.handle("audit provider", s.repo.AddProviderAudit(ctx, p))
}
func (s *Service) AddMediaAudit(ctx context.Context, m MediaAuditRecord) error {
if !s.cfg.StoreMediaStats {
return nil
}
m.Error = s.redact(m.Error, s.cfg.MaxEventMetadataChars)
m.Metadata = s.redactJSON(m.Metadata)
return s.handle("audit media", s.repo.AddMediaAudit(ctx, m))
}
func (s *Service) ExportCall(ctx context.Context, callID string) (CallAuditExport, error) {
return s.repo.ExportCall(ctx, callID)
}
func (s *Service) Prune(ctx context.Context, req RetentionPruneRequest) (RetentionPruneResult, error) {
if req.Now.IsZero() {
req.Now = time.Now().UTC()
}
if req.RetentionDays == 0 {
req.RetentionDays = s.cfg.RetentionDays
}
if req.TranscriptRetentionDays == 0 {
req.TranscriptRetentionDays = s.cfg.TranscriptRetentionDays
}
if req.ToolAuditRetentionDays == 0 {
req.ToolAuditRetentionDays = s.cfg.ToolAuditRetentionDays
}
if req.ErrorAuditRetentionDays == 0 {
req.ErrorAuditRetentionDays = s.cfg.ErrorAuditRetentionDays
}
return s.repo.Prune(ctx, req)
}
func (s *Service) redact(v string, max int) string {
if max > 0 && len([]rune(v)) > max {
r := []rune(v)
v = string(r[:max])
}
if !s.cfg.RedactionEnabled {
return v
}
return s.redactor.RedactText(v).Text
}
func (s *Service) redactJSON(v map[string]any) map[string]any {
if v == nil {
return map[string]any{}
}
if !s.cfg.RedactionEnabled {
return v
}
return s.redactor.RedactJSON(v)
}
func (s *Service) handle(msg string, err error) error {
if err == nil {
return nil
}
if s.logger != nil {
s.logger.Warn(msg, "error", s.redact(err.Error(), 1024))
}
if s.cfg.FailClosed {
return err
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package audit
import "time"
type CallRecord struct {
CallID string `json:"call_id"`
AsteriskChannelID string `json:"asterisk_channel_id,omitempty"`
Route string `json:"route"`
CallerNumberMasked string `json:"caller_number_masked,omitempty"`
Language string `json:"language,omitempty"`
RegionCode string `json:"region_code,omitempty"`
State string `json:"state,omitempty"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
EndReason string `json:"end_reason,omitempty"`
HandoffStatus string `json:"handoff_status,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type EventRecord struct {
CallID, EventType, EventSource, StateBefore, StateAfter, Severity, Message, ReasonCode string
CreatedAt time.Time
Metadata map[string]any
}
type TranscriptRecord struct {
CallID, Speaker, EventType, Language, Text string
CreatedAt time.Time
Metadata map[string]any
}
type ToolAuditRecord struct {
CallID, ToolCallID, ToolName, State, Language, RegionCode, ReasonCode string
Allowed, Denied bool
Args, Result map[string]any
DurationMS int64
CreatedAt time.Time
Metadata map[string]any
}
type KBAuditRecord struct {
CallID, Query, Language, RegionCode string
ResultCount int
TopScore float64
CrossLanguageFallbackUsed bool
CitationsCount int
NoAnswer bool
DurationMS int64
CreatedAt time.Time
Metadata map[string]any
}
type HandoffAuditRecord struct {
CallID, HandoffID, Mode, Status, ReasonCode, Target, Summary string
TransferAttempted, TransferSucceeded bool
CreatedAt time.Time
Metadata map[string]any
}
type ProviderAuditRecord struct {
CallID, Provider, EventType, Severity, Error string
InputAudioBytes, OutputAudioBytes, EventsReceived, EventsSent int64
CreatedAt time.Time
Metadata map[string]any
}
type MediaAuditRecord struct {
CallID, EventType, Severity, Codec, Error string
InboundFrames, InboundBytes, OutboundFrames, OutboundBytes, XOffCount, XOnCount int64
CreatedAt time.Time
Metadata map[string]any
}
type CallAuditExport struct {
Call *CallRecord `json:"call,omitempty"`
Events []EventRecord `json:"events"`
Transcripts []TranscriptRecord `json:"transcripts"`
Tools []ToolAuditRecord `json:"tools"`
KB []KBAuditRecord `json:"kb"`
Handoffs []HandoffAuditRecord `json:"handoffs"`
Providers []ProviderAuditRecord `json:"providers"`
Media []MediaAuditRecord `json:"media"`
}
type RetentionPruneRequest struct {
DryRun bool
Now time.Time
RetentionDays, TranscriptRetentionDays, ToolAuditRetentionDays, ErrorAuditRetentionDays int
}
type RetentionPruneResult struct {
DryRun bool `json:"dry_run"`
CallsDeleted int `json:"calls_deleted"`
EventsDeleted int `json:"events_deleted"`
TranscriptsDeleted int `json:"transcripts_deleted"`
ToolAuditDeleted int `json:"tool_audit_deleted"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}