1- package api
1+ package ainldash
22
33// Phase-50 / 0059 — PU3 Natural-language dashboard composer.
44//
@@ -72,6 +72,7 @@ package api
7272// slice.
7373
7474import (
75+ "bytes"
7576 "context"
7677 "encoding/json"
7778 "errors"
@@ -90,6 +91,7 @@ import (
9091 "github.com/ev-dev-labs/teslasync/internal/ai/stream"
9192 "github.com/ev-dev-labs/teslasync/internal/ai/tools"
9293 "github.com/ev-dev-labs/teslasync/internal/ai/tools/nlq"
94+ apihttpx "github.com/ev-dev-labs/teslasync/internal/api/httpx"
9395 tsauth "github.com/ev-dev-labs/teslasync/internal/auth"
9496)
9597
@@ -111,27 +113,27 @@ const aiNLDashboardComposerMaxBodyBytes = 16 * 1024
111113// message out of the window.
112114const aiNLDashboardComposerMaxPromptChars = 1200
113115
114- // AINLDashboardComposerCatalogSource is the narrow read
116+ // CatalogSource is the narrow read
115117// interface the handler consumes to load the curated install-
116118// wide dashboard panel catalog. Production wiring satisfies it
117- // via AINLDashboardComposerCatalogSourceImpl , which returns a
119+ // via CatalogSourceImpl , which returns a
118120// hardcoded whitelist of pre-validated panel templates so the
119121// AI can never name a panel outside the curated set.
120122//
121123// The interface is intentionally narrow (one method) so test
122124// fakes stay small and the production implementation cannot
123125// accidentally widen the surface.
124- type AINLDashboardComposerCatalogSource interface {
126+ type CatalogSource interface {
125127 // DashboardComposerCatalog returns the curated install-wide
126128 // catalog at the time of the call. The returned slice MUST
127129 // be safe for the caller to retain.
128- DashboardComposerCatalog (ctx context.Context ) ([]AINLDashboardComposerPanelEntry , error )
130+ DashboardComposerCatalog (ctx context.Context ) ([]PanelEntry , error )
129131}
130132
131- // AINLDashboardComposerPanelEntry describes one curated
133+ // PanelEntry describes one curated
132134// dashboard panel the LLM is allowed to use as a slot.
133135// Mirrors the SPA's CuratedDashboardPanel type 1:1.
134- type AINLDashboardComposerPanelEntry struct {
136+ type PanelEntry struct {
135137 // Name is the canonical panel slug used as the
136138 // slot.panel_name when the LLM proposes a layout. Lower-
137139 // case to match Grafana folding.
@@ -151,23 +153,23 @@ type aiNLDashboardComposerRequest struct {
151153 Prompt string `json:"prompt"`
152154}
153155
154- // AINLDashboardComposerHandler is the HTTP handler for
156+ // Handler is the HTTP handler for
155157// POST /api/v1/ai/power/dashboard/draft.
156158//
157159// Stateless beyond its constructor inputs; safe for concurrent
158160// use across requests. Construction is in router.go so the
159161// dispatcher's tool registry + provider registry are wired once
160162// at boot.
161- type AINLDashboardComposerHandler struct {
163+ type Handler struct {
162164 registry * provider.Registry
163165 tools * tools.Registry
164166 strategy strategy.Strategy
165- source AINLDashboardComposerCatalogSource
167+ source CatalogSource
166168 headerName string
167169 maxIters int
168170}
169171
170- // NewAINLDashboardComposerHandler constructs the handler. All
172+ // NewHandler constructs the handler. All
171173// non-pointer arguments are required; the constructor panics on
172174// a nil so the wiring bug surfaces at boot, not at first
173175// request.
@@ -186,31 +188,31 @@ type AINLDashboardComposerHandler struct {
186188//
187189// process).
188190//
189- // source: the production AINLDashboardComposerCatalogSource
191+ // source: the production CatalogSource
190192//
191- // (AINLDashboardComposerCatalogSourceImpl in router.go).
193+ // (CatalogSourceImpl in router.go).
192194//
193195// headerName: forward-auth header name; used to extract subject
194196//
195197// for audit.
196- func NewAINLDashboardComposerHandler (
198+ func NewHandler (
197199 registry * provider.Registry ,
198200 toolReg * tools.Registry ,
199201 strat strategy.Strategy ,
200- source AINLDashboardComposerCatalogSource ,
202+ source CatalogSource ,
201203 headerName string ,
202- ) * AINLDashboardComposerHandler {
204+ ) * Handler {
203205 switch {
204206 case registry == nil :
205- panic ("api: NewAINLDashboardComposerHandler : nil provider.Registry" )
207+ panic ("ainldash: NewHandler : nil provider.Registry" )
206208 case toolReg == nil :
207- panic ("api: NewAINLDashboardComposerHandler : nil tools.Registry" )
209+ panic ("ainldash: NewHandler : nil tools.Registry" )
208210 case strat == nil :
209- panic ("api: NewAINLDashboardComposerHandler : nil strategy.Strategy" )
211+ panic ("ainldash: NewHandler : nil strategy.Strategy" )
210212 case source == nil :
211- panic ("api: NewAINLDashboardComposerHandler : nil AINLDashboardComposerCatalogSource " )
213+ panic ("ainldash: NewHandler : nil CatalogSource " )
212214 }
213- return & AINLDashboardComposerHandler {
215+ return & Handler {
214216 registry : registry ,
215217 tools : toolReg ,
216218 strategy : strat ,
@@ -236,7 +238,7 @@ func parseNLDashboardComposerRequest(w http.ResponseWriter, r *http.Request) (ai
236238 writeError (w , http .StatusBadRequest , fmt .Sprintf ("failed to read body: %v" , readErr ))
237239 return req , false
238240 }
239- if len (bytesTrim (bodyBytes )) == 0 {
241+ if len (bytes . TrimSpace (bodyBytes )) == 0 {
240242 writeError (w , http .StatusBadRequest , "empty body" )
241243 return req , false
242244 }
@@ -265,7 +267,7 @@ func parseNLDashboardComposerRequest(w http.ResponseWriter, r *http.Request) (ai
265267// Every error path either writes a structured frame onto the
266268// SSE stream (when the writer has been opened) or a plain JSON
267269// 4xx/5xx (before it has).
268- func (h * AINLDashboardComposerHandler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
270+ func (h * Handler ) ServeHTTP (w http.ResponseWriter , r * http.Request ) {
269271 // 1) Parse + validate the request body.
270272 req , ok := parseNLDashboardComposerRequest (w , r )
271273 if ! ok {
@@ -365,7 +367,7 @@ func (h *AINLDashboardComposerHandler) ServeHTTP(w http.ResponseWriter, r *http.
365367// only the bare ground-truth metadata keeps the transcript
366368// volume minimal AND makes the goldens stable across catalog
367369// churn.
368- func buildNLDashboardComposerUserMessage (prompt string , catalog []AINLDashboardComposerPanelEntry ) string {
370+ func buildNLDashboardComposerUserMessage (prompt string , catalog []PanelEntry ) string {
369371 var b strings.Builder
370372
371373 b .WriteString ("Suggest a single typed DashboardLayoutDraft that satisfies the user's request below. " )
@@ -377,7 +379,7 @@ func buildNLDashboardComposerUserMessage(prompt string, catalog []AINLDashboardC
377379 b .WriteString ("Do NOT claim the dashboard was created, applied, exported, or pushed — the user reviews the proposal in the AI side panel and clicks the Apply to editor button to copy the draft into the manual dashboard composer on /power/dashboards, then clicks Copy to clipboard to paste it into their existing Grafana dashboard editor." )
378380
379381 // Sort panels by name for deterministic prompt hashing.
380- panels := append ([]AINLDashboardComposerPanelEntry (nil ), catalog ... )
382+ panels := append ([]PanelEntry (nil ), catalog ... )
381383 sort .Slice (panels , func (i , j int ) bool { return panels [i ].Name < panels [j ].Name })
382384 if len (panels ) == 0 {
383385 b .WriteString ("\n \n In-scope curated panel catalog: NONE.\n " )
@@ -396,9 +398,9 @@ func buildNLDashboardComposerUserMessage(prompt string, catalog []AINLDashboardC
396398 return b .String ()
397399}
398400
399- // Compile-time assertion: AINLDashboardComposerHandler satisfies
401+ // Compile-time assertion: Handler satisfies
400402// http.Handler.
401- var _ http.Handler = (* AINLDashboardComposerHandler )(nil )
403+ var _ http.Handler = (* Handler )(nil )
402404
403405// ---------------------------------------------------------------------
404406// Production wiring for the source + validator interfaces declared
@@ -419,7 +421,7 @@ var _ http.Handler = (*AINLDashboardComposerHandler)(nil)
419421// generated via nl-grafana-panel (slice 0058) OR a stock starter
420422// the install ships with; the dashboard composer just picks
421423// among them and arranges them on the grid.
422- var nlDashboardComposerCuratedPanels = []AINLDashboardComposerPanelEntry {
424+ var nlDashboardComposerCuratedPanels = []PanelEntry {
423425 {
424426 Name : "drives_per_day_timeseries" ,
425427 Description : "Timeseries panel: SUM(distance_m)/day from the drives table" ,
@@ -446,41 +448,41 @@ var nlDashboardComposerCuratedPanels = []AINLDashboardComposerPanelEntry{
446448 },
447449}
448450
449- // AINLDashboardComposerCatalogSourceImpl is the production
450- // AINLDashboardComposerCatalogSource . It returns the hardcoded
451+ // CatalogSourceImpl is the production
452+ // CatalogSource . It returns the hardcoded
451453// curated whitelist so the AI can never name a panel outside
452454// the curated set.
453455//
454456// No DB query — the catalog is hand-maintained. A future slice
455457// that needs per-tenant catalog gating can swap this out without
456458// churning the handler.
457- type AINLDashboardComposerCatalogSourceImpl struct {}
459+ type CatalogSourceImpl struct {}
458460
459- // NewAINLDashboardComposerCatalogSource constructs the adapter.
461+ // NewCatalogSource constructs the adapter.
460462// No deps. Returned by-pointer for symmetry with the other AI*
461463// source types.
462- func NewAINLDashboardComposerCatalogSource () * AINLDashboardComposerCatalogSourceImpl {
463- return & AINLDashboardComposerCatalogSourceImpl {}
464+ func NewCatalogSource () * CatalogSourceImpl {
465+ return & CatalogSourceImpl {}
464466}
465467
466468// DashboardComposerCatalog implements
467- // AINLDashboardComposerCatalogSource . Returns a defensive copy
469+ // CatalogSource . Returns a defensive copy
468470// of the curated whitelist so a caller cannot retroactively
469471// mutate the source-of-truth slice.
470- func (a * AINLDashboardComposerCatalogSourceImpl ) DashboardComposerCatalog (_ context.Context ) ([]AINLDashboardComposerPanelEntry , error ) {
471- out := make ([]AINLDashboardComposerPanelEntry , len (nlDashboardComposerCuratedPanels ))
472+ func (a * CatalogSourceImpl ) DashboardComposerCatalog (_ context.Context ) ([]PanelEntry , error ) {
473+ out := make ([]PanelEntry , len (nlDashboardComposerCuratedPanels ))
472474 copy (out , nlDashboardComposerCuratedPanels )
473475 return out , nil
474476}
475477
476478// Compile-time assertion.
477- var _ AINLDashboardComposerCatalogSource = (* AINLDashboardComposerCatalogSourceImpl )(nil )
479+ var _ CatalogSource = (* CatalogSourceImpl )(nil )
478480
479481// ---------------------------------------------------------------------
480482// Production wiring for the nlq.DashboardLayoutValidator interface.
481483// ---------------------------------------------------------------------
482484
483- // AINLDashboardComposerValidator is the production
485+ // Validator is the production
484486// nlq.DashboardLayoutValidator. The shape checks (panel-name
485487// catalog, slot count, per-slot grid bounds, duplicate-panel
486488// detector, overlap detector) are already enforced by the
@@ -501,13 +503,13 @@ var _ AINLDashboardComposerCatalogSource = (*AINLDashboardComposerCatalogSourceI
501503// the user reviews the typed proposal before clicking Apply.
502504//
503505// Stateless. Held by value; safe for concurrent use.
504- type AINLDashboardComposerValidator struct {}
506+ type Validator struct {}
505507
506- // NewAINLDashboardComposerValidator constructs the validator.
508+ // NewValidator constructs the validator.
507509// No deps. Returned by-pointer for symmetry with the other AI*
508510// validator types.
509- func NewAINLDashboardComposerValidator () * AINLDashboardComposerValidator {
510- return & AINLDashboardComposerValidator {}
511+ func NewValidator () * Validator {
512+ return & Validator {}
511513}
512514
513515// ValidateDashboardLayout implements
@@ -517,12 +519,21 @@ func NewAINLDashboardComposerValidator() *AINLDashboardComposerValidator {
517519// slices need them. Keeping the body intentionally minimal so
518520// the slice's mandate ("propose-only, no semantic surprises")
519521// is locally legible.
520- func (v * AINLDashboardComposerValidator ) ValidateDashboardLayout (draft * nlq.DashboardLayoutDraft ) error {
522+ func (v * Validator ) ValidateDashboardLayout (draft * nlq.DashboardLayoutDraft ) error {
521523 if draft == nil {
522- return errors .New ("api ai nl-dashboard-composer : nil DashboardLayoutDraft" )
524+ return errors .New ("ainldash : nil DashboardLayoutDraft" )
523525 }
524526 return nil
525527}
526528
527529// Compile-time assertion.
528- var _ nlq.DashboardLayoutValidator = (* AINLDashboardComposerValidator )(nil )
530+ var _ nlq.DashboardLayoutValidator = (* Validator )(nil )
531+
532+ func writeError (w http.ResponseWriter , status int , msg string ) {
533+ apihttpx .WriteError (w , status , msg )
534+ }
535+
536+ // denyAllConfirm rejects every mutating tool as defence-in-depth.
537+ func denyAllConfirm (_ context.Context , _ dispatch.ConfirmRequest ) (dispatch.ConfirmDecision , error ) {
538+ return dispatch .ConfirmDenied , nil
539+ }
0 commit comments