From ac685ccaf5be085a005031070dad9ace40a506f2 Mon Sep 17 00:00:00 2001 From: mt21625457 Date: Fri, 17 Jul 2026 14:38:10 +0800 Subject: [PATCH] feat(security-audit): persist full prompts on audit events and polish event review UI - Add prompt_audit_events.full_prompt (migration 182) so admins can review the exact unredacted prompt that triggered a finding; blocking mode writes it from the snapshot, async mode reconstructs it from the Redis scan payload so jobs rows stay redaction-only - Event detail API returns full_prompt (list endpoint stays lean); text is NUL-stripped and capped at 65536 runes - Detail dialog shows the full prompt in a scrollable pane with fallback to the legacy redacted preview; page copy updated to match the new behavior - Rework filter deletion into a dedicated dialog with time-range presets and criteria-change preview invalidation; localize decision/risk/category labels across the events workspace - Fix pre-existing i18n message-compile spec by declaring the @intlify/message-compiler dev dependency --- .../securityaudit/prompt_event_repository.go | 18 +- .../securityaudit/prompt_repository.go | 30 ++-- .../prompt_repository_integration_test.go | 35 ++-- .../securityaudit/prompt_repository_test.go | 25 +++ .../internal/securityaudit/prompt_snapshot.go | 25 +++ .../securityaudit/prompt_snapshot_test.go | 28 +++ .../internal/securityaudit/prompt_types.go | 1 + .../internal/securityaudit/prompt_worker.go | 3 + .../182_prompt_audit_full_prompt.sql | 5 + frontend/package.json | 1 + frontend/pnpm-lock.yaml | 3 + .../features/prompt-audit/PromptAuditView.vue | 75 ++++---- .../__tests__/PromptAuditView.spec.ts | 56 ++++-- .../prompt-audit/__tests__/components.spec.ts | 150 +++++++++++++++- .../components/EventDetailDialog.vue | 167 ++++++++++++++---- .../components/EventWorkspace.vue | 46 +++-- .../components/FilterDeleteDialog.vue | 166 +++++++++++++++++ .../prompt-audit/components/PolicyPanel.vue | 7 +- frontend/src/features/prompt-audit/types.ts | 1 + .../src/features/prompt-audit/viewModel.ts | 29 +++ .../src/i18n/locales/en/admin/promptAudit.ts | 59 ++++++- .../src/i18n/locales/zh/admin/promptAudit.ts | 59 ++++++- 22 files changed, 841 insertions(+), 148 deletions(-) create mode 100644 backend/internal/securityaudit/prompt_repository_test.go create mode 100644 backend/migrations/182_prompt_audit_full_prompt.sql create mode 100644 frontend/src/features/prompt-audit/components/FilterDeleteDialog.vue diff --git a/backend/internal/securityaudit/prompt_event_repository.go b/backend/internal/securityaudit/prompt_event_repository.go index aaa74588a4..edbc9c0fb1 100644 --- a/backend/internal/securityaudit/prompt_event_repository.go +++ b/backend/internal/securityaudit/prompt_event_repository.go @@ -103,7 +103,7 @@ func (r *PostgreSQLRepository) ListEvents(ctx context.Context, filter EventFilte } func (r *PostgreSQLRepository) GetEvent(ctx context.Context, id int64) (*Event, error) { - event, err := scanEvent(r.db.QueryRowContext(ctx, `SELECT `+eventColumns("e")+` FROM prompt_audit_events e WHERE e.id=$1`, id)) + event, err := scanEvent(r.db.QueryRowContext(ctx, `SELECT `+eventDetailColumns("e")+` FROM prompt_audit_events e WHERE e.id=$1`, id), true) if errors.Is(err, sql.ErrNoRows) { return nil, ErrEventNotFound } @@ -320,18 +320,28 @@ func eventColumns(alias string) string { %[1]s.chunk_total,%[1]s.latency_ms,%[1]s.created_at`, alias) } -func scanEvent(row rowScanner) (*Event, error) { +// eventDetailColumns adds the full prompt, which can be large, so it is only +// loaded for single-event detail reads and never for list pages. +func eventDetailColumns(alias string) string { + return eventColumns(alias) + fmt.Sprintf(",%[1]s.full_prompt", alias) +} + +func scanEvent(row rowScanner, withFullPrompt ...bool) (*Event, error) { event := &Event{} var userID, apiKeyID, groupID sql.NullInt64 var categories, matched, scores, evidence []byte - err := row.Scan(&event.ID, &event.JobID, &event.Snapshot.RequestID, &userID, + dest := []any{&event.ID, &event.JobID, &event.Snapshot.RequestID, &userID, &event.Snapshot.UsernameSnapshot, &event.Snapshot.UserEmailSnapshot, &apiKeyID, &event.Snapshot.APIKeyNameSnapshot, &groupID, &event.Snapshot.GroupName, &event.Snapshot.Provider, &event.Snapshot.Endpoint, &event.Snapshot.Protocol, &event.Snapshot.Model, &event.Snapshot.PromptHash, &event.Snapshot.RedactedPreview, &event.Snapshot.Stage, &event.Decision, &event.RiskLevel, &event.Action, &categories, &matched, &scores, &evidence, &event.ScannerBackend, &event.ScannerVersion, &event.GuardEndpointID, &event.PolicyID, &event.PolicyVersion, - &event.ConfigVersion, &event.ChunkTotal, &event.LatencyMS, &event.CreatedAt) + &event.ConfigVersion, &event.ChunkTotal, &event.LatencyMS, &event.CreatedAt} + if len(withFullPrompt) > 0 && withFullPrompt[0] { + dest = append(dest, &event.Snapshot.FullPrompt) + } + err := row.Scan(dest...) if err != nil { return nil, err } diff --git a/backend/internal/securityaudit/prompt_repository.go b/backend/internal/securityaudit/prompt_repository.go index 2d304c94d9..a1fe9bb641 100644 --- a/backend/internal/securityaudit/prompt_repository.go +++ b/backend/internal/securityaudit/prompt_repository.go @@ -69,12 +69,12 @@ type JobRepository interface { MarkStagingFailed(ctx context.Context, jobID int64, code, message string) error ClaimNextJob(ctx context.Context, now time.Time) (*Job, bool, error) RefreshLease(ctx context.Context, jobID, claimVersion int64, now time.Time) error - Complete(ctx context.Context, job *Job, result *NormalizedResult, storePass bool) (*Event, error) + Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error) Retry(ctx context.Context, jobID, claimVersion int64, next time.Time, code, message string) error Fail(ctx context.Context, jobID, claimVersion int64, code, message string) error ReclaimStale(ctx context.Context, stagingBefore, processingBefore time.Time, limit int) (int64, error) QueueStats(ctx context.Context) (QueueStats, error) - RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePass bool) (*Event, error) + RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error) } type PostgreSQLRepository struct { @@ -169,7 +169,7 @@ func (r *PostgreSQLRepository) RefreshLease(ctx context.Context, jobID, claimVer return requireOneRow(result, err, ErrLeaseLost) } -func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *NormalizedResult, storePass bool) (*Event, error) { +func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error) { if job == nil || result == nil { return nil, errors.New("prompt audit completion requires job and result") } @@ -186,7 +186,7 @@ func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *N return nil, err } var event *Event - if storePass || result.Decision != EventPass { + if shouldStorePromptAuditEvent(result.Decision, storePassEvents) { event, err = insertEvent(ctx, tx, job.ID, job.Snapshot.Redacted(), job.ConfigVersion, result) if err != nil { return nil, err @@ -278,7 +278,7 @@ func (r *PostgreSQLRepository) QueueStats(ctx context.Context) (QueueStats, erro return stats, rows.Err() } -func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePass bool) (*Event, error) { +func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error) { if result == nil { return nil, errors.New("prompt guard result required") } @@ -292,7 +292,7 @@ func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot Prom return nil, err } var event *Event - if storePass || result.Decision != EventPass { + if shouldStorePromptAuditEvent(result.Decision, storePassEvents) { event, err = insertEvent(ctx, tx, job.ID, snapshot.Redacted(), configVersion, result) if err != nil { return nil, err @@ -304,6 +304,12 @@ func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot Prom return event, nil } +// shouldStorePromptAuditEvent keeps store_pass_events scoped to safe results. +// Risk events are always persisted while prompt auditing itself is enabled. +func shouldStorePromptAuditEvent(decision EventDecision, storePassEvents bool) bool { + return decision != EventPass || storePassEvents +} + type sqlQueryer interface { QueryRowContext(context.Context, string, ...any) *sql.Row } @@ -342,17 +348,19 @@ func insertEvent(ctx context.Context, queryer sqlQueryer, jobID int64, snapshot job_id,request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot, group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,stage, decision,risk_level,action,categories,matched_scanners,scanner_scores,scanner_evidence, - scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms + scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms, + full_prompt ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19, - $20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31) - RETURNING `+eventColumns("prompt_audit_events"), + $20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31,$32) + RETURNING `+eventDetailColumns("prompt_audit_events"), jobID, snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot, nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName, snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash, snapshot.RedactedPreview, normalizeStage(snapshot.Stage), string(result.Decision), string(result.RiskLevel), string(result.Action), categories, matched, scores, evidenceJSON, result.ScannerBackend, result.ScannerVersion, - result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS) - return scanEvent(row) + result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS, + snapshot.FullPrompt) + return scanEvent(row, true) } type rowScanner interface{ Scan(...any) error } diff --git a/backend/internal/securityaudit/prompt_repository_integration_test.go b/backend/internal/securityaudit/prompt_repository_integration_test.go index 33a940f9b8..c334718d8e 100644 --- a/backend/internal/securityaudit/prompt_repository_integration_test.go +++ b/backend/internal/securityaudit/prompt_repository_integration_test.go @@ -43,15 +43,16 @@ func openPromptAuditIntegrationDB(t *testing.T) *sql.DB { ); `) require.NoError(t, err) - migrationPath := filepath.Join("..", "..", "migrations", "181_prompt_audit.sql") - migration, err := os.ReadFile(migrationPath) - require.NoError(t, err) - // The migration runner can retry an interrupted deployment; the migration - // must therefore be safe to execute more than once. - _, err = db.ExecContext(ctx, string(migration)) - require.NoError(t, err) - _, err = db.ExecContext(ctx, string(migration)) - require.NoError(t, err) + for _, name := range []string{"181_prompt_audit.sql", "182_prompt_audit_full_prompt.sql"} { + migration, err := os.ReadFile(filepath.Join("..", "..", "migrations", name)) + require.NoError(t, err) + // The migration runner can retry an interrupted deployment; the migration + // must therefore be safe to execute more than once. + _, err = db.ExecContext(ctx, string(migration)) + require.NoError(t, err) + _, err = db.ExecContext(ctx, string(migration)) + require.NoError(t, err) + } t.Cleanup(func() { require.NoError(t, db.Close()) }) resetPromptAuditIntegrationDB(t, db) return db @@ -150,7 +151,7 @@ func TestPromptAuditMigrationSchemaAndLeakageGate(t *testing.T) { require.Error(t, err) } -func TestPromptAuditDatabaseAndAdminJSONNeverPersistCanaryPromptOrRawErrors(t *testing.T) { +func TestPromptAuditDatabasePersistsFullPromptOnEventsOnly(t *testing.T) { db := openPromptAuditIntegrationDB(t) repo := NewPostgreSQLRepository(db) ctx := context.Background() @@ -163,11 +164,23 @@ func TestPromptAuditDatabaseAndAdminJSONNeverPersistCanaryPromptOrRawErrors(t *t snapshot, err := ExtractPromptSnapshot(request) require.NoError(t, err) require.NotContains(t, snapshot.RedactedPreview, promptCanary) + require.Contains(t, snapshot.FullPrompt, promptCanary) event, err := repo.RecordBlocking(ctx, snapshot.Redacted(), 1, integrationResult(EventCritical), true) require.NoError(t, err) + // The event intentionally retains the full prompt for admin review; the + // redacted preview and transient job row still never contain it. adminJSON, err := json.Marshal(event) require.NoError(t, err) - require.NotContains(t, string(adminJSON), promptCanary) + require.Contains(t, string(adminJSON), promptCanary) + require.NotContains(t, event.Snapshot.RedactedPreview, promptCanary) + + var storedFullPrompt string + require.NoError(t, db.QueryRow(`SELECT full_prompt FROM prompt_audit_events WHERE id=$1`, event.ID).Scan(&storedFullPrompt)) + require.Contains(t, storedFullPrompt, promptCanary) + + detail, err := repo.GetEvent(ctx, event.ID) + require.NoError(t, err) + require.Contains(t, detail.Snapshot.FullPrompt, promptCanary) var jobJSON string require.NoError(t, db.QueryRow(`SELECT row_to_json(j)::text FROM prompt_audit_jobs j WHERE id=$1`, event.JobID).Scan(&jobJSON)) diff --git a/backend/internal/securityaudit/prompt_repository_test.go b/backend/internal/securityaudit/prompt_repository_test.go new file mode 100644 index 0000000000..9fbf45b2c0 --- /dev/null +++ b/backend/internal/securityaudit/prompt_repository_test.go @@ -0,0 +1,25 @@ +package securityaudit + +import "testing" + +func TestShouldStorePromptAuditEvent(t *testing.T) { + tests := []struct { + name string + storePassEvents bool + decision EventDecision + want bool + }{ + {name: "pass disabled", storePassEvents: false, decision: EventPass, want: false}, + {name: "flag disabled", storePassEvents: false, decision: EventFlag, want: true}, + {name: "critical disabled", storePassEvents: false, decision: EventCritical, want: true}, + {name: "pass enabled", storePassEvents: true, decision: EventPass, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldStorePromptAuditEvent(tt.decision, tt.storePassEvents); got != tt.want { + t.Fatalf("shouldStorePromptAuditEvent(%q, %t) = %t, want %t", tt.decision, tt.storePassEvents, got, tt.want) + } + }) + } +} diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index 237eedfdda..2d38a877ff 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -50,6 +50,7 @@ func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) { GroupID: cloneInt64Ptr(req.GroupID), GroupName: req.GroupName, Provider: req.Provider, Endpoint: req.Endpoint, Protocol: req.Protocol, Model: req.Model, PromptHash: hex.EncodeToString(digest[:]), RedactedPreview: BuildPromptPreview(metadataText, DefaultPromptPreviewMaxRunes), + FullPrompt: BuildFullPrompt(metadataText, DefaultFullPromptMaxRunes), PromptLength: utf8.RuneCountInString(metadataText), MessageCount: len(segments), Stage: stage, ScanText: scanText, }, nil @@ -59,6 +60,11 @@ func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) { // considered before BuildPromptPreview withholds the majority for storage/UI. const DefaultPromptPreviewMaxRunes = 96 +// DefaultFullPromptMaxRunes caps how much unredacted prompt text is persisted +// on an audit event for admin review. It is deliberately generous so realistic +// prompts are kept intact while bounding per-row storage. +const DefaultFullPromptMaxRunes = 65536 + func extractProtocolSegments(protocol string, document any) []promptSegment { root, _ := document.(map[string]any) protocol = strings.ToLower(strings.TrimSpace(protocol)) @@ -520,6 +526,25 @@ func BuildPromptPreview(value string, maxRunes int) string { return preview } +// BuildFullPrompt returns the complete prompt text for audit-event storage and +// admin review, without redaction. NUL bytes are stripped because PostgreSQL +// TEXT rejects them, and the result is capped at maxRunes. +func BuildFullPrompt(value string, maxRunes int) string { + if maxRunes <= 0 { + maxRunes = DefaultFullPromptMaxRunes + } + value = strings.ReplaceAll(value, "\x00", "") + return TrimRunes(strings.TrimSpace(value), maxRunes) +} + +// FullPromptFromScanText reconstructs the display prompt from the worker scan +// payload. buildPrioritizedScanText inserts exactly one priority separator +// between the prioritized segment and the remainder, so replacing it with the +// metadata joiner yields the original multi-segment text. +func FullPromptFromScanText(scanText string) string { + return BuildFullPrompt(strings.ReplaceAll(scanText, promptAuditPrioritySeparator, "\n\n"), DefaultFullPromptMaxRunes) +} + func TrimRunes(value string, limit int) string { if limit <= 0 { return "" diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index eb432f2569..ceff3756e2 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -54,6 +54,34 @@ func TestSnapshotRedactsCanariesAndPreservesHashOfScanText(t *testing.T) { require.Empty(t, snapshot.Redacted().ScanText) } +func TestSnapshotFullPromptKeepsUnredactedText(t *testing.T) { + body := `{"messages":[{"role":"user","content":"PROMPT_CANARY_ABC123 email@example.com sk-secretvalue123"}]}` + snapshot, err := ExtractPromptSnapshot(Request{Protocol: "openai_chat_completions", Body: []byte(body)}) + require.NoError(t, err) + // The full prompt is stored verbatim for admin review, unlike the preview. + require.Contains(t, snapshot.FullPrompt, "PROMPT_CANARY_ABC123 email@example.com sk-secretvalue123") + require.NotContains(t, snapshot.RedactedPreview, "PROMPT_CANARY_ABC123") + require.Equal(t, snapshot.FullPrompt, snapshot.Redacted().FullPrompt) +} + +func TestBuildFullPromptStripsNULAndTruncates(t *testing.T) { + require.Equal(t, "abcd", BuildFullPrompt("ab\x00cd", 0)) + long := strings.Repeat("长", DefaultFullPromptMaxRunes+10) + trimmed := BuildFullPrompt(long, DefaultFullPromptMaxRunes) + require.Equal(t, DefaultFullPromptMaxRunes+1, utf8.RuneCountInString(trimmed)) + require.True(t, strings.HasSuffix(trimmed, "…")) +} + +func TestFullPromptFromScanTextRestoresMultiSegmentLayout(t *testing.T) { + scanText, metadataText := buildPrioritizedScanText([]string{"latest user", "system policy", "earlier user"}) + require.Contains(t, scanText, promptAuditPrioritySeparator) + require.Equal(t, metadataText, FullPromptFromScanText(scanText)) + + singleScan, singleMeta := buildPrioritizedScanText([]string{"only"}) + require.NotContains(t, singleScan, promptAuditPrioritySeparator) + require.Equal(t, singleMeta, FullPromptFromScanText(singleScan)) +} + func TestSplitRunesDoesNotSplitUTF8(t *testing.T) { chunks := SplitRunes("中文😀éabc", 2) require.Equal(t, []string{"中文", "😀e", "́a", "bc"}, chunks) diff --git a/backend/internal/securityaudit/prompt_types.go b/backend/internal/securityaudit/prompt_types.go index bf38b64637..34611444e0 100644 --- a/backend/internal/securityaudit/prompt_types.go +++ b/backend/internal/securityaudit/prompt_types.go @@ -105,6 +105,7 @@ type PromptSnapshot struct { Model string `json:"model"` PromptHash string `json:"prompt_hash"` RedactedPreview string `json:"redacted_preview"` + FullPrompt string `json:"full_prompt"` PromptLength int `json:"prompt_length"` MessageCount int `json:"message_count"` Stage string `json:"stage"` diff --git a/backend/internal/securityaudit/prompt_worker.go b/backend/internal/securityaudit/prompt_worker.go index 5db007f6bd..05ae828f05 100644 --- a/backend/internal/securityaudit/prompt_worker.go +++ b/backend/internal/securityaudit/prompt_worker.go @@ -140,6 +140,9 @@ func (r *Runner) processJob(ctx context.Context, workerID int, cfg ActiveConfig, if err != nil { return r.finishFailure(ctx, job, &GuardError{Code: "payload_missing", Retryable: false, Cause: err}) } + // The job row only carries redacted metadata; the full prompt for the audit + // event is reconstructed here from the transient scan payload. + job.Snapshot.FullPrompt = FullPromptFromScanText(scanText) endpoints := cfg.EnabledEndpoints() if len(endpoints) == 0 { return r.finishFailure(ctx, job, &GuardError{Code: "no_enabled_endpoint", Retryable: true}) diff --git a/backend/migrations/182_prompt_audit_full_prompt.sql b/backend/migrations/182_prompt_audit_full_prompt.sql new file mode 100644 index 0000000000..a93fb1bb37 --- /dev/null +++ b/backend/migrations/182_prompt_audit_full_prompt.sql @@ -0,0 +1,5 @@ +-- Retain the full (unredacted) prompt text on audit events so admins can +-- review the exact content that triggered a finding. Scoped to events only: +-- transient processing jobs keep storing redacted metadata. +ALTER TABLE prompt_audit_events + ADD COLUMN IF NOT EXISTS full_prompt TEXT NOT NULL DEFAULT ''; diff --git a/frontend/package.json b/frontend/package.json index 06b46d0d2e..77111e84fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "xlsx": "^0.18.5" }, "devDependencies": { + "@intlify/message-compiler": "9.14.5", "@types/dompurify": "^3.0.5", "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 43378527c9..9d3c450271 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: specifier: ^0.18.5 version: 0.18.5 devDependencies: + '@intlify/message-compiler': + specifier: 9.14.5 + version: 9.14.5 '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 diff --git a/frontend/src/features/prompt-audit/PromptAuditView.vue b/frontend/src/features/prompt-audit/PromptAuditView.vue index 939fc52acd..22959fd7fb 100644 --- a/frontend/src/features/prompt-audit/PromptAuditView.vue +++ b/frontend/src/features/prompt-audit/PromptAuditView.vue @@ -127,23 +127,17 @@ @confirm="confirmIDDelete" @cancel="clearDeleteRequest" /> - -
-

{{ t('admin.promptAudit.events.filterDeleteCount', { count: deletePreview.matched_count }) }}

-
-
{{ t('admin.promptAudit.events.snapshotMax') }}
{{ deletePreview.snapshot_max_id }}
-
Filter SHA-256
{{ deletePreview.filter_hash }}
-
{{ t('admin.promptAudit.events.expiresAt') }}
{{ formatDate(deletePreview.expires_at) }}
-
-

{{ t('admin.promptAudit.events.filterDeleteWarning') }}

-
- -
+ @@ -152,7 +146,6 @@ import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue' import { useI18n } from 'vue-i18n' import AppLayout from '@/components/layout/AppLayout.vue' -import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import { useAppStore } from '@/stores/app' import { extractApiErrorCode, extractApiErrorMessage } from '@/utils/apiError' @@ -161,6 +154,7 @@ import EndpointPool from './components/EndpointPool.vue' import PolicyPanel from './components/PolicyPanel.vue' import EventWorkspace from './components/EventWorkspace.vue' import EventDetailDialog from './components/EventDetailDialog.vue' +import FilterDeleteDialog from './components/FilterDeleteDialog.vue' import promptAuditAPI from './api' import type { PromptAuditDraft, @@ -179,7 +173,7 @@ import { buildUpdateRequest, cloneData, configToDraft, draftFingerprint, emptyEv const { t, locale } = useI18n() const appStore = useAppStore() type PromptAuditPageTab = 'config' | 'events' -const activeTab = ref('config') +const activeTab = ref('events') const pageTabs = computed(() => [ { id: 'events' as const, label: t('admin.promptAudit.tabs.events') }, { id: 'config' as const, label: t('admin.promptAudit.tabs.config') }, @@ -196,10 +190,12 @@ const activeEvent = ref(null) const showEventDetail = ref(false) const probeResults = reactive>({}) const probingIds = ref([]) +const showFilterDelete = ref(false) const deletePreview = ref(null) +const deletePreviewFilters = ref(null) const showBlockingConfirmation = ref(false) const deleteRequest = reactive<{ mode: '' | 'single' | 'batch'; ids: number[] }>({ mode: '', ids: [] }) -const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false }) +const loading = reactive({ config: false, runtime: false, groups: false, events: false, saving: false, detail: false, deleting: false, previewing: false }) const loadErrors = reactive({ config: '', runtime: '', groups: '', events: '' }) const dirty = computed(() => draftFingerprint(draft.value) !== draftFingerprint(serverConfig.value)) @@ -346,13 +342,13 @@ async function runProbe(endpoint: PromptAuditEndpointDraft) { function handleFiltersChanged(value: PromptEventFilters) { filters.value = cloneData(value) - deletePreview.value = null + clearDeletePreview() } function applyEventFilters(value: PromptEventFilters) { filters.value = cloneData(value) appliedFilters.value = cloneData(value) events.page = 1 - deletePreview.value = null + clearDeletePreview() void loadEvents() } function changePage(value: number) { events.page = value; void loadEvents() } @@ -382,23 +378,40 @@ async function confirmIDDelete() { } catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.delete')) } finally { loading.deleting = false } } -async function requestFilterDeletePreview() { - loading.deleting = true - try { deletePreview.value = await promptAuditAPI.previewDelete(filters.value) } - catch (error) { appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete')) } - finally { loading.deleting = false } +function clearDeletePreview() { + deletePreview.value = null + deletePreviewFilters.value = null +} +function requestFilterDeletePreview() { + clearDeletePreview() + showFilterDelete.value = true +} +function closeFilterDelete() { + showFilterDelete.value = false + clearDeletePreview() +} +async function runFilterDeletePreview(value: PromptEventFilters) { + loading.previewing = true + try { + deletePreview.value = await promptAuditAPI.previewDelete(value) + deletePreviewFilters.value = cloneData(value) + } catch (error) { + clearDeletePreview() + appStore.showError(errorMessage(error, 'admin.promptAudit.errors.previewDelete')) + } finally { loading.previewing = false } } async function confirmFilterDelete() { - if (!deletePreview.value) return + if (!deletePreview.value || !deletePreviewFilters.value) return const preview = deletePreview.value + const previewFilters = cloneData(deletePreviewFilters.value) loading.deleting = true try { - const result = await promptAuditAPI.deleteEventsByFilter(filters.value, preview) - deletePreview.value = null + const result = await promptAuditAPI.deleteEventsByFilter(previewFilters, preview) + closeFilterDelete() appStore.showSuccess(t('admin.promptAudit.messages.deleted', { count: result.deleted_events })) await Promise.allSettled([loadEvents(), loadRuntime()]) } catch (error) { - deletePreview.value = null + clearDeletePreview() appStore.showError(errorMessage(error, 'admin.promptAudit.errors.deleteConfirmation')) } finally { loading.deleting = false } } diff --git a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts index 1309430d07..e565686aee 100644 --- a/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/PromptAuditView.spec.ts @@ -45,12 +45,16 @@ const EventsStub = defineComponent({ template: '
', }) const DetailStub = defineComponent({ props: ['show', 'event', 'loading'], emits: ['close'], template: '
' }) -const DialogStub = defineComponent({ props: ['show', 'title'], emits: ['close'], template: '
' }) const ConfirmStub = defineComponent({ props: ['show', 'title', 'message'], emits: ['confirm', 'cancel'], template: '
' }) +const FilterDeleteStub = defineComponent({ + props: ['show', 'initialFilters', 'preview', 'previewing', 'deleting'], + emits: ['close', 'preview', 'confirm', 'criteria-change'], + template: '
{{ preview ? preview.matched_count : \'none\' }}
', +}) function mountView() { return mount(PromptAuditView, { - global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, BaseDialog: DialogStub, ConfirmDialog: ConfirmStub } }, + global: { stubs: { AppLayout: AppLayoutStub, RuntimeOverview: RuntimeStub, EndpointPool: EndpointStub, PolicyPanel: PolicyStub, EventWorkspace: EventsStub, EventDetailDialog: DetailStub, FilterDeleteDialog: FilterDeleteStub, ConfirmDialog: ConfirmStub } }, }) } @@ -86,28 +90,30 @@ describe('PromptAuditView', () => { const wrapper = mountView() await flushPromises() + expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true') + expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('false') + expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none') + expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').toContain('display: none') + expect(wrapper.find('[data-test="save-config"]').exists()).toBe(false) + expect(wrapper.find('[data-test="events"]').exists()).toBe(true) + expect(wrapper.find('[data-test="pass-events-disabled-notice"]').exists()).toBe(true) + expect(wrapper.get('[data-test="tab-events"]').text()).toContain('admin.promptAudit.tabs.events') + expect(wrapper.get('[data-test="tab-config"]').text()).toContain('admin.promptAudit.tabs.config') + + await wrapper.get('[data-test="tab-config"]').trigger('click') + await flushPromises() expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true') - expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('false') expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none') expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').toContain('display: none') expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true) - expect(wrapper.get('[data-test="tab-events"]').text()).toContain('admin.promptAudit.tabs.events') - expect(wrapper.get('[data-test="tab-config"]').text()).toContain('admin.promptAudit.tabs.config') await wrapper.get('[data-test="tab-events"]').trigger('click') await flushPromises() expect(wrapper.get('[data-test="tab-events"]').attributes('aria-selected')).toBe('true') - expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').toContain('display: none') - expect(wrapper.get('[data-test="tab-panel-events"]').attributes('style') || '').not.toContain('display: none') expect(wrapper.find('[data-test="save-config"]').exists()).toBe(false) - expect(wrapper.find('[data-test="events"]').exists()).toBe(true) - expect(wrapper.find('[data-test="pass-events-disabled-notice"]').exists()).toBe(true) await wrapper.get('[data-test="pass-events-disabled-notice"] button').trigger('click') expect(wrapper.get('[data-test="tab-config"]').attributes('aria-selected')).toBe('true') - - await wrapper.get('[data-test="tab-config"]').trigger('click') - await flushPromises() expect(wrapper.find('[data-test="save-config"]').exists()).toBe(true) expect(wrapper.get('[data-test="tab-panel-config"]').attributes('style') || '').not.toContain('display: none') }) @@ -115,6 +121,7 @@ describe('PromptAuditView', () => { it('requires confirmation for blocking and disables it when audit is turned off', async () => { const wrapper = mountView() await flushPromises() + await wrapper.get('[data-test="tab-config"]').trigger('click') await wrapper.get('[data-test="blocking-toggle"]').trigger('click') expect(wrapper.find('[data-test="confirm"]').exists()).toBe(true) await wrapper.get('[data-test="confirm-action"]').trigger('click') @@ -128,6 +135,7 @@ describe('PromptAuditView', () => { it('clears plaintext token state after a successful save', async () => { const wrapper = mountView() await flushPromises() + await wrapper.get('[data-test="tab-config"]').trigger('click') await wrapper.get('[data-test="inject-secret"]').trigger('click') expect(wrapper.text()).toContain('admin.promptAudit.saveBar.dirty') await wrapper.get('[data-test="save-config"]').trigger('click') @@ -141,22 +149,31 @@ describe('PromptAuditView', () => { it('reports real probe progress/results and invalidates filter confirmation when filters change', async () => { const wrapper = mountView() await flushPromises() + await wrapper.get('[data-test="tab-config"]').trigger('click') await wrapper.get('[data-test="probe"]').trigger('click') await flushPromises() expect(mocks.probeEndpoint).toHaveBeenCalledOnce() expect((wrapper.getComponent(EndpointStub).props('probeResults') as Record)).toHaveProperty('guard-1') + await wrapper.get('[data-test="tab-events"]').trigger('click') await wrapper.get('[data-test="preview"]').trigger('click') await flushPromises() - expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true) + expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true) + expect(mocks.previewDelete).not.toHaveBeenCalled() + await wrapper.get('[data-test="dialog-preview"]').trigger('click') + await flushPromises() + expect(mocks.previewDelete).toHaveBeenCalledOnce() + expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('2') await wrapper.get('[data-test="change-filter"]').trigger('click') await flushPromises() - expect(wrapper.find('[data-test="dialog"]').exists()).toBe(false) + expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(true) + expect(wrapper.get('[data-test="dialog-preview-state"]').text()).toBe('none') }) it('uses native labeled switches and a responsive fixed save surface', async () => { const wrapper = mountView() await flushPromises() + await wrapper.get('[data-test="tab-config"]').trigger('click') const switches = wrapper.findAll('[role="switch"]') expect(switches).toHaveLength(3) expect(switches.every((item) => Boolean(item.attributes('aria-label')))).toBe(true) @@ -181,11 +198,18 @@ describe('PromptAuditView', () => { await wrapper.get('[data-test="preview"]').trigger('click') await flushPromises() - await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click') + await wrapper.get('[data-test="dialog-preview"]').trigger('click') await flushPromises() - expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({ + expect(mocks.previewDelete).toHaveBeenCalledWith(expect.objectContaining({ start_at: '2026-07-15T00:00', end_at: '2026-07-16T00:00' })) + await wrapper.get('[data-test="dialog-confirm"]').trigger('click') + await flushPromises() + expect(mocks.deleteEventsByFilter).toHaveBeenCalledWith(expect.objectContaining({ + start_at: '2026-07-15T00:00', + end_at: '2026-07-16T00:00', + }), expect.objectContaining({ snapshot_max_id: 10, confirmation_token: 'opaque-confirmation', })) + expect(wrapper.find('[data-test="filter-delete-dialog"]').exists()).toBe(false) }) }) diff --git a/frontend/src/features/prompt-audit/__tests__/components.spec.ts b/frontend/src/features/prompt-audit/__tests__/components.spec.ts index 62c8a0a102..2bbeca9879 100644 --- a/frontend/src/features/prompt-audit/__tests__/components.spec.ts +++ b/frontend/src/features/prompt-audit/__tests__/components.spec.ts @@ -4,8 +4,10 @@ import { mount } from '@vue/test-utils' import EndpointPool from '../components/EndpointPool.vue' import PolicyPanel from '../components/PolicyPanel.vue' import EventWorkspace from '../components/EventWorkspace.vue' -import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent } from '../types' -import { emptyEventFilters, SCANNER_CATALOG } from '../viewModel' +import EventDetailDialog from '../components/EventDetailDialog.vue' +import FilterDeleteDialog from '../components/FilterDeleteDialog.vue' +import type { PromptAuditDraft, PromptAuditEndpointDraft, PromptAuditEvent, PromptEventFilters } from '../types' +import { emptyEventFilters, resolveDeleteRangeFilters, SCANNER_CATALOG } from '../viewModel' vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') @@ -58,7 +60,7 @@ describe('Prompt Audit components', () => { props: { draft, groups: [{ id: 1, name: 'Alpha', platform: 'openai', status: 'active' }, { id: 2, name: 'Beta', platform: 'claude', status: 'inactive' }] }, }) expect(wrapper.text()).toContain('99') - expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === scanner.label))).toHaveLength(9) + expect(wrapper.findAll('input[type="checkbox"]').filter((input) => SCANNER_CATALOG.some((scanner) => input.attributes('aria-label') === `admin.promptAudit.scanners.${scanner.id}`))).toHaveLength(9) await wrapper.get('[aria-label="admin.promptAudit.policy.searchGroups"]').setValue('Beta') expect(wrapper.text()).toContain('Beta') expect(wrapper.text()).not.toContain('Alpha') @@ -67,10 +69,10 @@ describe('Prompt Audit components', () => { expect(emitted.worker_count).toBe(6) }) - it('keeps identity fields separate, supports selection, and gates filter deletion on a time range', async () => { + it('keeps identity fields separate, supports selection, and opens filter deletion from the toolbar', async () => { const event: PromptAuditEvent = { id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block', categories: ['pii'], matched_scanners: ['pii'], scanner_scores: { pii: 1 }, scanner_evidence: { pii: 'redacted' }, scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1', policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 10, issue_summaries: [], created_at: '2026-07-16T00:00:00Z', - snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', prompt_length: 10, message_count: 1, stage: 'http' }, + snapshot: { request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted preview', full_prompt: 'full prompt text', prompt_length: 10, message_count: 1, stage: 'http' }, } const wrapper = mount(EventWorkspace, { props: { events: [event], total: 1, page: 1, pageSize: 20, filters: emptyEventFilters(), selectedIds: [], loading: false, error: '' }, @@ -79,11 +81,143 @@ describe('Prompt Audit components', () => { expect(wrapper.text()).toContain('alice') expect(wrapper.text()).toContain('alice@example.test') expect(wrapper.text()).toContain('alice-key') - expect(wrapper.get('[data-test="filter-delete"]').attributes()).toHaveProperty('disabled') - await wrapper.get('[aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-15T00:00') - await wrapper.get('[aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-16T00:00') + expect(wrapper.text()).toContain('admin.promptAudit.decisions.critical · admin.promptAudit.riskLevels.critical') + expect(wrapper.text()).toContain('admin.promptAudit.scanners.pii') expect(wrapper.get('[data-test="filter-delete"]').attributes()).not.toHaveProperty('disabled') + await wrapper.get('[data-test="filter-delete"]').trigger('click') + expect(wrapper.emitted('preview-delete')).toHaveLength(1) await wrapper.get('[aria-label="admin.promptAudit.events.selectEvent"]').setValue(true) expect(wrapper.emitted('selection')?.at(-1)?.[0]).toEqual([1]) }) + + it('resolves delete range presets to an epoch start and a cutoff end', () => { + const now = Date.parse('2026-07-17T12:00:00.000Z') + const sevenDays = resolveDeleteRangeFilters(emptyEventFilters(), '7d', now) + expect(sevenDays.start_at).toBe('1970-01-01T00:00:00.000Z') + expect(sevenDays.end_at).toBe('2026-07-10T12:00:00.000Z') + const all = resolveDeleteRangeFilters(emptyEventFilters(), 'all', now) + expect(all.start_at).toBe('1970-01-01T00:00:00.000Z') + expect(all.end_at).toBe('2026-07-17T12:00:00.000Z') + const customSource = { ...emptyEventFilters(), start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00' } + expect(resolveDeleteRangeFilters(customSource, 'custom', now)).toEqual(customSource) + }) + + it('drives filter deletion through presets, custom validation, preview, and confirm', async () => { + const wrapper = mount(FilterDeleteDialog, { + props: { show: true, initialFilters: emptyEventFilters(), preview: null, previewing: false, deleting: false }, + global: { stubs: { BaseDialog: DialogStub } }, + }) + expect(wrapper.get('[data-test="range-preset-7d"]').element.checked).toBe(true) + expect(wrapper.find('[data-test="custom-range"]').exists()).toBe(false) + expect(wrapper.get('[data-test="delete-preview-empty"]').exists()).toBeTruthy() + expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).toHaveProperty('disabled') + + await wrapper.get('[data-test="range-preset-30d"]').setValue() + expect(wrapper.emitted('criteria-change')?.length).toBeGreaterThan(0) + await wrapper.get('[data-test="delete-risk"]').setValue('high') + await wrapper.get('[data-test="run-delete-preview"]').trigger('click') + const presetPreview = wrapper.emitted('preview')?.at(-1)?.[0] as PromptEventFilters + expect(presetPreview.risk_level).toBe('high') + expect(presetPreview.start_at).toBe('1970-01-01T00:00:00.000Z') + expect(Date.now() - new Date(presetPreview.end_at).getTime()).toBeGreaterThanOrEqual(30 * 24 * 60 * 60 * 1000) + + await wrapper.get('[data-test="range-preset-custom"]').setValue() + expect(wrapper.find('[data-test="custom-range"]').exists()).toBe(true) + expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).toHaveProperty('disabled') + await wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.startAt"]').setValue('2026-07-01T00:00') + await wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.endAt"]').setValue('2026-07-02T00:00') + expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).not.toHaveProperty('disabled') + await wrapper.get('[data-test="run-delete-preview"]').trigger('click') + const customPreview = wrapper.emitted('preview')?.at(-1)?.[0] as PromptEventFilters + expect(customPreview.start_at).toBe('2026-07-01T00:00') + expect(customPreview.end_at).toBe('2026-07-02T00:00') + + await wrapper.setProps({ + preview: { matched_count: 3, filter_summary: {}, snapshot_max_id: 9, filter_hash: 'b'.repeat(64), confirmation_token: 'tok', expires_at: '2026-07-16T00:05:00Z' }, + }) + expect(wrapper.get('[data-test="delete-preview-result"]').text()).toContain('admin.promptAudit.events.filterDeleteCount') + expect(wrapper.get('[data-test="confirm-filter-delete"]').attributes()).not.toHaveProperty('disabled') + await wrapper.get('[data-test="confirm-filter-delete"]').trigger('click') + expect(wrapper.emitted('confirm')).toHaveLength(1) + }) + + it('inherits an explicit list-filter range as the custom preset', async () => { + const initialFilters = { ...emptyEventFilters(), start_at: '2026-07-01T00:00', end_at: '2026-07-02T00:00', decision: 'critical' } + const wrapper = mount(FilterDeleteDialog, { + props: { show: true, initialFilters, preview: null, previewing: false, deleting: false }, + global: { stubs: { BaseDialog: DialogStub } }, + }) + expect(wrapper.get('[data-test="range-preset-custom"]').element.checked).toBe(true) + expect(wrapper.get('[data-test="custom-range"] [aria-label="admin.promptAudit.events.startAt"]').element.value).toBe('2026-07-01T00:00') + expect(wrapper.get('[data-test="delete-decision"]').element.value).toBe('critical') + expect(wrapper.get('[data-test="run-delete-preview"]').attributes()).not.toHaveProperty('disabled') + }) + + it('shows the full unredacted prompt and structured guard return on the risks tab', async () => { + const event: PromptAuditEvent = { + id: 1, job_id: 1, decision: 'critical', risk_level: 'critical', action: 'Block', + categories: ['sexual_content_or_sexual_acts'], matched_scanners: ['sexual_content_or_sexual_acts'], + scanner_scores: { sexual_content_or_sexual_acts: 1 }, + scanner_evidence: { sexual_content_or_sexual_acts: 'Sexual Content or Sexual Acts' }, + scanner_backend: 'qwen3guard-openai', scanner_version: 'qwen3guard', guard_endpoint_id: 'guard-1', + policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 12, + issue_summaries: [{ + category: 'sexual_content_or_sexual_acts', scanner_id: 'sexual_content_or_sexual_acts', + title: '性内容或性行为', description: 'Sexual content or sexual acts', severity: 'critical', + severity_label: '严重', action: 'Block', action_label: '阻止', + code: 'prompt_audit_sexual_content_or_sexual_acts', score: 1, + evidence: 'Sexual Content or Sexual Acts', evidence_hash: 'abc', + }], + created_at: '2026-07-16T00:00:00Z', + snapshot: { + request_id: 'req-1', user_id: 1, username: 'alice', user_email: 'alice@example.test', + api_key_id: 2, api_key_name: 'alice-key', group_id: 3, group_name: 'Alpha', provider: 'openai', + endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', + prompt_hash: 'a'.repeat(64), redacted_preview: 'redacted prompt body', full_prompt: 'complete unmasked prompt body', prompt_length: 20, + message_count: 1, stage: 'http', + }, + } + const wrapper = mount(EventDetailDialog, { + props: { show: true, event, loading: false }, + global: { stubs: { BaseDialog: DialogStub } }, + }) + const panel = wrapper.get('[data-test="event-detail-tab-panel"]') + expect(panel.classes()).toContain('h-[min(62vh,36rem)]') + expect(panel.classes()).toContain('overflow-y-auto') + + const riskTab = wrapper.findAll('[role="tab"]').find((tab) => tab.text().includes('admin.promptAudit.events.tabs.risks')) + expect(riskTab).toBeTruthy() + await riskTab!.trigger('click') + expect(wrapper.get('[data-test="event-detail-tab-panel"]').classes()).toContain('h-[min(62vh,36rem)]') + expect(wrapper.get('[data-test="risk-prompt-preview"]').text()).toContain('complete unmasked prompt body') + expect(wrapper.get('[data-test="risk-prompt-preview"]').text()).not.toContain('redacted prompt body') + expect(wrapper.get('[data-test="risk-prompt-full"]').classes()).toContain('overflow-auto') + expect(wrapper.get('[data-test="risk-guard-return"]').text()).toContain('"decision": "admin.promptAudit.decisions.critical"') + expect(wrapper.get('[data-test="risk-guard-return"]').text()).toContain('admin.promptAudit.scanners.sexual_content_or_sexual_acts') + expect(wrapper.get('[data-test="risk-issue"]').text()).toContain('admin.promptAudit.scanners.sexual_content_or_sexual_acts') + }) + + it('falls back to the redacted preview for events stored before full prompts were kept', async () => { + const event: PromptAuditEvent = { + id: 2, job_id: 2, decision: 'flag', risk_level: 'medium', action: 'Warn', + categories: ['pii'], matched_scanners: ['pii'], scanner_scores: {}, scanner_evidence: {}, + scanner_backend: 'qwen3guard-openai', scanner_version: '1', guard_endpoint_id: 'guard-1', + policy_id: 'priority', policy_version: 1, config_version: 1, chunk_total: 1, latency_ms: 5, + issue_summaries: [], created_at: '2026-07-16T00:00:00Z', + snapshot: { + request_id: 'req-2', user_id: 1, username: 'bob', user_email: '', api_key_id: 2, + api_key_name: 'bob-key', group_id: 3, group_name: 'Alpha', provider: 'openai', + endpoint: '/v1/chat/completions', protocol: 'openai_chat', model: 'gpt-test', + prompt_hash: 'b'.repeat(64), redacted_preview: 'legacy redacted preview', full_prompt: '', prompt_length: 20, + message_count: 1, stage: 'http', + }, + } + const wrapper = mount(EventDetailDialog, { + props: { show: true, event, loading: false }, + global: { stubs: { BaseDialog: DialogStub } }, + }) + const riskTab = wrapper.findAll('[role="tab"]').find((tab) => tab.text().includes('admin.promptAudit.events.tabs.risks')) + await riskTab!.trigger('click') + expect(wrapper.get('[data-test="risk-prompt-full"]').text()).toContain('legacy redacted preview') + }) }) diff --git a/frontend/src/features/prompt-audit/components/EventDetailDialog.vue b/frontend/src/features/prompt-audit/components/EventDetailDialog.vue index f977379162..13da54ce6c 100644 --- a/frontend/src/features/prompt-audit/components/EventDetailDialog.vue +++ b/frontend/src/features/prompt-audit/components/EventDetailDialog.vue @@ -1,53 +1,76 @@ @@ -56,7 +79,8 @@ import { ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import BaseDialog from '@/components/common/BaseDialog.vue' -import type { PromptAuditEvent } from '../types' +import type { PromptAuditEvent, PromptIssueSummary } from '../types' +import { SCANNER_CATALOG } from '../viewModel' const props = defineProps<{ show: boolean; event: PromptAuditEvent | null; loading: boolean }>() defineEmits<{ (event: 'close'): void }>() @@ -64,4 +88,69 @@ const { t } = useI18n() const tabs = ['summary', 'risks', 'technical'] as const const activeTab = ref<(typeof tabs)[number]>('summary') watch(() => props.event?.id, () => { activeTab.value = 'summary' }) + +const DECISIONS = new Set(['pass', 'flag', 'critical']) +const ACTIONS = new Set(['Allow', 'Warn', 'Block']) +const RISK_LEVELS = new Set(['low', 'medium', 'high', 'critical']) + +function displayPrompt(event: PromptAuditEvent): string { + return event.snapshot.full_prompt || event.snapshot.redacted_preview || '—' +} + +function formatDecisionAction(decision: string, action: string): string { + const decisionLabel = DECISIONS.has(decision) ? t(`admin.promptAudit.decisions.${decision}`) : decision + const actionLabel = ACTIONS.has(action) ? t(`admin.promptAudit.actions.${action}`) : action + return `${decisionLabel} · ${actionLabel}` +} +function translateCategory(category: string): string { + return SCANNER_CATALOG.some((scanner) => scanner.id === category) + ? t(`admin.promptAudit.scanners.${category}`) + : category +} +function formatCategories(categories: string[]): string { + if (!categories.length) return '—' + return categories.map(translateCategory).join(', ') +} +function translateEvidence(value: string): string { + const byId = SCANNER_CATALOG.find((scanner) => scanner.id === value) + if (byId) return t(`admin.promptAudit.scanners.${byId.id}`) + const byLabel = SCANNER_CATALOG.find((scanner) => scanner.label === value) + if (byLabel) return t(`admin.promptAudit.scanners.${byLabel.id}`) + return value +} +function formatGuardReturn(event: PromptAuditEvent): string { + const evidence: Record = {} + for (const [key, value] of Object.entries(event.scanner_evidence || {})) { + evidence[key] = translateEvidence(value) + } + return JSON.stringify({ + decision: DECISIONS.has(event.decision) ? t(`admin.promptAudit.decisions.${event.decision}`) : event.decision, + risk_level: RISK_LEVELS.has(event.risk_level) ? t(`admin.promptAudit.riskLevels.${event.risk_level}`) : event.risk_level, + action: ACTIONS.has(event.action) ? t(`admin.promptAudit.actions.${event.action}`) : event.action, + categories: event.categories.map(translateCategory), + matched_scanners: event.matched_scanners.map(translateCategory), + scanner_scores: event.scanner_scores, + scanner_evidence: evidence, + scanner_backend: event.scanner_backend, + scanner_version: event.scanner_version, + guard_endpoint_id: event.guard_endpoint_id, + chunk_total: event.chunk_total, + latency_ms: event.latency_ms, + }, null, 2) +} +function issueTitle(issue: PromptIssueSummary): string { + return translateCategory(issue.category || issue.scanner_id) || issue.title +} +function issueDescription(issue: PromptIssueSummary): string { + const category = issue.category || issue.scanner_id + const key = `admin.promptAudit.scannerDescriptions.${category}` + const label = t(key) + return label === key ? issue.description : label +} +function issueSeverity(issue: PromptIssueSummary): string { + return RISK_LEVELS.has(issue.severity) ? t(`admin.promptAudit.riskLevels.${issue.severity}`) : issue.severity_label || issue.severity +} +function issueAction(issue: PromptIssueSummary): string { + return ACTIONS.has(issue.action) ? t(`admin.promptAudit.actions.${issue.action}`) : issue.action_label || issue.action +} diff --git a/frontend/src/features/prompt-audit/components/EventWorkspace.vue b/frontend/src/features/prompt-audit/components/EventWorkspace.vue index eb692b70ce..551454dd36 100644 --- a/frontend/src/features/prompt-audit/components/EventWorkspace.vue +++ b/frontend/src/features/prompt-audit/components/EventWorkspace.vue @@ -9,7 +9,7 @@ -
@@ -19,21 +19,28 @@ - - + +