package analyze import ( "encoding/json" "fmt" "strings" "gitea.konchin.com/service/stonks/internal/report" ) // memoryMarker and recommendationsMarker must match the marker text // SystemPrompt instructs Claude to emit: the narrative text, then the // memory marker and Claude's freeform notes for next run, then the // recommendations marker and its trailing JSON block, in that order. const ( memoryMarker = "===MEMORY===" recommendationsMarker = "===RECOMMENDATIONS_JSON===" ) type recommendationsPayload struct { Recommendations []report.Recommendation `json:"recommendations"` } // SplitReport separates Claude's raw response into the long-form narrative // text, Claude's freeform memory notes, and the buy/sell recommendations // list. // // If a marker is missing or the trailing JSON fails to parse, the fullest // text available is still returned (degrading gracefully section by // section) plus a non-nil error — callers should log it as a warning and // still send the partial report rather than treat this as fatal. func SplitReport(raw string) (report.Report, error) { memIdx := strings.Index(raw, memoryMarker) recIdx := strings.Index(raw, recommendationsMarker) textEnd := len(raw) if memIdx != -1 { textEnd = memIdx } else if recIdx != -1 { textEnd = recIdx } text := strings.TrimSpace(raw[:textEnd]) if memIdx == -1 { if recIdx == -1 { return report.Report{Text: text}, fmt.Errorf("neither memory nor recommendations marker found in response") } rep, err := parseRecommendations(raw[recIdx+len(recommendationsMarker):], text, "") return rep, fmt.Errorf("memory marker not found in response: %w", err) } memoryEnd := len(raw) if recIdx != -1 { memoryEnd = recIdx } memoryText := strings.TrimSpace(raw[memIdx+len(memoryMarker) : memoryEnd]) if recIdx == -1 { return report.Report{Text: text, Memory: memoryText}, fmt.Errorf("recommendations marker not found in response") } return parseRecommendations(raw[recIdx+len(recommendationsMarker):], text, memoryText) } func parseRecommendations(jsonSection, text, memoryText string) (report.Report, error) { jsonPart := extractJSONObject(jsonSection) var payload recommendationsPayload if err := json.Unmarshal([]byte(jsonPart), &payload); err != nil { return report.Report{Text: text, Memory: memoryText}, fmt.Errorf("parse recommendations json: %w", err) } for _, r := range payload.Recommendations { if r.Action != "buy" && r.Action != "sell" { return report.Report{Text: text, Memory: memoryText}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action) } } return report.Report{Text: text, Memory: memoryText, Recommendations: payload.Recommendations}, nil } // extractJSONObject pulls out the substring from the first "{" to the last // "}" in s. This tolerates any decoration Claude adds around the JSON // despite being asked not to — a ```json fence, a stray leading backtick, a // trailing sentence — since none of that can contain a brace that comes // before the real opening one or after the real closing one. Returns s // trimmed, unchanged, if no braces are found (json.Unmarshal will then fail // with a clear error). func extractJSONObject(s string) string { start := strings.IndexByte(s, '{') end := strings.LastIndexByte(s, '}') if start == -1 || end == -1 || end < start { return strings.TrimSpace(s) } return s[start : end+1] }