blob: c40c786886bf730d3f6aa26968be7780c76ecfb7 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package view
import (
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"github.com/apache/skywalking-ai-sessionizer/internal/storage"
"github.com/apache/skywalking-ai-sessionizer/pkg/model"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessiondata"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessionflow"
"github.com/apache/skywalking-ai-sessionizer/pkg/sessionview"
)
// Handler serves the page and the four questions a reader asks: which
// conversation, what happened in it, what happened in this turn, and what
// exactly did this step say.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", s.index)
mux.HandleFunc("/c/", s.page)
mux.Handle("/favicon.svg", svg(faviconSVG))
mux.Handle("/logo.svg", svg(logoSVG))
mux.HandleFunc("/api/status", s.apiStatus)
mux.HandleFunc("/api/conversations", s.apiList)
mux.HandleFunc("/api/glossary", s.apiGlossary)
mux.HandleFunc("/api/c/", s.apiConversation)
return mux
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func fail(w http.ResponseWriter, err error, code int) {
writeJSONStatus(w, code, map[string]string{"error": err.Error()})
}
func writeJSONStatus(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// summary is one row in the conversation list.
type summary struct {
ID string `json:"id"`
Title string `json:"title"`
Talks int `json:"talks"`
Steps int `json:"steps"`
Stream int `json:"streams"`
Segments int `json:"segments"`
Rounds uint64 `json:"rounds"`
From int64 `json:"from"`
To int64 `json:"to"`
Open int `json:"unresolved"`
}
func (s *Server) apiList(w http.ResponseWriter, _ *http.Request) {
ids, err := s.List()
if err != nil {
fail(w, err, http.StatusInternalServerError)
return
}
out := make([]summary, 0, len(ids))
for _, id := range ids {
c, err := s.Load(id)
if err != nil {
continue
}
row := summary{ID: id, Rounds: c.View.Round, Open: len(c.View.OpenUnresolved())}
for _, n := range c.View.Nodes {
switch n.Kind {
case model.KindTalk:
row.Talks++
case model.KindStream:
row.Stream++
case model.KindSegment:
row.Segments++
case model.KindSession:
row.Title = attrString(n, "title")
}
if isStep(n.Kind) {
row.Steps++
}
}
for _, t := range c.Talks() {
lo, hi := c.Span(t)
if lo != 0 && (row.From == 0 || lo < row.From) {
row.From = lo
}
if hi > row.To {
row.To = hi
}
}
row.From, row.To = Millis(row.From), Millis(row.To)
if row.Title == "" {
row.Title = "(untitled)"
}
out = append(out, row)
}
writeJSON(w, out)
}
// apiGlossary says what each name means, and what the runtime calls it.
//
// Two sources, kept apart on purpose. A field that names something the model
// names is described by the adapter's glossary, which also carries the
// runtime's word for it. The rest are the landed envelope's own fields, and
// the format describes those itself.
func (s *Server) apiGlossary(w http.ResponseWriter, _ *http.Request) {
g := s.glossary
if g == nil {
writeJSONStatus(w, http.StatusNotFound, map[string]string{"error": "no glossary was supplied for this storage root"})
return
}
terms := map[string]map[string]string{}
for _, t := range g.Terms() {
terms[t.Unified] = map[string]string{
"native": t.Native, "where": t.Where, "note": t.Note,
}
}
writeJSON(w, map[string]any{
"dialect": g.Dialect,
"terms": terms,
"fields": sessiondata.Fields(),
})
}
var flowPath = regexp.MustCompile(`^/api/c/([^/]+)/flow$`)
var viewPath = regexp.MustCompile(`^/api/c/([^/]+)/view$`)
var recordPath = regexp.MustCompile(`^/api/c/([^/]+)/record/(\d+)/(\d+)$`)
var talkPath = regexp.MustCompile(`^/api/c/([^/]+)/talk/(.+)$`)
func (s *Server) apiConversation(w http.ResponseWriter, r *http.Request) {
if m := recordPath.FindStringSubmatch(r.URL.Path); m != nil {
seq, _ := strconv.ParseUint(m[2], 10, 64)
row, _ := strconv.ParseUint(m[3], 10, 64)
s.apiRecord(w, m[1], seq, row)
return
}
if m := flowPath.FindStringSubmatch(r.URL.Path); m != nil {
s.apiFlow(w, m[1])
return
}
if m := viewPath.FindStringSubmatch(r.URL.Path); m != nil {
s.apiView(w, m[1])
return
}
if m := talkPath.FindStringSubmatch(r.URL.Path); m != nil {
s.apiTalk(w, m[1], m[2])
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/c/")
if id == "" || strings.Contains(id, "/") {
fail(w, fmt.Errorf("not found"), http.StatusNotFound)
return
}
s.apiOverview(w, id)
}
// talkRow is one talk in the list down the side.
type talkRow struct {
ID string `json:"id"`
Stream string `json:"stream"`
Label string `json:"label"`
Runs int `json:"runs"`
Steps int `json:"steps"`
Tools int `json:"tools"`
From int64 `json:"from"`
To int64 `json:"to"`
Child bool `json:"child"`
Segment string `json:"segment,omitempty"`
Reply string `json:"reply,omitempty"`
// labelAt and replyAt are where those are read from. They never leave
// the server. labelAt holds candidates in preference order, because
// which one can name the talk is only known once its text is read.
labelAt []*sessionflow.Ref
replyAt *sessionflow.Ref
}
// overview is what the page's conversation summary and the Conversation
// View share: computed once per fold, from the fold alone plus the text of
// the records that name and answer each talk.
type overview struct {
title string
kinds, rels, quality map[string]int
talks []talkRow
streams []sessionview.Stream
segments []sessionview.Segment
open []map[string]string
}
func (s *Server) apiOverview(w http.ResponseWriter, id string) {
c, err := s.Load(id)
if err != nil {
fail(w, err, http.StatusNotFound)
return
}
o := c.overview()
writeJSON(w, map[string]any{
"id": id, "title": o.title, "session": c.Session,
"rounds": c.View.Round, "digest": c.View.Digest,
"parser": c.View.Parser, "policy": c.View.Policy,
"through_seq": c.View.ThroughSeq,
"nodes": len(c.View.Nodes), "relations": len(c.View.Relations),
"kinds": o.kinds, "relation_types": o.rels, "quality": o.quality,
"talks": o.talks, "unresolved": o.open,
"streams": o.streams,
"segments": o.segments,
})
}
func (c *Conversation) overview() *overview {
var title string
kinds := map[string]int{}
for _, n := range c.View.Nodes {
kinds[n.Kind]++
if n.Kind == model.KindSession {
title = attrString(n, "title")
}
}
quality := map[string]int{}
rels := map[string]int{}
for _, r := range c.View.Relations {
rels[r.Type]++
quality[r.Quality]++
}
childStream := map[string]bool{}
for _, st := range c.Streams() {
if !strings.Contains(string(st.Attrs), `"role":"main"`) {
childStream[st.Stream] = true
}
}
// Which segment a talk sits in. The assembler relates the talk to the
// segment, so this is read, never recomputed from time.
inSegment := map[string]string{}
for _, r := range c.View.Relations {
if r.Type == model.RelInSegment {
inSegment[r.From] = r.To
}
}
talks := make([]talkRow, 0, 64)
var labelRefs []*sessionflow.Ref
for _, t := range c.Talks() {
lo, hi := c.Span(t)
row := talkRow{ID: t.ID, Stream: t.Stream, Child: childStream[t.Stream],
From: Millis(lo), To: Millis(hi), Segment: inSegment[t.ID]}
var walk func(string)
walk = func(nid string) {
for _, k := range c.View.Children(nid) {
if k.Kind == model.KindRun {
row.Runs++
}
if isStep(k.Kind) {
row.Steps++
}
if k.Kind == model.KindTool || k.Kind == model.KindAgentCall {
row.Tools++
}
walk(k.ID)
}
}
walk(t.ID)
if refs := c.labelRefs(t); len(refs) > 0 {
row.labelAt = refs
labelRefs = append(labelRefs, refs...)
}
if ref := c.replyRef(t); ref != nil {
row.replyAt = ref
labelRefs = append(labelRefs, ref)
}
talks = append(talks, row)
}
// One pass per landed file, not one per talk.
texts := c.texts(labelRefs)
for i := range talks {
for _, r := range talks[i].labelAt {
text := texts[[2]uint64{r.Seq, r.Row}]
// A tools-list delta says which tools appeared, not what the
// work is, so it cannot name a talk.
if strings.HasPrefix(strings.TrimSpace(text), `{"type":"deferred_tools_delta"`) {
continue
}
if text != "" {
talks[i].Label = clip(text)
break
}
}
if r := talks[i].replyAt; r != nil {
talks[i].Reply = clip(texts[[2]uint64{r.Seq, r.Row}])
}
}
open := []map[string]string{}
for _, u := range c.View.OpenUnresolved() {
open = append(open, map[string]string{"kind": u.Kind, "ref": u.RefID, "reason": u.Reason})
}
return &overview{
title: title, kinds: kinds, rels: rels, quality: quality,
talks: talks, streams: streamRows(c, talks), segments: segmentRows(c, talks), open: open,
}
}
// segmentRows lists the conversation's segments, each with the span of the
// talks the assembler placed in it.
//
// A segment's own time is not recomputed here. Its bounds are the bounds of its
// talks, which is what "in_segment" already decided.
func segmentRows(c *Conversation, talks []talkRow) []sessionview.Segment {
span := map[string][2]int64{}
count := map[string]int{}
for _, t := range talks {
if t.Segment == "" {
continue
}
count[t.Segment]++
s := span[t.Segment]
if t.From != 0 && (s[0] == 0 || t.From < s[0]) {
s[0] = t.From
}
if t.To > s[1] {
s[1] = t.To
}
span[t.Segment] = s
}
var segs []*sessionflow.Node
for _, n := range c.View.Nodes {
if n.Kind == model.KindSegment {
segs = append(segs, n)
}
}
out := []sessionview.Segment{}
for _, n := range sessionflow.InOrder(segs) {
s := span[n.ID]
out = append(out, sessionview.Segment{
ID: n.ID, State: attrString(n, "state"),
Talks: count[n.ID], From: s[0], To: s[1],
Committable: attrBool(n, "committable"),
})
}
return out
}
// streamRows lists the execution streams, each with the parent that started it
// and the talk that opens it.
//
// The parent is read from the "starts" relation, never from the stream name. A
// call that could have started several streams reports each of them, because
// the assembler does not choose one and neither may this.
func streamRows(c *Conversation, talks []talkRow) []sessionview.Stream {
firstTalk := map[string]string{}
steps := map[string]int{}
for i := range talks {
if _, seen := firstTalk[talks[i].Stream]; !seen {
firstTalk[talks[i].Stream] = talks[i].ID
}
steps[talks[i].Stream] += talks[i].Steps
}
// Which step started a stream, and how well that is known. A call the
// assembler could not tie to one stream leaves several candidates, and
// every one is reported rather than one being chosen here.
type origin struct {
Step, Stream, Quality, Talk string
}
parent := map[string]string{}
from := map[string][]origin{}
for _, r := range c.View.Relations {
if r.Type != model.RelStarts {
continue
}
if n := c.View.Nodes[r.From]; n != nil && n.Stream != "" {
parent[r.To] = n.Stream
from[r.To] = append(from[r.To], origin{r.From, n.Stream, r.Quality, c.talkOf(r.From)})
}
}
names := c.journalNames()
out := []sessionview.Stream{}
for _, st := range c.Streams() {
label, namedBy := attrString(st, "label"), ""
if label == "" {
if n, ok := names[st.Stream]; ok {
label, namedBy = n, "journal"
}
}
opened := []sessionview.Origin{}
for _, o := range from[st.ID] {
opened = append(opened, sessionview.Origin{Step: o.Step, Stream: o.Stream, Quality: o.Quality, Talk: o.Talk})
}
out = append(out, sessionview.Stream{
ID: st.ID, Name: st.Stream,
Role: attrString(st, "role"),
Label: label,
NamedBy: namedBy,
Records: int(attrNumber(st, "records")),
Parent: parent[st.ID],
Talk: firstTalk[st.Stream],
Steps: steps[st.Stream],
OpenedBy: opened,
})
}
return out
}
func attrBool(n *sessionflow.Node, key string) bool {
if len(n.Attrs) == 0 {
return false
}
var m map[string]any
if json.Unmarshal(n.Attrs, &m) != nil {
return false
}
b, _ := m[key].(bool)
return b
}
// labelRefs finds where a talk's name should be read from: the first
// external input under it. A talk with no external input at all - work
// delegated to a pool agent arrives outside its own records - is named by
// what was first put into its context instead, so the first few injections
// are offered as candidates and the caller, which reads the texts, picks
// the first one that can serve as a name.
//
// This returns positions rather than texts because resolving one position
// means opening a landed file and reading forward to a row. Done per talk,
// that is one file scan per talk - measured at six seconds for a
// conversation with 922 of them. The positions are collected first and
// read together instead.
func (c *Conversation) labelRefs(t *sessionflow.Node) []*sessionflow.Ref {
var ext *sessionflow.Ref
var inj []*sessionflow.Ref
var walk func(string) bool
walk = func(nid string) bool {
for _, k := range c.View.Children(nid) {
if k.Kind == model.KindMessageExternal && k.Ref != nil {
ext = k.Ref
return true
}
if k.Kind == model.KindContextInjection && k.Ref != nil && len(inj) < 3 {
inj = append(inj, k.Ref)
}
if walk(k.ID) {
return true
}
}
return false
}
walk(t.ID)
if ext != nil {
return []*sessionflow.Ref{ext}
}
return inj
}
// replyRef finds where a talk's answer should be read from: the last thing the
// agent said in it.
//
// Not the first, and not the only. The agent speaks between tool calls, so a
// talk usually holds several assistant messages; the last one is the answer
// and the earlier ones are narration on the way to it.
func (c *Conversation) replyRef(t *sessionflow.Node) *sessionflow.Ref {
var found *sessionflow.Ref
var walk func(string)
walk = func(nid string) {
for _, k := range c.View.Children(nid) {
if (k.Kind == model.KindMessageAssistant || k.Kind == model.KindAgentOutput) && k.Ref != nil {
found = k.Ref
}
walk(k.ID)
}
}
walk(t.ID)
return found
}
// journalNames names child streams from the run journals that started
// them, keyed by stream name.
//
// A pool agent's own records never say what it was for: its opening context
// is the same tool roster and skill catalogue every agent gets, and the
// prompt reaches it outside its transcript. Measured over 178 children of
// one conversation, 177 open with byte-identical injections. The workflow's
// journal is the one place that tells them apart: each result row carries
// the agent id and, in what the agent returned, the surface it covered. That
// is read here, at render time, because a journal row is payload and the
// assembler reads only the index.
//
// The name is what the result says - a surface, else a summary, else the
// verdict and the first claim it was about. An agent with no result row has
// no name from here and keeps whatever it had.
func (c *Conversation) journalNames() map[string]string {
names := map[string]string{}
files, err := storage.LandedFiles(c.zone, c.Session)
if err != nil {
return names
}
for _, lf := range files {
if lf.RunID == "" {
continue
}
f, err := os.Open(lf.Path)
if err != nil {
continue
}
rd, err := sessiondata.NewReader(f)
if err != nil {
f.Close()
continue
}
for {
rec, rerr := rd.Next()
if rerr != nil {
break
}
if rec.Child == "" {
continue
}
if _, seen := names[rec.Child]; seen {
continue
}
for _, raw := range candidates(rec) {
var row struct {
Type string `json:"type"`
Result struct {
Surface string `json:"surface"`
Summary string `json:"summary"`
Verdict string `json:"verdict"`
Refuted []struct {
Claim string `json:"claim"`
} `json:"refuted_claims"`
} `json:"result"`
}
if json.Unmarshal(raw, &row) != nil || row.Type != "result" {
continue
}
r := row.Result
name := r.Surface
if name == "" {
name = r.Summary
}
if name == "" && r.Verdict != "" {
name = r.Verdict
if len(r.Refuted) > 0 && r.Refuted[0].Claim != "" {
name += " · " + r.Refuted[0].Claim
}
}
if name = shortName(name); name != "" {
names[rec.Child] = name
}
break
}
}
f.Close()
}
return names
}
// shortName cuts a name to what a label can hold, on a rune boundary, and
// collapses the line breaks a result text carries.
func shortName(s string) string {
s = strings.Join(strings.Fields(s), " ")
const limit = 160
if len(s) <= limit {
return s
}
cut := limit
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "…"
}
// texts reads many landed positions with one pass per file.
//
// A landed file is a sequence of records with no row offsets, so a row is
// reached by reading forward. Reading every wanted row of one file in a single
// pass turns a scan per position into a scan per file.
func (c *Conversation) texts(refs []*sessionflow.Ref) map[[2]uint64]string {
want := map[uint64]map[uint64]bool{}
for _, r := range refs {
if r == nil {
continue
}
if want[r.Seq] == nil {
want[r.Seq] = map[uint64]bool{}
}
want[r.Seq][r.Row] = true
}
out := map[[2]uint64]string{}
for seq, rows := range want {
path, err := c.landedPath(seq)
if err != nil {
continue
}
f, err := os.Open(path)
if err != nil {
continue
}
rd, err := sessiondata.NewReader(f)
if err != nil {
f.Close()
continue
}
left := len(rows)
for i := uint64(1); left > 0; i++ {
rec, rerr := rd.Next()
if rerr != nil {
break
}
if rows[i] {
out[[2]uint64{seq, i}] = readable(rec)
left--
}
}
f.Close()
}
return out
}
// flowStep is one step as the timeline needs it: where it sits and when, and
// nothing it does not draw.
//
// The timeline shows the whole conversation, so it needs every step at once.
// Sending each talk's full tree would be tens of megabytes of text the axis
// never renders; this is the same steps without their content.
type flowStep struct {
ID string `json:"id"`
Kind string `json:"kind"`
Stream string `json:"stream"`
Talk string `json:"talk"`
Parent string `json:"parent,omitempty"`
At int64 `json:"at"`
Name string `json:"name,omitempty"`
Depth int `json:"depth"`
}
func (s *Server) apiFlow(w http.ResponseWriter, id string) {
c, err := s.Load(id)
if err != nil {
fail(w, err, http.StatusNotFound)
return
}
out := make([]flowStep, 0, 4096)
for _, t := range c.Talks() {
var walk func(*sessionflow.Node, int)
walk = func(n *sessionflow.Node, depth int) {
for _, k := range c.View.Children(n.ID) {
if isStep(k.Kind) {
out = append(out, flowStep{
ID: k.ID, Kind: k.Kind, Stream: k.Stream, Talk: t.ID,
Parent: k.Parent, At: Millis(c.Time(k)),
Name: attrString(k, "name"), Depth: depth,
})
}
walk(k, depth+1)
}
}
walk(t, 1)
}
writeJSON(w, out)
}
// talkOf walks up from a node to the talk that contains it.
//
// A reader sent to a step needs the talk holding it, because a talk is what
// the page loads. Without it, following a child stream back to the step that
// opened it lands on a stream whose talk was never fetched, and the page shows
// nothing at all.
func (c *Conversation) talkOf(id string) string {
for i := 0; i < 24 && id != ""; i++ {
n := c.View.Nodes[id]
if n == nil {
return ""
}
if n.Kind == model.KindTalk {
return n.ID
}
id = n.Parent
}
return ""
}
func isStep(kind string) bool {
switch kind {
case model.KindSession, model.KindSegment, model.KindStream,
model.KindEpoch, model.KindTalk, model.KindRun:
return false
}
return true
}
func attrString(n *sessionflow.Node, key string) string {
if len(n.Attrs) == 0 {
return ""
}
var m map[string]any
if json.Unmarshal(n.Attrs, &m) != nil {
return ""
}
s, _ := m[key].(string)
return s
}
func attrNumber(n *sessionflow.Node, key string) float64 {
if len(n.Attrs) == 0 {
return 0
}
var m map[string]any
if json.Unmarshal(n.Attrs, &m) != nil {
return 0
}
f, _ := m[key].(float64)
return f
}