129 lines
3.2 KiB
Go
129 lines
3.2 KiB
Go
// ©AngelaMos | 2026
|
|
// prompt.go
|
|
|
|
package ai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
systemPrompt = `You are a senior security-content strategist helping a creator turn cybersecurity news into content. You are given a cluster of related news articles (one story covered by one or more outlets), optionally with referenced CVEs and their exploit signals. Produce content ideation for the story.
|
|
|
|
Respond with a SINGLE JSON object and NOTHING else. No prose, no markdown, no code fences. The object has exactly these keys:
|
|
"summary": a 2-3 sentence plain-language summary of the story.
|
|
"why": one paragraph on why it matters to a security audience (impact, who is affected, exploitation).
|
|
"angles": an array of 3 to 5 distinct content angles or hooks a creator could lead with.
|
|
"format": the single best-fit format, one of: "blog", "newsletter", "video".
|
|
|
|
Ground every claim in the provided material. Do not invent CVEs, vendors, or facts not present in the input.`
|
|
|
|
jsonObjectOpen byte = '{'
|
|
jsonObjectClose byte = '}'
|
|
)
|
|
|
|
func buildPrompt(req IdeationRequest) (string, string) {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Story cluster: %d article(s) across %d outlet(s), spanning ~%dh.\n\n", req.ClusterSize, len(req.Sources), req.SpanHours)
|
|
if len(req.Sources) > 0 {
|
|
fmt.Fprintf(&b, "Outlets: %s\n\n", strings.Join(req.Sources, ", "))
|
|
}
|
|
b.WriteString("Headlines:\n")
|
|
for _, t := range req.Titles {
|
|
fmt.Fprintf(&b, "- %s\n", t)
|
|
}
|
|
if len(req.CVEs) > 0 {
|
|
b.WriteString("\nReferenced CVEs:\n")
|
|
for _, c := range req.CVEs {
|
|
b.WriteString("- " + c.ID)
|
|
if c.CVSS != nil {
|
|
fmt.Fprintf(&b, " CVSS %.1f", *c.CVSS)
|
|
}
|
|
if c.KEV {
|
|
b.WriteString(" [KEV: known exploited]")
|
|
}
|
|
if c.EPSS != nil {
|
|
fmt.Fprintf(&b, " EPSS %.2f", *c.EPSS)
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
return systemPrompt, b.String()
|
|
}
|
|
|
|
func parseResult(text string) (IdeationResult, error) {
|
|
for _, obj := range jsonObjectCandidates(text) {
|
|
var res IdeationResult
|
|
if err := json.Unmarshal([]byte(obj), &res); err != nil {
|
|
continue
|
|
}
|
|
res.Format = normalizeFormat(res.Format)
|
|
if strings.TrimSpace(res.Summary) != "" && len(res.Angles) > 0 {
|
|
return res, nil
|
|
}
|
|
}
|
|
return IdeationResult{}, fmt.Errorf("ai: no usable JSON ideation object in model output")
|
|
}
|
|
|
|
func jsonObjectCandidates(text string) []string {
|
|
var out []string
|
|
for i := 0; i < len(text); {
|
|
if text[i] != jsonObjectOpen {
|
|
i++
|
|
continue
|
|
}
|
|
end := balancedObjectEnd(text, i)
|
|
if end < 0 {
|
|
break
|
|
}
|
|
out = append(out, text[i:end+1])
|
|
i = end + 1
|
|
}
|
|
return out
|
|
}
|
|
|
|
func balancedObjectEnd(text string, start int) int {
|
|
depth := 0
|
|
inStr := false
|
|
esc := false
|
|
for i := start; i < len(text); i++ {
|
|
ch := text[i]
|
|
if inStr {
|
|
switch {
|
|
case esc:
|
|
esc = false
|
|
case ch == '\\':
|
|
esc = true
|
|
case ch == '"':
|
|
inStr = false
|
|
}
|
|
continue
|
|
}
|
|
switch ch {
|
|
case '"':
|
|
inStr = true
|
|
case jsonObjectOpen:
|
|
depth++
|
|
case jsonObjectClose:
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func normalizeFormat(f string) string {
|
|
switch strings.ToLower(strings.TrimSpace(f)) {
|
|
case FormatNewsletter:
|
|
return FormatNewsletter
|
|
case FormatVideo:
|
|
return FormatVideo
|
|
default:
|
|
return FormatBlog
|
|
}
|
|
}
|