97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
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]
|
|
}
|