132 lines
5.1 KiB
Go
132 lines
5.1 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"log/slog"
|
||
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// RequestLog is one telemetry row (Р7: маршрут, токены с cache-полями, $,
|
||
// latency per книга/глава/чанк/стадия/роль/модель). Telemetry is strictly off
|
||
// the money path: деньги живут в spend/checkpoints, а сбой записи телеметрии
|
||
// логируется и никогда не роняет перевод.
|
||
type RequestLog struct {
|
||
TraceID string
|
||
BookID string
|
||
Chapter int
|
||
ChunkIdx int
|
||
Stage string
|
||
Role string
|
||
ModelRequested string
|
||
ModelActual string
|
||
RequestHash string
|
||
PromptTokens int
|
||
CachedTokens int
|
||
CacheCreationTokens int
|
||
CompletionTokens int
|
||
ReasoningTokens int
|
||
CostUSD float64
|
||
LatencyMS int
|
||
FinishReason string
|
||
TMHit bool // served from a checkpoint, no call was made
|
||
Degraded string
|
||
Err string
|
||
OK bool
|
||
}
|
||
|
||
// InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has
|
||
// no answer-latency to protect, unlike vojo's chat path; the async+recover
|
||
// pattern returns if this ever sits on a hot path). Errors are the caller's to
|
||
// log as WARN — never to fail the pipeline on.
|
||
func (s *Store) InsertRequestLog(rl RequestLog) error {
|
||
ctx, cancel := opContext()
|
||
defer cancel()
|
||
_, err := s.w.ExecContext(ctx, `
|
||
INSERT INTO request_log (
|
||
trace_id, book_id, chapter, chunk_idx, stage, role,
|
||
model_requested, model_actual, request_hash,
|
||
prompt_tokens, cached_tokens, cache_creation_tokens,
|
||
completion_tokens, reasoning_tokens,
|
||
cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role,
|
||
rl.ModelRequested, rl.ModelActual, rl.RequestHash,
|
||
rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens,
|
||
rl.CompletionTokens, rl.ReasoningTokens,
|
||
rl.CostUSD, rl.LatencyMS, rl.FinishReason, rl.TMHit, rl.Degraded, rl.Err, rl.OK)
|
||
return err
|
||
}
|
||
|
||
// LogRequest is the fire-and-forget wrapper the runner uses: insert, WARN on
|
||
// failure, never propagate.
|
||
func (s *Store) LogRequest(ctx context.Context, log *slog.Logger, rl RequestLog) {
|
||
if rl.TraceID == "" {
|
||
rl.TraceID = obs.TraceFromContext(ctx)
|
||
}
|
||
if err := s.InsertRequestLog(rl); err != nil && log != nil {
|
||
log.WarnContext(ctx, "request_log insert failed (non-fatal)", "err", err)
|
||
}
|
||
}
|
||
|
||
// FreshCallLatencyMS sums the wall-clock latency of the book's FRESH provider calls (tm_hit=0
|
||
// — a checkpoint replay has no answer latency to count) and their count. It is the throughput
|
||
// input for the `tmctl status` ETA (D15.3): mean fresh-call latency × remaining work, a
|
||
// throughput estimate, never a synthetic time-bar. Pure read, $0.
|
||
func (s *Store) FreshCallLatencyMS(bookID string) (totalMS int64, calls int, err error) {
|
||
ctx, cancel := opContext()
|
||
defer cancel()
|
||
err = s.r.QueryRowContext(ctx,
|
||
`SELECT COALESCE(SUM(latency_ms),0), COUNT(*) FROM request_log WHERE book_id = ? AND tm_hit = 0`,
|
||
bookID).Scan(&totalMS, &calls)
|
||
return
|
||
}
|
||
|
||
// RequestLogView is one request_log row for inspection (tmctl report).
|
||
type RequestLogView struct {
|
||
TS string
|
||
Stage string
|
||
Role string
|
||
ModelActual string
|
||
PromptTokens int
|
||
CachedTokens int
|
||
CacheCreationTokens int
|
||
CompletionTokens int
|
||
ReasoningTokens int
|
||
CostUSD float64
|
||
LatencyMS int
|
||
FinishReason string
|
||
TMHit int
|
||
OK int
|
||
}
|
||
|
||
// RequestLogRows returns all request_log rows for a book (tmctl report / приёмка
|
||
// Фазы 0). Строки МАТЕРИАЛИЗУЮТСЯ под op-таймаутом и возвращаются срезом —
|
||
// отдавать *sql.Rows нельзя: ленивая итерация у вызывающего переживает
|
||
// `defer cancel()` этого метода, и контекст отменяется ПОСРЕДИ чтения
|
||
// («context canceled», обрыв таблицы report — находка реальной приёмки).
|
||
func (s *Store) RequestLogRows(bookID string) ([]RequestLogView, error) {
|
||
ctx, cancel := opContext()
|
||
defer cancel()
|
||
rows, err := s.r.QueryContext(ctx, `
|
||
SELECT ts, stage, role, model_actual, prompt_tokens, cached_tokens,
|
||
cache_creation_tokens, completion_tokens, reasoning_tokens,
|
||
cost_usd, latency_ms, finish_reason, tm_hit, ok
|
||
FROM request_log WHERE book_id = ? ORDER BY id`, bookID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []RequestLogView
|
||
for rows.Next() {
|
||
var v RequestLogView
|
||
if err := rows.Scan(&v.TS, &v.Stage, &v.Role, &v.ModelActual,
|
||
&v.PromptTokens, &v.CachedTokens, &v.CacheCreationTokens,
|
||
&v.CompletionTokens, &v.ReasoningTokens, &v.CostUSD,
|
||
&v.LatencyMS, &v.FinishReason, &v.TMHit, &v.OK); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, v)
|
||
}
|
||
return out, rows.Err()
|
||
}
|