Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

264 lines
7.5 KiB
Go

package sync
import (
"slices"
"time"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/signals"
)
// computeSignalsFromMessages produces a SessionSignalUpdate from
// in-memory session metadata and messages. Pure function with no
// DB access — the caller already holds everything we need.
//
// Used by both the live write paths (writeBatch, writeSessionFull,
// writeIncremental) and the legacy backfill path (RecomputeSignals,
// which reads msgs from the DB once and then calls this).
func computeSignalsFromMessages(
sess db.Session, msgs []db.Message,
) db.SessionSignalUpdate {
toolRows := extractToolCallRows(msgs)
heuristics := signals.AnalyzeHeuristics(signals.HeuristicInput{
Messages: extractHeuristicMessages(msgs),
ToolRows: toolRows,
})
ctxTokens := extractContextTokens(msgs)
boundaries := extractCompactBoundaryOrdinals(msgs)
model := extractMostCommonModel(msgs)
lastRole, lastContent := extractLastMessageRole(msgs)
toolHealth := signals.ComputeToolHealth(toolRows)
ctxPressure := signals.ComputeContextPressure(
ctxTokens, sess.PeakContextTokens, model,
)
// Prefer explicit boundary count when available; fall back
// to the token-drop heuristic for sessions without
// compact-boundary messages.
compactionCount := ctxPressure.CompactionCount
if len(boundaries) > 0 {
compactionCount = len(boundaries)
}
midTaskCalls := make(
[]signals.ToolCallOrdinal, 0, len(toolRows),
)
for _, t := range toolRows {
midTaskCalls = append(midTaskCalls,
signals.ToolCallOrdinal{
MessageOrdinal: t.MessageOrdinal,
ToolName: t.ToolName,
})
}
midTaskCount := signals.CountMidTaskCompactions(
boundaries, midTaskCalls,
)
finalStreak := computeFinalStreak(toolRows)
var lastActivity time.Time
if sess.EndedAt != nil {
lastActivity, _ = time.Parse(
time.RFC3339Nano, *sess.EndedAt,
)
}
outcomeResult := signals.ClassifyOutcome(signals.OutcomeInput{
IsAutomated: sess.IsAutomated,
MessageCount: sess.MessageCount,
EndedWithRole: lastRole,
FinalFailureStreak: finalStreak,
LastAssistantText: lastContent,
LastActivity: lastActivity,
})
hasContextData := sess.HasPeakContextTokens
if !hasContextData {
for _, t := range ctxTokens {
if t.HasContextTokens {
hasContextData = true
break
}
}
}
scoreResult := signals.ComputeHealthScore(signals.ScoreInput{
Outcome: outcomeResult.Outcome,
OutcomeConfidence: outcomeResult.Confidence,
HasToolCalls: len(toolRows) > 0,
FailureSignalCount: toolHealth.FailureSignalCount,
RetryCount: toolHealth.RetryCount,
EditChurnCount: toolHealth.EditChurnCount,
ConsecutiveFailMax: toolHealth.ConsecutiveFailureMax,
HasContextData: hasContextData,
CompactionCount: compactionCount,
MidTaskCompactionCount: midTaskCount,
PressureMax: ctxPressure.PressureMax,
Heuristics: heuristics,
})
var pendingSince *string
if outcomeResult.IsRecent {
now := time.Now().UTC().Format(time.RFC3339)
pendingSince = &now
}
var healthGrade *string
if scoreResult.Grade != "" {
healthGrade = &scoreResult.Grade
}
return db.SessionSignalUpdate{
ToolFailureSignalCount: toolHealth.FailureSignalCount,
ToolRetryCount: toolHealth.RetryCount,
EditChurnCount: toolHealth.EditChurnCount,
ConsecutiveFailureMax: toolHealth.ConsecutiveFailureMax,
Outcome: outcomeResult.Outcome,
OutcomeConfidence: outcomeResult.Confidence,
EndedWithRole: lastRole,
FinalFailureStreak: finalStreak,
SignalsPendingSince: pendingSince,
CompactionCount: compactionCount,
MidTaskCompactionCount: midTaskCount,
ContextPressureMax: ctxPressure.PressureMax,
HealthScore: scoreResult.Score,
HealthGrade: healthGrade,
HasToolCalls: len(toolRows) > 0,
HasContextData: hasContextData,
QualitySignals: db.QualitySignals{
Version: db.CurrentQualitySignalVersion,
ShortPromptCount: heuristics.ShortPromptCount,
UnstructuredStart: heuristics.UnstructuredStart,
MissingSuccessCriteriaCount: heuristics.
MissingSuccessCriteriaCount,
MissingVerificationCount: heuristics.
MissingVerificationCount,
DuplicatePromptCount: heuristics.DuplicatePromptCount,
NoCodeContextCount: heuristics.NoCodeContextCount,
RunawayToolLoopCount: heuristics.RunawayToolLoopCount,
},
}
}
func extractHeuristicMessages(
msgs []db.Message,
) []signals.HeuristicMessage {
rows := make([]signals.HeuristicMessage, 0, len(msgs))
for _, m := range msgs {
rows = append(rows, signals.HeuristicMessage{
Role: m.Role,
Content: m.Content,
IsSystem: m.IsSystem,
Ordinal: m.Ordinal,
Timestamp: m.Timestamp,
})
}
return rows
}
// extractToolCallRows builds signal inputs from in-memory tool
// calls. CallIndex is the call's position within its message.
// EventStatus is the status of the latest result event (events
// are stored in event_index order, so the last one wins).
func extractToolCallRows(
msgs []db.Message,
) []signals.ToolCallRow {
rows := make([]signals.ToolCallRow, 0)
for _, m := range msgs {
for callIdx, tc := range m.ToolCalls {
status := ""
if n := len(tc.ResultEvents); n > 0 {
status = tc.ResultEvents[n-1].Status
}
rows = append(rows, signals.ToolCallRow{
ToolName: tc.ToolName,
Category: tc.Category,
InputJSON: tc.InputJSON,
ResultContent: tc.ResultContent,
MessageOrdinal: m.Ordinal,
CallIndex: callIdx,
EventStatus: status,
})
}
}
return rows
}
// extractContextTokens returns context-token measurements for
// assistant messages in order.
func extractContextTokens(
msgs []db.Message,
) []signals.ContextTokenRow {
var rows []signals.ContextTokenRow
for _, m := range msgs {
if m.Role != "assistant" {
continue
}
rows = append(rows, signals.ContextTokenRow{
ContextTokens: m.ContextTokens,
HasContextTokens: m.HasContextTokens,
})
}
return rows
}
// extractCompactBoundaryOrdinals returns ordinals of explicit
// compact-boundary messages in ascending order.
func extractCompactBoundaryOrdinals(msgs []db.Message) []int {
var ords []int
for _, m := range msgs {
if m.IsCompactBoundary {
ords = append(ords, m.Ordinal)
}
}
return ords
}
// extractMostCommonModel returns the assistant model name that
// appears most often. Ties are broken by the model that appears
// first chronologically — matches SQLite's GROUP BY iteration
// order closely enough that legacy and live computations agree
// on real sessions (every observed session has a clear majority
// model).
func extractMostCommonModel(msgs []db.Message) string {
counts := map[string]int{}
firstSeen := map[string]int{}
for i, m := range msgs {
if m.Role != "assistant" || m.Model == "" {
continue
}
counts[m.Model]++
if _, ok := firstSeen[m.Model]; !ok {
firstSeen[m.Model] = i
}
}
var best string
bestCount := -1
for model, n := range counts {
switch {
case n > bestCount:
best, bestCount = model, n
case n == bestCount && firstSeen[model] < firstSeen[best]:
best = model
}
}
return best
}
// extractLastMessageRole returns the role and content of the
// last non-system message. Empty strings if none.
func extractLastMessageRole(
msgs []db.Message,
) (role, content string) {
if msgs == nil {
return "", ""
}
for _, v := range slices.Backward(msgs) {
if !v.IsSystem {
return v.Role, v.Content
}
}
return "", ""
}