feat: add portfolio

This commit is contained in:
ytshih committed 2026-09-11 09:54:49 +08:00
1 parent ba63c5deb0
commit f09cf4d5c2
11 files changed
+669 -38

No files matched your search

+49 -18
View File
@@ -8,44 +8,75 @@ import (
"gitea.konchin.com/service/stonks/internal/report"
)
// recommendationsMarker must match the marker text SystemPrompt instructs
// Claude to emit before the trailing recommendations JSON block.
const recommendationsMarker = "===RECOMMENDATIONS_JSON==="
// 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 and the short buy/sell recommendations list appended after
// recommendationsMarker (see SystemPrompt).
// text, Claude's freeform memory notes, and the buy/sell recommendations
// list.
//
// If the marker is missing or the trailing JSON fails to parse, the full raw
// text is returned as-is with a nil recommendations slice, plus a non-nil
// error — callers should log it as a warning and still send the text-only
// report rather than treat this as fatal.
// 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) {
idx := strings.Index(raw, recommendationsMarker)
if idx == -1 {
return report.Report{Text: strings.TrimSpace(raw)},
fmt.Errorf("recommendations marker not found in response")
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)
}
text := strings.TrimSpace(raw[:idx])
jsonPart := extractJSONObject(raw[idx+len(recommendationsMarker):])
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}, fmt.Errorf("parse recommendations json: %w", err)
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}, fmt.Errorf("recommendation for %q has invalid action %q", r.Symbol, r.Action)
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, Recommendations: payload.Recommendations}, nil
return report.Report{Text: text, Memory: memoryText, Recommendations: payload.Recommendations}, nil
}
// extractJSONObject pulls out the substring from the first "{" to the last