Files
2026-07-30 18:51:25 +02:00

269 lines
7.6 KiB
Go

package server
import (
"bytes"
"encoding/xml"
"html"
"regexp"
"strings"
"time"
"gitea.alexandru.macocian.me/amacocian/jarvis/internal/store"
)
// Atom document types (RFC 4287). Hand-rolled to avoid a feed dependency.
type atomFeed struct {
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Links []atomLink `xml:"link"`
Author atomAuthor `xml:"author"`
Entries []atomEntry `xml:"entry"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
}
type atomAuthor struct {
Name string `xml:"name"`
}
type atomEntry struct {
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Links []atomLink `xml:"link"`
Content atomContent `xml:"content"`
}
type atomContent struct {
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
// renderAtom serializes entries into an Atom 1.0 document.
func renderAtom(cfg Config, entries []store.Entry) ([]byte, error) {
feedID := cfg.FeedID
if feedID == "" {
feedID = strings.TrimRight(cfg.BaseURL, "/") + "/feed.atom"
}
title := cfg.FeedTitle
if title == "" {
title = "jarvis alerts"
}
updated := time.Now().UTC()
if len(entries) > 0 {
updated = entries[0].CreatedAt.UTC()
}
f := atomFeed{
Title: title,
ID: feedID,
Updated: updated.Format(time.RFC3339),
Links: []atomLink{
{Href: feedID, Rel: "self", Type: "application/atom+xml"},
},
Author: atomAuthor{Name: "jarvis"},
}
for _, e := range entries {
f.Entries = append(f.Entries, atomEntry{
Title: e.Title,
ID: "urn:jarvis:entry:" + e.ID,
Updated: e.CreatedAt.UTC().Format(time.RFC3339),
Links: []atomLink{
{Href: feedID + "#" + e.ID, Rel: "alternate", Type: "text/html"},
},
Content: atomContent{Type: "html", Body: entryHTML(e)},
})
}
var buf bytes.Buffer
buf.WriteString(xml.Header)
enc := xml.NewEncoder(&buf)
enc.Indent("", " ")
if err := enc.Encode(f); err != nil {
return nil, err
}
buf.WriteByte('\n')
return buf.Bytes(), nil
}
// entryHTML builds the (escaped) HTML body of a feed entry. The triage
// verdict/confidence are surfaced up top so a reader can decide act/watch/ignore
// at a glance, followed by the structured triage report. The raw agent
// investigation transcript is tucked into a collapsible <details> block below
// as supporting evidence. Everything is escaped so the reader can safely
// display it.
func entryHTML(e store.Entry) string {
var b strings.Builder
if e.Verdict != "" {
b.WriteString("<p><strong>Verdict: ")
b.WriteString(html.EscapeString(strings.ToUpper(e.Verdict)))
b.WriteString("</strong>")
if e.Confidence != "" {
b.WriteString(" <em>(confidence: ")
b.WriteString(html.EscapeString(e.Confidence))
b.WriteString(")</em>")
}
b.WriteString("</p>")
}
b.WriteString("<p><strong>")
b.WriteString(html.EscapeString(e.Summary))
b.WriteString("</strong></p>")
meta := []string{}
if e.Host != "" {
meta = append(meta, "host: "+html.EscapeString(e.Host))
}
if e.Severity != "" {
meta = append(meta, "severity: "+html.EscapeString(e.Severity))
}
if e.Status != "" {
meta = append(meta, "status: "+html.EscapeString(e.Status))
}
if len(meta) > 0 {
b.WriteString("<p><em>")
b.WriteString(strings.Join(meta, " · "))
b.WriteString("</em></p>")
}
triage, transcript := splitDiagnostic(e.Diagnostic)
b.WriteString(markdownToHTML(triage))
if transcript != "" {
// <details>/<summary> collapse in capable readers; miniflux 2.3.1
// strips those tags but keeps the children, so it degrades to a
// labelled <pre> block below the triage.
b.WriteString("<details><summary>Investigation transcript</summary><pre>")
b.WriteString(html.EscapeString(transcript))
b.WriteString("</pre></details>")
}
return b.String()
}
var (
// triageStartRe marks where the structured triage begins: the first
// Verdict line. Everything before it is the agent's investigation
// transcript (tool calls, intermediate reasoning).
triageStartRe = regexp.MustCompile(`(?im)^[ \t]*[-*]?[ \t]*\**verdict\**[ \t]*:`)
// badgeLineRe matches a leading Verdict/Confidence line, already surfaced
// in the badge at the top, so it can be trimmed from the triage body.
badgeLineRe = regexp.MustCompile(`(?i)^[ \t]*[-*]?[ \t]*\**(verdict|confidence)\**[ \t]*:`)
)
// splitDiagnostic separates the structured triage (the tail beginning at the
// Verdict line) from the preceding agent transcript. The redundant leading
// Verdict/Confidence lines are trimmed from the triage since they are already
// shown in the badge. If no Verdict line is found the whole text is treated as
// the triage with no transcript.
func splitDiagnostic(diagnostic string) (triage, transcript string) {
loc := triageStartRe.FindStringIndex(diagnostic)
if loc == nil {
return strings.TrimSpace(diagnostic), ""
}
transcript = strings.TrimSpace(diagnostic[:loc[0]])
triage = trimBadgeLines(diagnostic[loc[0]:])
return triage, transcript
}
// trimBadgeLines drops leading blank and Verdict/Confidence lines from a triage
// body so they aren't repeated below the badge.
func trimBadgeLines(triage string) string {
lines := strings.Split(strings.ReplaceAll(triage, "\r\n", "\n"), "\n")
i := 0
for i < len(lines) {
t := strings.TrimSpace(lines[i])
if t == "" || badgeLineRe.MatchString(t) {
i++
continue
}
break
}
return strings.TrimSpace(strings.Join(lines[i:], "\n"))
}
// markdownToHTML is a minimal, dependency-free markdown renderer covering the
// subset Copilot emits for these diagnostics: headings, bold, bullet lists,
// and paragraphs. Everything is HTML-escaped first.
func markdownToHTML(md string) string {
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
var b strings.Builder
inList := false
closeList := func() {
if inList {
b.WriteString("</ul>")
inList = false
}
}
for _, raw := range lines {
line := strings.TrimRight(raw, " \t")
trimmed := strings.TrimSpace(line)
switch {
case trimmed == "":
closeList()
case strings.HasPrefix(trimmed, "### "):
closeList()
b.WriteString("<h4>" + inline(trimmed[4:]) + "</h4>")
case strings.HasPrefix(trimmed, "## "):
closeList()
b.WriteString("<h3>" + inline(trimmed[3:]) + "</h3>")
case strings.HasPrefix(trimmed, "# "):
closeList()
b.WriteString("<h3>" + inline(trimmed[2:]) + "</h3>")
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "):
if !inList {
b.WriteString("<ul>")
inList = true
}
b.WriteString("<li>" + inline(trimmed[2:]) + "</li>")
default:
closeList()
b.WriteString("<p>" + inline(trimmed) + "</p>")
}
}
closeList()
return b.String()
}
// inline escapes text and renders **bold** and `code` spans.
func inline(s string) string {
s = html.EscapeString(s)
s = replacePairs(s, "**", "<strong>", "</strong>")
s = replacePairs(s, "`", "<code>", "</code>")
return s
}
// replacePairs turns matched pairs of delim into open/close tags. Unmatched
// trailing delimiters are left as literal text.
func replacePairs(s, delim, open, close string) string {
var b strings.Builder
openNext := true
for {
i := strings.Index(s, delim)
if i < 0 {
b.WriteString(s)
break
}
b.WriteString(s[:i])
if openNext {
b.WriteString(open)
} else {
b.WriteString(close)
}
openNext = !openNext
s = s[i+len(delim):]
}
// If we ended mid-pair, the last tag was an unmatched opener; fix by
// appending nothing — readers tolerate a stray <strong>, but to be safe
// we only emit balanced output above when pairs match. Acceptable for
// our controlled input.
return b.String()
}