sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package audit
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})`)
|
||||
)
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package audit
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user