Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 179 additions & 16 deletions admin/prompt_intelligence_ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,36 @@ type promptIntelligenceAIAnalysisMetadata struct {
APIKeyName string `json:"api_key_name,omitempty"`
ReviewSystemPromptHash string `json:"review_system_prompt_hash"`
UpstreamEvidenceCount int `json:"upstream_evidence_count"`
LearnableEvidenceCount int `json:"learnable_evidence_count"`
Result promptIntelligenceAIDecision `json:"result"`
RuleValidationError string `json:"rule_validation_error,omitempty"`
IdentityValidationError string `json:"identity_validation_error,omitempty"`
RawOutputPreview string `json:"raw_output_preview,omitempty"`
}

type promptIntelligenceLearningContext struct {
Origin string `json:"origin"`
Role string `json:"role,omitempty"`
Text string `json:"text"`
Linked bool `json:"linked,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Trust string `json:"trust,omitempty"`
}

type promptIntelligenceLearningEvidence struct {
Version int `json:"version"`
Quality string `json:"quality"`
PromptText string `json:"prompt_text,omitempty"`
Context []promptIntelligenceLearningContext `json:"context,omitempty"`
UpstreamError string `json:"upstream_error,omitempty"`
Transport string `json:"transport,omitempty"`
StatusCode int `json:"status_code,omitempty"`
AttemptIndex int `json:"attempt_index,omitempty"`
ReviewModel string `json:"review_model,omitempty"`
ReviewFlagged bool `json:"review_flagged,omitempty"`
ReviewError string `json:"review_error,omitempty"`
}

type promptIdentityUpdateResult struct {
Mode string `json:"mode"`
Suggested bool `json:"suggested"`
Expand Down Expand Up @@ -271,12 +295,18 @@ func (h *Handler) AnalyzePromptIntelligenceCandidate(c *gin.Context) {
writeError(c, http.StatusConflict, "该候选没有可供分析的上游 CY 证据")
return
}
learnableEvidence := selectPromptIntelligenceLearnableEvidence(upstreamEvidence, 20)
if len(learnableEvidence) == 0 {
writeError(c, http.StatusConflict, "该候选只有证据不足的 CY 记录,尚未提取到可学习的 Prompt 或关联上下文;已停止调用外部模型")
return
}
learnableEvidenceCount := countPromptIntelligenceLearnableEvidence(upstreamEvidence)

cfg := h.store.GetPromptFilterConfig()
reviewCfg := promptfilter.NormalizeReviewConfig(cfg.Review)
reviewSystemPrompt := promptfilter.NormalizeReviewAdapterConfig(reviewCfg.Adapter).SystemPrompt
analysisSystemPrompt := buildPromptIntelligenceAIIdentity(reviewSystemPrompt)
analysisInput := buildPromptIntelligenceAIEvidenceInput(candidate, upstreamEvidence)
analysisInput := buildPromptIntelligenceAIEvidenceInput(candidate, learnableEvidence)
rawOutput, attribution, err := h.callPromptIntelligenceAI(c.Request.Context(), request, reviewCfg, analysisSystemPrompt, analysisInput)
if err != nil {
writeError(c, http.StatusBadGateway, err.Error())
Expand All @@ -287,7 +317,7 @@ func (h *Handler) AnalyzePromptIntelligenceCandidate(c *gin.Context) {
writeError(c, http.StatusBadGateway, err.Error())
return
}
coverage := summarizePromptIntelligenceCoverage(upstreamEvidence)
coverage := summarizePromptIntelligenceCoverage(learnableEvidence)
if err := validatePromptIntelligenceAICoverageDecision(decision, coverage); err != nil {
writeError(c, http.StatusBadGateway, err.Error())
return
Expand All @@ -297,7 +327,7 @@ func (h *Handler) AnalyzePromptIntelligenceCandidate(c *gin.Context) {
Version: 1, Provider: attribution.Provider, Model: attribution.Model,
APIKeyID: attribution.APIKeyID, APIKeyName: attribution.APIKeyName,
ReviewSystemPromptHash: promptfilter.StableEvidenceFingerprint("review-system-prompt", reviewSystemPrompt),
UpstreamEvidenceCount: len(upstreamEvidence), Result: decision,
UpstreamEvidenceCount: len(upstreamEvidence), LearnableEvidenceCount: learnableEvidenceCount, Result: decision,
RawOutputPreview: promptfilter.RedactedPreview(promptfilter.RedactSensitive(rawOutput), 4000),
}
if decision.Rule != nil {
Expand Down Expand Up @@ -341,9 +371,10 @@ func (h *Handler) AnalyzePromptIntelligenceCandidate(c *gin.Context) {
if metadata.IdentityValidationError != "" {
response.IdentityUpdate.BlockReason = metadata.IdentityValidationError
} else if request.IdentityUpdateMode == promptIdentityUpdateModeGuardedAuto {
response.IdentityUpdate.Eligible = decision.Confidence >= promptIdentityAutoMinConfidence && len(upstreamEvidence) >= promptIdentityAutoMinUpstreamEvidence
directEvidenceCount := countPromptIntelligenceDirectEvidence(upstreamEvidence)
response.IdentityUpdate.Eligible = decision.Confidence >= promptIdentityAutoMinConfidence && directEvidenceCount >= promptIdentityAutoMinUpstreamEvidence
if !response.IdentityUpdate.Eligible {
response.IdentityUpdate.BlockReason = fmt.Sprintf("受控自动应用要求置信度至少 %.2f 且同类上游证据至少 %d 条", promptIdentityAutoMinConfidence, promptIdentityAutoMinUpstreamEvidence)
response.IdentityUpdate.BlockReason = fmt.Sprintf("受控自动应用要求置信度至少 %.2f 且同类完整 Prompt 证据至少 %d 条", promptIdentityAutoMinConfidence, promptIdentityAutoMinUpstreamEvidence)
Comment on lines +374 to +377

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Count direct evidence over the deduplicated learnable set.

countPromptIntelligenceDirectEvidence runs over upstreamEvidence, which is the raw list. selectPromptIntelligenceLearnableEvidence deduplicates by prompt fingerprint precisely because repeated identical prompts are not independent corroboration. As written, N retries of the same request satisfy promptIdentityAutoMinUpstreamEvidence and unlock automatic identity-prompt mutation. The block message also states "同类完整 Prompt 证据", which implies distinct records.

A second gap exists in the same expression. promptIntelligenceLearningEvidenceFromMetadata assigns quality legacy_preview when only SamplePreview is available. countPromptIntelligenceDirectEvidence accepts that quality, so a truncated legacy preview counts as complete direct Prompt evidence.

The unit test at admin/prompt_intelligence_ai_test.go line 106 counts direct evidence over the selected set, which confirms the intended input.

🔒️ Proposed fix
-			directEvidenceCount := countPromptIntelligenceDirectEvidence(upstreamEvidence)
+			directEvidenceCount := countPromptIntelligenceDirectEvidence(learnableEvidence)

Also restrict the direct-evidence quality set:

func countPromptIntelligenceDirectEvidence(evidence []*database.PromptRuleCandidateEvidence) int {
	count := 0
	for _, row := range evidence {
		learning := promptIntelligenceLearningEvidenceFromMetadata(row.MetadataJSON, row.SamplePreview)
		if strings.TrimSpace(learning.PromptText) != "" && learning.Quality == "complete" {
			count++
		}
	}
	return count
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
directEvidenceCount := countPromptIntelligenceDirectEvidence(upstreamEvidence)
response.IdentityUpdate.Eligible = decision.Confidence >= promptIdentityAutoMinConfidence && directEvidenceCount >= promptIdentityAutoMinUpstreamEvidence
if !response.IdentityUpdate.Eligible {
response.IdentityUpdate.BlockReason = fmt.Sprintf("受控自动应用要求置信度至少 %.2f 且同类上游证据至少 %d 条", promptIdentityAutoMinConfidence, promptIdentityAutoMinUpstreamEvidence)
response.IdentityUpdate.BlockReason = fmt.Sprintf("受控自动应用要求置信度至少 %.2f 且同类完整 Prompt 证据至少 %d 条", promptIdentityAutoMinConfidence, promptIdentityAutoMinUpstreamEvidence)
directEvidenceCount := countPromptIntelligenceDirectEvidence(learnableEvidence)
response.IdentityUpdate.Eligible = decision.Confidence >= promptIdentityAutoMinConfidence && directEvidenceCount >= promptIdentityAutoMinUpstreamEvidence
if !response.IdentityUpdate.Eligible {
response.IdentityUpdate.BlockReason = fmt.Sprintf("受控自动应用要求置信度至少 %.2f 且同类完整 Prompt 证据至少 %d 条", promptIdentityAutoMinConfidence, promptIdentityAutoMinUpstreamEvidence)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@admin/prompt_intelligence_ai.go` around lines 374 - 377, Update the
eligibility calculation around countPromptIntelligenceDirectEvidence to count
only the deduplicated result from
selectPromptIntelligenceLearnableEvidence(upstreamEvidence), not the raw
upstreamEvidence list. Also update countPromptIntelligenceDirectEvidence to
require non-empty PromptText and learning.Quality == "complete", excluding
legacy_preview evidence from the direct-evidence threshold.

} else {
applied, applyErr := h.applyPromptIntelligenceIdentityPatch(c.Request.Context(), candidateID, analysisEvidence.ID, "guarded_auto")
if applyErr != nil {
Expand Down Expand Up @@ -446,13 +477,23 @@ unsafe to generalize.`

func buildPromptIntelligenceAIEvidenceInput(candidate *database.PromptRuleCandidate, evidence []*database.PromptRuleCandidateEvidence) string {
type safeEvidence struct {
SourceKind string `json:"source_kind"`
SamplePreview string `json:"sample_preview"`
Protocol string `json:"protocol,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ObservedAt time.Time `json:"observed_at"`
Context map[string]any `json:"context,omitempty"`
SourceKind string `json:"source_kind"`
EvidenceQuality string `json:"evidence_quality"`
SamplePreview string `json:"sample_preview,omitempty"`
PromptText string `json:"prompt_text,omitempty"`
RelatedContext []promptIntelligenceLearningContext `json:"related_context,omitempty"`
UpstreamError string `json:"upstream_error,omitempty"`
Transport string `json:"transport,omitempty"`
StatusCode int `json:"status_code,omitempty"`
AttemptIndex int `json:"attempt_index,omitempty"`
ReviewModel string `json:"review_model,omitempty"`
ReviewFlagged bool `json:"review_flagged,omitempty"`
ReviewError string `json:"review_error,omitempty"`
Protocol string `json:"protocol,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
ObservedAt time.Time `json:"observed_at"`
DecisionContext map[string]any `json:"decision_context,omitempty"`
}
items := make([]safeEvidence, 0, len(evidence))
for _, row := range evidence {
Expand All @@ -468,22 +509,144 @@ func buildPromptIntelligenceAIEvidenceInput(candidate *database.PromptRuleCandid
}
}
}
learning := promptIntelligenceLearningEvidenceFromMetadata(row.MetadataJSON, row.SamplePreview)
items = append(items, safeEvidence{
SourceKind: row.SourceKind, SamplePreview: promptfilter.RedactedPreview(row.SamplePreview, 2000),
Protocol: row.Protocol, Provider: row.Provider, Model: row.Model, ObservedAt: row.ObservedAt,
Context: contextFields,
SourceKind: row.SourceKind, EvidenceQuality: learning.Quality,
SamplePreview: promptfilter.RedactedPreview(row.SamplePreview, 2000),
PromptText: promptfilter.RedactedPreview(learning.PromptText, 8000),
RelatedContext: boundPromptIntelligenceLearningContext(learning.Context, 6000),
UpstreamError: promptfilter.RedactedPreview(learning.UpstreamError, 2000),
Transport: learning.Transport, StatusCode: learning.StatusCode, AttemptIndex: learning.AttemptIndex,
ReviewModel: learning.ReviewModel, ReviewFlagged: learning.ReviewFlagged,
ReviewError: promptfilter.RedactedPreview(learning.ReviewError, 500),
Protocol: row.Protocol, Provider: row.Provider, Model: row.Model, ObservedAt: row.ObservedAt,
DecisionContext: contextFields,
})
}
payload := map[string]any{
"candidate_id": candidate.ID, "fingerprint": candidate.Fingerprint,
"evidence_count": candidate.EvidenceCount, "sample_preview": promptfilter.RedactedPreview(candidate.SamplePreview, 2000),
"evidence_count": candidate.EvidenceCount, "learnable_evidence_count": len(evidence),
"sample_preview": promptfilter.RedactedPreview(candidate.SamplePreview, 2000),
"coverage_summary": summarizePromptIntelligenceCoverage(evidence),
"evidence": items,
}
encoded, _ := json.Marshal(payload)
return "Analyze the following <user_input> evidence data.\n<user_input>\n" + string(encoded) + "\n</user_input>"
}

func promptIntelligenceLearningEvidenceFromMetadata(raw, fallbackPreview string) promptIntelligenceLearningEvidence {
result := promptIntelligenceLearningEvidence{}
var metadata struct {
EvidenceQuality string `json:"evidence_quality"`
Learning promptIntelligenceLearningEvidence `json:"learning_evidence"`
}
if json.Unmarshal([]byte(raw), &metadata) == nil {
result = metadata.Learning
if result.Quality == "" {
result.Quality = metadata.EvidenceQuality
}
}
if strings.TrimSpace(result.PromptText) == "" && strings.TrimSpace(fallbackPreview) != "" {
result.PromptText = fallbackPreview
if result.Quality == "" || result.Quality == "insufficient" {
result.Quality = "legacy_preview"
}
}
if result.Quality == "" {
result.Quality = "insufficient"
}
return result
}

func selectPromptIntelligenceLearnableEvidence(evidence []*database.PromptRuleCandidateEvidence, limit int) []*database.PromptRuleCandidateEvidence {
if limit <= 0 {
limit = 20
}
selected := make([]*database.PromptRuleCandidateEvidence, 0, min(limit, len(evidence)))
seen := make(map[string]struct{}, limit)
for _, row := range evidence {
learning := promptIntelligenceLearningEvidenceFromMetadata(row.MetadataJSON, row.SamplePreview)
text := strings.TrimSpace(learning.PromptText)
if text == "" {
parts := make([]string, 0, len(learning.Context))
for _, segment := range learning.Context {
if value := strings.TrimSpace(segment.Text); value != "" {
parts = append(parts, segment.Origin+": "+value)
}
}
text = strings.Join(parts, "\n")
}
if text == "" || learning.Quality == "insufficient" {
continue
}
fingerprint := promptfilter.PromptEvidenceFingerprint(text)
if fingerprint == "" {
fingerprint = row.SourceRefHash
}
if _, exists := seen[fingerprint]; exists {
continue
}
seen[fingerprint] = struct{}{}
selected = append(selected, row)
if len(selected) >= limit {
break
}
}
return selected
}

func countPromptIntelligenceDirectEvidence(evidence []*database.PromptRuleCandidateEvidence) int {
count := 0
for _, row := range evidence {
learning := promptIntelligenceLearningEvidenceFromMetadata(row.MetadataJSON, row.SamplePreview)
if strings.TrimSpace(learning.PromptText) != "" && learning.Quality != "context_only" && learning.Quality != "insufficient" {
count++
}
}
return count
}

func countPromptIntelligenceLearnableEvidence(evidence []*database.PromptRuleCandidateEvidence) int {
count := 0
for _, row := range evidence {
learning := promptIntelligenceLearningEvidenceFromMetadata(row.MetadataJSON, row.SamplePreview)
if learning.Quality == "insufficient" {
continue
}
if strings.TrimSpace(learning.PromptText) != "" {
count++
continue
}
for _, context := range learning.Context {
if strings.TrimSpace(context.Text) != "" {
count++
break
}
}
}
return count
}

func boundPromptIntelligenceLearningContext(contexts []promptIntelligenceLearningContext, maxRunes int) []promptIntelligenceLearningContext {
if maxRunes <= 0 {
return nil
}
result := make([]promptIntelligenceLearningContext, 0, min(len(contexts), 8))
remaining := maxRunes
for _, context := range contexts {
if remaining <= 0 || len(result) >= 8 {
break
}
context.Text = promptfilter.RedactedPreview(context.Text, remaining)
if strings.TrimSpace(context.Text) == "" {
continue
}
remaining -= len([]rune(context.Text))
result = append(result, context)
}
return result
}

func summarizePromptIntelligenceCoverage(evidence []*database.PromptRuleCandidateEvidence) promptIntelligenceCoverageSummary {
summary := promptIntelligenceCoverageSummary{EffectiveCoverage: "unknown", UpstreamEvidence: len(evidence)}
for _, row := range evidence {
Expand Down
48 changes: 48 additions & 0 deletions admin/prompt_intelligence_ai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,54 @@ func TestPromptIntelligenceCoverageAllowsNoChangeWhenEveryCYWasBlocked(t *testin
}
}

func TestPromptIntelligenceLearnableEvidenceSelectionRejectsInsufficientAndDeduplicates(t *testing.T) {
insufficient := []*database.PromptRuleCandidateEvidence{
{SourceRefHash: "one", MetadataJSON: `{"evidence_quality":"insufficient","learning_evidence":{"version":1,"quality":"insufficient"}}`},
{SourceRefHash: "two", MetadataJSON: `{"evidence_quality":"insufficient","learning_evidence":{"version":1,"quality":"insufficient"}}`},
}
if selected := selectPromptIntelligenceLearnableEvidence(insufficient, 20); len(selected) != 0 {
t.Fatalf("insufficient evidence selected for AI: %#v", selected)
}
evidence := []*database.PromptRuleCandidateEvidence{
{SourceRefHash: "one", MetadataJSON: `{"evidence_quality":"complete","learning_evidence":{"version":1,"quality":"complete","prompt_text":"same request"}}`},
{SourceRefHash: "two", MetadataJSON: `{"evidence_quality":"complete","learning_evidence":{"version":1,"quality":"complete","prompt_text":"same request"}}`},
{SourceRefHash: "three", MetadataJSON: `{"evidence_quality":"context_only","learning_evidence":{"version":1,"quality":"context_only","context":[{"origin":"history","text":"linked context"}]}}`},
}
selected := selectPromptIntelligenceLearnableEvidence(evidence, 20)
if len(selected) != 2 {
t.Fatalf("representative evidence len=%d want=2", len(selected))
}
if direct := countPromptIntelligenceDirectEvidence(selected); direct != 1 {
t.Fatalf("direct evidence count=%d want=1", direct)
}
}

func TestPromptIntelligenceEvidenceInputIncludesDurableLearningBundle(t *testing.T) {
evidence := []*database.PromptRuleCandidateEvidence{{
SourceKind: database.PromptRuleCandidateSourceUpstreamCyberPolicy,
SamplePreview: "preview",
MetadataJSON: `{
"local_action":"allow","local_outcome":"no_hit","local_comparison":"confirmed_miss",
"evidence_quality":"complete","learning_evidence":{
"version":1,"quality":"complete","prompt_text":"full request Authorization: Bearer secret-token",
"context":[{"origin":"history","text":"linked context"}],
"upstream_error":"cyber_policy details","transport":"sse","status_code":400,"attempt_index":2,
"review_model":"deepseek-test","review_flagged":false,"review_error":"timeout"
}
}`,
Protocol: "responses", Provider: "openai", Model: "gpt-5.6-sol", ObservedAt: time.Now(),
}}
input := buildPromptIntelligenceAIEvidenceInput(&database.PromptRuleCandidate{ID: 7, EvidenceCount: 1}, evidence)
for _, expected := range []string{"full request", "linked context", "cyber_policy details", "deepseek-test", `"status_code":400`, `"learnable_evidence_count":1`} {
if !strings.Contains(input, expected) {
t.Fatalf("AI evidence input missing %q: %s", expected, input)
}
}
if strings.Contains(input, "secret-token") || !strings.Contains(input, "[REDACTED]") {
t.Fatalf("AI evidence input was not redacted: %s", input)
}
}

func TestPromptIntelligenceReviewProviderUsesBoundedParallelKeys(t *testing.T) {
var active atomic.Int32
var maximum atomic.Int32
Expand Down
Loading
Loading