Help us shape the future of Go! Take the 2026 Go Developer Survey

recap

package
v0.40.21 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package recap turns a finished drive into a short natural-language narration ("Trip recap") via the operator-configured LLM provider.

Why this lives in its own package and not under internal/ai:

  • internal/ai is provider-agnostic plumbing (Provider interface, OpenAI/Anthropic/Gemini adapters, token cost table). Domain features sit on top.
  • The aggregator is pure: it takes a Drive + samples + a few adjacent charges and produces a compact textual summary the LLM operates on. No GPS coordinates, no per-second telemetry ever leaves the box -- only summary statistics. This matters for the BYO-key trust posture: even an OpenAI-keyed install ships only the same scalars an analytics dashboard would.

Cache strategy:

  • drive_recaps (user_id, drive_id) PK -- migration 0020.
  • Finished drives are immutable; a cached recap stays valid forever. Regeneration is a user-initiated action.
  • The handler is the cache reader/writer; this package only knows about generation.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func HumanizeDriveMode added in v0.36.10

func HumanizeDriveMode(raw string) string

HumanizeDriveMode is humanizeDriveMode for other packages (the efficiency breakdown's drive-mode rows use the same labels).

Types

type CabinTempStats

type CabinTempStats struct {
	MedianCabinC   float64
	MedianOutsideC float64
	MedianDeltaC   float64 // cabin - outside, when both are present per sample
	HasDelta       bool
}

CabinTempStats summarizes interior temperature across a drive. Returns nil flag when no usable cabin samples exist (filtered out the same way the SPA tooltip does — zero readings are sentinels from the live merge path when Rivian's WS feed didn't carry a fresh ambient value).

func CabinTemp

func CabinTemp(ss []samples.Sample) (CabinTempStats, bool)

type DriveModeShare

type DriveModeShare struct {
	Mode string
	Pct  float64
}

DriveModeShare is a single (mode, percentage) row computed from time spent in each unique drive_mode value across the windowed samples. Modes are lowercased and trimmed for stable keys; the label stays as-the-mode-arrived for human readability ("All-Purpose" vs the lowercased "all-purpose" key).

func DriveModeShares

func DriveModeShares(ss []samples.Sample) []DriveModeShare

DriveModeShares returns time-share by drive mode, sorted descending by share. Empty samples or all-blank-mode inputs return nil so the prompt builder can drop the field cleanly.

type EfficiencyFactor

type EfficiencyFactor struct {
	Name              string  `json:"name"`
	ImpactEstimatePct float64 `json:"impact_estimate_pct"`
	Confidence0To100  int     `json:"confidence_0_to_100"`
	MagnitudeKWh      float64 `json:"magnitude_kwh,omitempty"`
	Evidence          string  `json:"evidence,omitempty"`
}

EfficiencyFactor is a single contributor to efficiency variance, scored by the model. Negative impacts mean it hurt efficiency, positive means it helped.

MagnitudeKWh and Evidence are newer schema fields:

  • MagnitudeKWh: signed kWh impact on this specific drive. Lets the SPA show concrete loss in addition to the relative percent and lets us sum factors and sanity-check against EnergyUsedKWh.
  • Evidence: short string citing the data point that justified the factor (e.g. "Headwind 18 mph from Open-Meteo at start"). Forces the model to commit to a citation; the SPA renders it beneath the factor name as a verifiable subtitle.

Both are optional in the JSON wire format and on legacy stored rows so the SPA renders defensively without breaking older analyses generated before the field landed.

type EfficiencyInputs

type EfficiencyInputs struct {
	Drive            drives.Drive
	Samples          []samples.Sample
	UseFahrenheit    bool
	BaselineMiPerKWh float64
	BaselineDays     int
	Weather          *Weather

	// Vehicle profile (per-vehicle, set in Settings). Nil/empty
	// fields are silently dropped from the prompt.
	VehicleProfile *VehicleProfile

	// Per-trip transient context, populated from the request body
	// on the efficiency POST. ExtraLoadLb is in pounds; Towing is
	// a hint that an external load (trailer, hauler) was attached.
	ExtraLoadLb float64
	Towing      bool
}

EfficiencyInputs is the bag of data the prompt builder consumes.

type EfficiencyParsed

type EfficiencyParsed struct {
	Factors        []EfficiencyFactor `json:"factors,omitempty"`
	Recommendation string             `json:"recommendation,omitempty"`
	Forecast       string             `json:"forecast,omitempty"`
	Summary        string             `json:"summary,omitempty"`
}

EfficiencyParsed is the structured shape the model returns. Every field is optional; the SPA falls back to Analysis (raw text) when parsing fails.

func ParseEfficiency

func ParseEfficiency(s string) *EfficiencyParsed

ParseEfficiency tolerates ```json fences and leading/trailing whitespace around the JSON envelope. Returns nil on parse failure.

type EfficiencyResult

type EfficiencyResult struct {
	Analysis     string
	Parsed       *EfficiencyParsed
	Model        string
	InputTokens  int64
	OutputTokens int64
}

EfficiencyResult is what GenerateEfficiency returns to the handler.

func GenerateEfficiency

func GenerateEfficiency(ctx context.Context, a *ai.Analyzer, in EfficiencyInputs) (EfficiencyResult, error)

GenerateEfficiency calls the analyzer with a system+user prompt and returns the parsed result. Caller controls the timeout via ctx.

type Highlight

type Highlight struct {
	Label string `json:"label"`
	Value string `json:"value"`
}

Highlight is one badge-sized stat the SPA renders next to the prose. The model picks 2-4 per recap; we re-derive ours from the drive on the server so callers can rely on a non-empty list even when the model omits the field.

type Inputs

type Inputs struct {
	Drive   drives.Drive
	Samples []samples.Sample
	// AdjacentCharges are charge sessions that started or ended within
	// ~30 min of the drive window. The prompt mentions them so the
	// recap can say "charged at Buc-ee's afterwards" without us
	// having to reverse-geocode anything client-side.
	AdjacentCharges []charges.Charge
	// UseFahrenheit toggles the temperature unit shown in the prompt.
	// Distance is always miles + feet because the app surface is
	// imperial everywhere; revisit if a metric pref ever lands.
	UseFahrenheit bool
	// BaselineMiPerKWh is the user's energy efficiency over the
	// trailing BaselineDays before this drive (drives with a known
	// EnergyUsedKWh only). Zero when there's not enough history. The
	// prompt uses this so the model can ground comments like "3.1
	// mi/kWh, well above your 2.7 mi/kWh average" instead of making
	// up the comparison.
	BaselineMiPerKWh float64
	BaselineDays     int

	// Weather is the optional weather snapshot at the trip's start.
	// Nil when the operator hasn't enabled the recap weather toggle
	// (Settings -> AI), or when the upstream lookup failed. The
	// prompt builder skips its block cleanly on nil so a fetch
	// failure never poisons the recap.
	Weather *Weather
}

Inputs bundles every piece of context the prompt builder needs. Keeping this struct shallow makes the prompt unit-testable: feed it deterministic values, snapshot the resulting prompt string.

type Parsed

type Parsed struct {
	Headline   string      `json:"headline,omitempty"`
	Body       string      `json:"body,omitempty"`
	Highlights []Highlight `json:"highlights,omitempty"`
	// Mood is one of: commute, errand, road-trip, mountain, city,
	// scenic, quick-trip. Free-form on the wire so the SPA can
	// gracefully render unknown values as a neutral chip rather than
	// dropping them, but the prompt asks for the closed set above.
	Mood string `json:"mood,omitempty"`
}

Parsed is the structured shape the model emits when it follows the JSON instructions. Callers should treat every field as optional and fall back to the raw Recap text on parse failure.

func ParseRecap

func ParseRecap(s string) *Parsed

ParseRecap extracts the structured shape from a model reply. Tries (in order):

  1. Direct json.Unmarshal of the whole string.
  2. The substring between the first "{" and the last "}".
  3. nil — caller falls back to plain prose.

Safe to call on legacy plain-text recaps: returns nil, no error.

type PowerAnalysis

type PowerAnalysis struct {
	// Total energy drawn from the pack across the drive (kWh).
	DrawKwh float64
	// Total energy recovered via regen (kWh, always >= 0).
	RegenKwh float64
	// Regen as a percentage of consumption — 0 when there's no
	// usable signal. Typical EV drives sit in the 10–30 % range;
	// city stop-and-go or long downhill descents push above 40 %.
	RegenPct float64
}

PowerAnalysis is a Go port of analyzeDrivePower from web/src/lib/power.ts. It estimates instantaneous battery-side power across a drive from physics (speed + acceleration + grade + drag) and integrates it into total draw and regen totals. The two implementations need to stay in lock-step: the SPA uses the TS copy for the chart's red/green ribbon and the "Regen recovered" tile; this copy feeds the efficiency analyzer's prompt.

Why not derive from SoC delta? Because Rivian reports BatteryLevelPct at 1% resolution (see internal/rivian/ws.go — vs.BatteryLevel.Value is integer percent). On a 135 kWh pack that's 1.35 kWh per step. A 5-second hard regen at 50 kW recovers ~0.07 kWh = 5% of one step, so most braking events don't move the SoC integer at all and are invisible to the derivative.

func AnalyzeDrivePower

func AnalyzeDrivePower(ss []samples.Sample, d drives.Drive) PowerAnalysis

AnalyzeDrivePower runs the physics model on the windowed sample set and returns net draw, regen, and ratio. Returns the zero value when the sample set is too thin for a meaningful estimate.

type Result

type Result struct {
	// Recap is the raw model reply, persisted as-is in drive_recaps
	// so reads can re-parse without paying the LLM. For new recaps
	// this is a JSON object matching Parsed; for legacy rows it's
	// the original 60-word paragraph.
	Recap        string
	Parsed       *Parsed
	Model        string
	InputTokens  int64
	OutputTokens int64
}

Result is what the generator returns to the handler.

func Generate

func Generate(ctx context.Context, a *ai.Analyzer, in Inputs) (Result, error)

Generate calls the LLM and returns a structured recap. Caller (handler) owns the cache. The analyzer must be non-nil; the handler 503s before reaching here when AI is unconfigured.

The model is asked for strict JSON, but providers occasionally wrap it in a code fence or prefix it with chatter. ParseRecap is tolerant: it strips the most common framings and falls back to a {body: <text>} shape if all parse attempts fail, so the SPA still has something to render.

type SpeedBand

type SpeedBand struct {
	Label string
	Pct   float64
}

SpeedDistribution returns the percentage of drive time spent in each speed band. Bands match the route-map color buckets in web/src/components/DriveMap.tsx so the prompt and the user's UI agree on what "highway" means. Returned in canonical order (low to high) so the prompt block reads stop-to-cruise.

func SpeedBuckets

func SpeedBuckets(ss []samples.Sample) []SpeedBand

SpeedBuckets returns time-weighted percentages by speed band for the drive. Returns nil when the sample window is too short to be meaningful (< 3 samples or < 30 s of total time).

type StopStats

type StopStats struct {
	Count     int
	IdleSec   float64
	HasSignal bool
}

StopStats counts low-speed dwell events (a "stop") and total time spent below 5 mph. A "stop" is an interval of consecutive samples with avg speed < 5 mph that lasts at least 8 s — short enough to catch traffic-light waits, long enough to skip momentary GPS hiccups during steady cruise.

func Stops

func Stops(ss []samples.Sample) StopStats

type VehicleProfile

type VehicleProfile struct {
	TireType           string
	WheelInches        int
	Accessories        []string
	DefaultExtraLoadLb float64
	FrequentlyTows     bool
	// TirePlacardPSI is the user-set door-jamb cold-fill pressure.
	// Zero means unset — the prompt then sends raw psi and lets the
	// model use its training-data prior for placard.
	TirePlacardPSI float64
}

VehicleProfile is the prompt-side view of the user's per-vehicle settings. Mirrors db.VehicleProfile but kept local so the recap package doesn't depend on the db package.

type Weather

type Weather struct {
	TempC         float64
	ApparentTempC float64
	WindKPH       float64
	WindDirDeg    float64
	HeadwindKPH   float64
	PrecipMM      float64
	HumidityPct   float64
	Conditions    string
	HasTemp       bool
	HasApparent   bool
	HasWind       bool
	HasHeadwind   bool
	HasPrecip     bool
	HasHumidity   bool
	HasConditions bool
}

Weather is the prompt-ready view of weather conditions at a drive's start. The recap handler populates this from internal/weather; the recap package has no dependency on the upstream provider, so swapping providers stays a one-package change. Numeric fields are zero when the upstream omitted the metric; callers should set the matching Has flag to disambiguate (only WindDirDeg has 0.0 as a valid value).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL