Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,49 @@ from their usual local locations.
configuration for trusted local or private-network deployments
- Privacy controls for redaction, hashing, capture filters, and offline operation

## Annotated Trace Datasets

Beacon annotations can mark a whole session, a transcript message, or a
specific event. The local JSON API exposes annotated traces directly for review,
evaluation, fine-tuning, and skill-development datasets.

List annotated sessions and their annotated targets:

```bash
curl 'http://localhost:4600/api/annotations/traces?label=dataset:eval&limit=25'
```

Export dataset-ready traces with session metadata, ordered event context, and
annotation records:

```bash
curl 'http://localhost:4600/api/annotations/export?label=dataset:eval&event_limit=2000'
```

Discovery and export responses are paginated with `limit`, `offset`, and
`has_more`. Continue increasing `offset` until `has_more` is false when
collecting a complete dataset:

```bash
offset=0
while :; do
curl -fsS "http://localhost:4600/api/annotations/export?label=dataset:eval&event_limit=2000&limit=200&offset=${offset}" > "annotated-traces-${offset}.json"
jq -e '.has_more' "annotated-traces-${offset}.json" >/dev/null || break
offset=$((offset + 200))
done
```

Each exported trace reports `event_truncated`, and the response includes
`warnings` when `event_limit` clipped ordered event context for a session.

Both endpoints return versioned JSON schema markers:
`beacon.annotated_traces.index.v1` and
`beacon.annotated_traces.export.v1`. Supported filters include `session_id`,
`event_uid`, `target_type`, `label`, `author_type`, `source`, `category`,
`outcome`, `needs_followup`, `include_deleted`, and the usual Beacon scope
filters (`source_name`, `source_names`, `runtime`, `runtimes`, `project_key`,
`project_keys`).

Check the local setup:

```bash
Expand Down
217 changes: 185 additions & 32 deletions internal/store/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,30 @@ type AnnotationFilter struct {
AnnotationID string
TargetType string
SessionID string
SessionIDs []string
EventUID string
AuthorType string
Source string
Category string
Outcome string
Label string
NeedsFollowup *bool
IncludeDeleted bool
Limit int
Offset int
}

type TraceAnnotationSessionSummary struct {
SessionID string
AnnotationCount int
SessionAnnotationCount int
MessageAnnotationCount int
EventAnnotationCount int
NeedsFollowupCount int
FirstAnnotationAt time.Time
LastAnnotationAt time.Time
}

type AnnotationUpdate struct {
AuthorType *string
AuthorID *string
Expand Down Expand Up @@ -112,38 +130,9 @@ func ListTraceAnnotations(ctx context.Context, db *sql.DB, filter AnnotationFilt
filter.TargetType = strings.TrimSpace(strings.ToLower(filter.TargetType))
filter.SessionID = strings.TrimSpace(filter.SessionID)
filter.EventUID = strings.TrimSpace(filter.EventUID)
if filter.Limit <= 0 {
filter.Limit = defaultAnnotationLimit
}
if filter.Limit > maxAnnotationLimit {
filter.Limit = maxAnnotationLimit
}
if filter.Offset < 0 {
filter.Offset = 0
}

where := []string{"1 = 1"}
args := []any{}
if filter.AnnotationID != "" {
where = append(where, "annotation_id = ?")
args = append(args, filter.AnnotationID)
}
if filter.TargetType != "" {
where = append(where, "target_type = ?")
args = append(args, filter.TargetType)
}
if filter.SessionID != "" {
where = append(where, "session_id = ?")
args = append(args, filter.SessionID)
}
if filter.EventUID != "" {
where = append(where, "event_uid = ?")
args = append(args, filter.EventUID)
}
if !filter.IncludeDeleted {
where = append(where, "status != ?")
args = append(args, models.AnnotationStatusDeleted)
}
filter.Limit = normalizeAnnotationLimit(filter.Limit)
filter.Offset = normalizeAnnotationOffset(filter.Offset)
where, args := annotationFilterWhere(filter)
args = append(args, filter.Limit, filter.Offset)

rows, err := db.QueryContext(ctx, traceAnnotationSelectSQL("WHERE "+strings.Join(where, " AND ")+" ORDER BY created_at ASC, annotation_id ASC LIMIT ? OFFSET ?"), args...)
Expand All @@ -166,6 +155,66 @@ func ListTraceAnnotations(ctx context.Context, db *sql.DB, filter AnnotationFilt
return annotations, nil
}

func ListTraceAnnotationSessionSummaries(ctx context.Context, db *sql.DB, filter AnnotationFilter) ([]TraceAnnotationSessionSummary, error) {
if db == nil {
return nil, fmt.Errorf("database is not configured")
}
filter.Limit = normalizeAnnotationLimit(filter.Limit)
filter.Offset = normalizeAnnotationOffset(filter.Offset)
where, args := annotationFilterWhere(filter)
args = append(args, filter.Limit, filter.Offset)
rows, err := db.QueryContext(ctx, `SELECT session_id,
count() AS annotation_count,
countIf(target_type = ?) AS session_annotation_count,
countIf(target_type = ?) AS message_annotation_count,
countIf(target_type = ?) AS event_annotation_count,
countIf(needs_followup != 0) AS needs_followup_count,
min(created_at) AS first_annotation_at,
max(updated_at) AS last_annotation_at
FROM trace_annotations FINAL
WHERE `+strings.Join(where, " AND ")+`
GROUP BY session_id
ORDER BY last_annotation_at DESC, session_id ASC
LIMIT ? OFFSET ?`,
append([]any{
models.AnnotationTargetSession,
models.AnnotationTargetMessage,
models.AnnotationTargetEvent,
}, args...)...)
if err != nil {
return nil, err
}
defer rows.Close()

summaries := make([]TraceAnnotationSessionSummary, 0)
for rows.Next() {
var summary TraceAnnotationSessionSummary
var annotationCount, sessionCount, messageCount, eventCount, followupCount uint64
if err := rows.Scan(
&summary.SessionID,
&annotationCount,
&sessionCount,
&messageCount,
&eventCount,
&followupCount,
&summary.FirstAnnotationAt,
&summary.LastAnnotationAt,
); err != nil {
return nil, err
}
summary.AnnotationCount = int(annotationCount)
summary.SessionAnnotationCount = int(sessionCount)
summary.MessageAnnotationCount = int(messageCount)
summary.EventAnnotationCount = int(eventCount)
summary.NeedsFollowupCount = int(followupCount)
summaries = append(summaries, summary)
}
if err := rows.Err(); err != nil {
return nil, err
}
return summaries, nil
}

func UpdateTraceAnnotation(ctx context.Context, db *sql.DB, annotationID string, update AnnotationUpdate) (models.TraceAnnotation, error) {
current, err := GetTraceAnnotation(ctx, db, annotationID, false)
if err != nil {
Expand Down Expand Up @@ -417,6 +466,110 @@ func splitAnnotationLabels(value string) []string {
return models.NormalizeAnnotationLabels(strings.Split(value, annotationLabelJoiner))
}

func annotationFilterWhere(filter AnnotationFilter) ([]string, []any) {
filter.AnnotationID = strings.TrimSpace(filter.AnnotationID)
filter.TargetType = strings.TrimSpace(strings.ToLower(filter.TargetType))
filter.SessionID = strings.TrimSpace(filter.SessionID)
filter.EventUID = strings.TrimSpace(filter.EventUID)
filter.AuthorType = strings.TrimSpace(strings.ToLower(filter.AuthorType))
filter.Source = strings.TrimSpace(strings.ToLower(filter.Source))
filter.Category = strings.TrimSpace(strings.ToLower(filter.Category))
filter.Outcome = strings.TrimSpace(strings.ToLower(filter.Outcome))
filter.Label = strings.TrimSpace(strings.ToLower(filter.Label))
filter.SessionIDs = compactAnnotationStrings(filter.SessionIDs)

where := []string{"1 = 1"}
args := []any{}
if filter.AnnotationID != "" {
where = append(where, "annotation_id = ?")
args = append(args, filter.AnnotationID)
}
if filter.TargetType != "" {
where = append(where, "target_type = ?")
args = append(args, filter.TargetType)
}
if filter.SessionID != "" {
where = append(where, "session_id = ?")
args = append(args, filter.SessionID)
}
if len(filter.SessionIDs) > 0 {
where = append(where, "session_id IN ("+strings.TrimRight(strings.Repeat("?,", len(filter.SessionIDs)), ",")+")")
for _, sessionID := range filter.SessionIDs {
args = append(args, sessionID)
}
}
if filter.EventUID != "" {
where = append(where, "event_uid = ?")
args = append(args, filter.EventUID)
}
if filter.AuthorType != "" {
where = append(where, "author_type = ?")
args = append(args, filter.AuthorType)
}
if filter.Source != "" {
where = append(where, "source = ?")
args = append(args, filter.Source)
}
if filter.Category != "" {
where = append(where, "category = ?")
args = append(args, filter.Category)
}
if filter.Outcome != "" {
where = append(where, "outcome = ?")
args = append(args, filter.Outcome)
}
if filter.Label != "" {
where = append(where, "has(labels, ?)")
args = append(args, filter.Label)
}
if filter.NeedsFollowup != nil {
where = append(where, "needs_followup = ?")
args = append(args, boolToUInt8(*filter.NeedsFollowup))
}
if !filter.IncludeDeleted {
where = append(where, "status != ?")
args = append(args, models.AnnotationStatusDeleted)
}
return where, args
}

func normalizeAnnotationLimit(limit int) int {
if limit <= 0 {
return defaultAnnotationLimit
}
if limit > maxAnnotationLimit {
return maxAnnotationLimit
}
return limit
}

func normalizeAnnotationOffset(offset int) int {
if offset < 0 {
return 0
}
return offset
}

func compactAnnotationStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}

func boolToUInt8(value bool) uint8 {
if value {
return 1
Expand Down
52 changes: 52 additions & 0 deletions internal/store/annotations_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package store

import (
"reflect"
"strings"
"testing"

"github.com/johnnygreco/beacon/internal/models"
)

func TestTraceAnnotationSelectSQLTargetsFinalTable(t *testing.T) {
Expand All @@ -27,6 +30,55 @@ func TestSplitAnnotationLabelsNormalizesValues(t *testing.T) {
}
}

func TestAnnotationFilterWhereSupportsDatasetFilters(t *testing.T) {
followup := true
where, args := annotationFilterWhere(AnnotationFilter{
TargetType: models.AnnotationTargetMessage,
SessionIDs: []string{"session-2", "session-1", "session-2"},
EventUID: "event-1",
AuthorType: models.AnnotationAuthorAgent,
Source: models.AnnotationSourceMCP,
Category: "quality",
Outcome: "useful",
Label: "dataset:eval",
NeedsFollowup: &followup,
IncludeDeleted: false,
})
joined := strings.Join(where, " AND ")
for _, want := range []string{
"target_type = ?",
"session_id IN (?,?)",
"event_uid = ?",
"author_type = ?",
"source = ?",
"category = ?",
"outcome = ?",
"has(labels, ?)",
"needs_followup = ?",
"status != ?",
} {
if !strings.Contains(joined, want) {
t.Fatalf("filter where missing %q:\n%s", want, joined)
}
}
wantArgs := []any{
models.AnnotationTargetMessage,
"session-2",
"session-1",
"event-1",
models.AnnotationAuthorAgent,
models.AnnotationSourceMCP,
"quality",
"useful",
"dataset:eval",
uint8(1),
models.AnnotationStatusDeleted,
}
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("filter args = %#v, want %#v", args, wantArgs)
}
}

func TestNewAnnotationIDUsesStablePrefix(t *testing.T) {
id, err := newAnnotationID()
if err != nil {
Expand Down
Loading
Loading