package tools
import (
"sort"
"strings"
"skraak/utils"
)
// CallsSummariseInput defines the input for the calls-summarise tool
type CallsSummariseInput struct {
Folder string `json:"folder" jsonschema:"required,Path to folder containing .data files"`
}
// CallsSummariseOutput defines the output for the calls-summarise tool
type CallsSummariseOutput struct {
Segments []SegmentSummary `json:"segments"`
DataFilesRead int `json:"data_files_read"`
DataFilesSkipped []string `json:"data_files_skipped"`
TotalSegments int `json:"total_segments"`
Filters map[string]FilterStats `json:"filters"`
ReviewStatus ReviewStatus `json:"review_status"`
Operators []string `json:"operators"`
Reviewers []string `json:"reviewers"`
Error *string `json:"error,omitempty"`
}
// SegmentSummary represents a single segment in the output
type SegmentSummary struct {
File string `json:"file"`
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
Labels []LabelSummary `json:"labels"`
}
// LabelSummary represents a label in the output (omits empty fields)
type LabelSummary struct {
Filter string `json:"filter"`
Certainty int `json:"certainty"`
Species string `json:"species"`
CallType string `json:"calltype,omitempty"`
Comment string `json:"comment,omitempty"`
Bookmark bool `json:"bookmark,omitempty"`
}
// FilterStats contains per-filter statistics
type FilterStats struct {
Segments int `json:"segments"`
Species map[string]int `json:"species"`
}
// ReviewStatus contains review progress statistics
type ReviewStatus struct {
Unreviewed int `json:"unreviewed"` // certainty < 100
Confirmed int `json:"confirmed"` // certainty = 100
DontKnow int `json:"dont_know"` // certainty = 0
WithCallType int `json:"with_calltype"`
WithComments int `json:"with_comments"`
Bookmarked int `json:"bookmarked"`
}
// CallsSummarise reads all .data files in a folder and produces a summary
func CallsSummarise(input CallsSummariseInput) (CallsSummariseOutput, error) {
var output CallsSummariseOutput
// Find all .data files
filePaths, err := utils.FindDataFiles(input.Folder)
if err != nil {
errMsg := err.Error()
output.Error = &errMsg
return output, err
}
// Initialize empty slices/maps (avoid null in JSON)
output.Segments = make([]SegmentSummary, 0)
output.Filters = make(map[string]FilterStats)
output.Operators = make([]string, 0)
output.Reviewers = make([]string, 0)
output.DataFilesSkipped = make([]string, 0)
if len(filePaths) == 0 {
return output, nil
}
// Track unique operators and reviewers
operatorSet := make(map[string]bool)
reviewerSet := make(map[string]bool)
// Process each file
for _, path := range filePaths {
df, err := utils.ParseDataFile(path)
if err != nil {
// Extract just the filename for skipped list
output.DataFilesSkipped = append(output.DataFilesSkipped, path)
continue
}
output.DataFilesRead++
// Track operator and reviewer
if df.Meta != nil {
if df.Meta.Operator != "" {
operatorSet[df.Meta.Operator] = true
}
if df.Meta.Reviewer != "" {
reviewerSet[df.Meta.Reviewer] = true
}
}
// Process segments
for _, seg := range df.Segments {
// Extract relative filename
relPath := extractRelativePath(input.Folder, path)
// Build label summaries
var labels []LabelSummary
for _, l := range seg.Labels {
labelSummary := LabelSummary{
Filter: l.Filter,
Certainty: l.Certainty,
Species: l.Species,
}
if l.CallType != "" {
labelSummary.CallType = l.CallType
}
if l.Comment != "" {
labelSummary.Comment = l.Comment
}
if l.Bookmark {
labelSummary.Bookmark = true
}
labels = append(labels, labelSummary)
// Update filter stats
fs, exists := output.Filters[l.Filter]
if !exists {
fs = FilterStats{
Segments: 0,
Species: make(map[string]int),
}
}
fs.Segments++
fs.Species[l.Species]++
output.Filters[l.Filter] = fs
// Update review status
if l.Certainty == 100 {
output.ReviewStatus.Confirmed++
} else if l.Certainty == 0 {
output.ReviewStatus.DontKnow++
} else {
output.ReviewStatus.Unreviewed++
}
if l.CallType != "" {
output.ReviewStatus.WithCallType++
}
if l.Comment != "" {
output.ReviewStatus.WithComments++
}
if l.Bookmark {
output.ReviewStatus.Bookmarked++
}
}
// Create segment summary
segSummary := SegmentSummary{
File: relPath,
StartTime: seg.StartTime,
EndTime: seg.EndTime,
Labels: labels,
}
output.Segments = append(output.Segments, segSummary)
}
}
output.TotalSegments = len(output.Segments)
// Convert sets to sorted slices
for op := range operatorSet {
output.Operators = append(output.Operators, op)
}
for r := range reviewerSet {
output.Reviewers = append(output.Reviewers, r)
}
sort.Strings(output.Operators)
sort.Strings(output.Reviewers)
// Sort segments by file, then start time
sort.Slice(output.Segments, func(i, j int) bool {
if output.Segments[i].File != output.Segments[j].File {
return output.Segments[i].File < output.Segments[j].File
}
return output.Segments[i].StartTime < output.Segments[j].StartTime
})
return output, nil
}
// extractRelativePath extracts the .wav filename from a .data file path
// e.g., "/folder/tx51_LISTENING_20260221_203004.WAV.data" -> "tx51_LISTENING_20260221_203004.wav"
func extractRelativePath(folder, dataPath string) string {
// Get the filename
filename := dataPath
if idx := strings.LastIndex(dataPath, "/"); idx >= 0 {
filename = dataPath[idx+1:]
}
// Remove .data extension
filename = strings.TrimSuffix(filename, ".data")
// Normalize extension to lowercase .wav
lower := strings.ToLower(filename)
if strings.HasSuffix(lower, ".wav") {
// Already has .wav/.WAV extension, just normalize to lowercase
return filename[:len(filename)-4] + ".wav"
}
// No extension, add .wav
return filename + ".wav"
}