package workflow // modes.go — adapters that wrap §3.4 capabilities + §3.5 drift + // distillation scorer as workflow.Mode functions. Each mode follows // the same glue pattern: marshal the generic input map through a // typed struct (so workflow YAML schemas are self-documenting and // validation errors are clear), call the underlying capability, // return a generic output map. // // Pure modes (no I/O): MatrixRelevance, MatrixDowngrade, // DistillationScore, DriftScorer. // // HTTP modes: MatrixSearch + PlaybookRecord — observerd talks to // matrixd over HTTP since the search/record paths need vectord // access. Constructed via factory funcs that take the matrixd base // URL + an http.Client. import ( "bytes" "encoding/json" "fmt" "io" "net/http" "git.agentview.dev/profit/golangLAKEHOUSE/internal/distillation" "git.agentview.dev/profit/golangLAKEHOUSE/internal/drift" "git.agentview.dev/profit/golangLAKEHOUSE/internal/matrix" ) // ─── Pure-function wrappers ───────────────────────────────────── // MatrixRelevance wraps matrix.FilterChunks. Input shape: // // { // "focus": {"Path":"...", "Content":"...", ...}, // "chunks": [{"source":"...", "doc_id":"...", "text":"...", "score":0.8}, ...], // "threshold": 0.3 # optional; default = matrix.DefaultRelevanceThreshold // } // // Output: {"kept":[...], "dropped":[...], "threshold":N, "total_in":N}. func MatrixRelevance(_ Context, input map[string]any) (map[string]any, error) { var req struct { Focus matrix.FocusFile `json:"focus"` Chunks []matrix.CandidateChunk `json:"chunks"` Threshold float64 `json:"threshold"` } if err := remarshalInput(input, &req); err != nil { return nil, fmt.Errorf("matrix.relevance: %w", err) } threshold := req.Threshold if threshold == 0 { threshold = matrix.DefaultRelevanceThreshold } res := matrix.FilterChunks(req.Focus, req.Chunks, threshold) return map[string]any{ "kept": res.Kept, "dropped": res.Dropped, "threshold": res.Threshold, "total_in": res.TotalIn, }, nil } // MatrixDowngrade wraps matrix.MaybeDowngrade. Input shape: // // { // "mode": "codereview_lakehouse", // "model": "x-ai/grok-4.1-fast", // "forced_mode": false, # optional // "force_full_override": false # optional // } // // Output: matrix.DowngradeDecision JSON. func MatrixDowngrade(_ Context, input map[string]any) (map[string]any, error) { var req struct { Mode string `json:"mode"` Model string `json:"model"` ForcedMode bool `json:"forced_mode"` ForceFullOverride bool `json:"force_full_override"` } if err := remarshalInput(input, &req); err != nil { return nil, fmt.Errorf("matrix.downgrade: %w", err) } if req.Mode == "" || req.Model == "" { return nil, fmt.Errorf("matrix.downgrade: mode and model are required") } dec := matrix.MaybeDowngrade(matrix.DowngradeInput{ Mode: req.Mode, Model: req.Model, ForcedMode: req.ForcedMode, ForceFullOverride: req.ForceFullOverride, }) return map[string]any{ "mode": dec.Mode, "downgraded_from": dec.DowngradedFrom, "reason": dec.Reason, }, nil } // MatrixDowngradeWithWeakList is the config-driven variant of // MatrixDowngrade — callers pass cfg.Models.WeakModels at startup // and the closure includes that list in every DowngradeInput. // nil/empty list falls back to matrix.DefaultWeakModels (matching the // plain MatrixDowngrade behavior). func MatrixDowngradeWithWeakList(weakModels []string) Mode { return func(_ Context, input map[string]any) (map[string]any, error) { var req struct { Mode string `json:"mode"` Model string `json:"model"` ForcedMode bool `json:"forced_mode"` ForceFullOverride bool `json:"force_full_override"` } if err := remarshalInput(input, &req); err != nil { return nil, fmt.Errorf("matrix.downgrade: %w", err) } if req.Mode == "" || req.Model == "" { return nil, fmt.Errorf("matrix.downgrade: mode and model are required") } dec := matrix.MaybeDowngrade(matrix.DowngradeInput{ Mode: req.Mode, Model: req.Model, ForcedMode: req.ForcedMode, ForceFullOverride: req.ForceFullOverride, WeakModels: weakModels, }) return map[string]any{ "mode": dec.Mode, "downgraded_from": dec.DowngradedFrom, "reason": dec.Reason, }, nil } } // DistillationScore wraps distillation.ScoreRecord — re-runs the // scorer over a single EvidenceRecord. Useful as a workflow node // that grades a freshly-produced evidence row. // // Input: a JSON EvidenceRecord under the key "record": // // {"record": {"run_id":"...", "task_id":"...", ...}} // // Output: ScoreOutput-ish map with category, reasons, sub_scores. func DistillationScore(_ Context, input map[string]any) (map[string]any, error) { var req struct { Record distillation.EvidenceRecord `json:"record"` } if err := remarshalInput(input, &req); err != nil { return nil, fmt.Errorf("distillation.score: %w", err) } if req.Record.RunID == "" { return nil, fmt.Errorf("distillation.score: record.run_id required") } out := distillation.ScoreRecord(req.Record) return map[string]any{ "category": string(out.Category), "reasons": out.Reasons, "sub_scores": out.SubScores, }, nil } // DriftScorer wraps drift.ComputeScorerDrift. Input shape: // // { // "inputs": [ // {"record": {...EvidenceRecord...}, "persisted_category": "accepted"}, // ... // ], // "include_entries": false # optional, default false // } // // Output: ScorerDriftReport JSON. func DriftScorer(_ Context, input map[string]any) (map[string]any, error) { var req struct { Inputs []drift.ScorerDriftInput `json:"inputs"` IncludeEntries bool `json:"include_entries"` } if err := remarshalInput(input, &req); err != nil { return nil, fmt.Errorf("drift.scorer: %w", err) } if len(req.Inputs) == 0 { return nil, fmt.Errorf("drift.scorer: inputs must be non-empty") } report := drift.ComputeScorerDrift(req.Inputs, req.IncludeEntries) bs, err := json.Marshal(report) if err != nil { return nil, err } var asMap map[string]any if err := json.Unmarshal(bs, &asMap); err != nil { return nil, err } return asMap, nil } // ─── HTTP-backed modes ────────────────────────────────────────── // MatrixSearch returns a workflow.Mode bound to a matrixd base URL // and HTTP client. The mode posts to /v1/matrix/search via the // gateway-internal upstream (caller passes the URL). // // Input shape mirrors matrix.SearchRequest (see retrieve.go). // Output is the matrix.SearchResponse JSON. func MatrixSearch(matrixdURL string, hc *http.Client) Mode { return func(ctx Context, input map[string]any) (map[string]any, error) { bs, err := json.Marshal(input) if err != nil { return nil, fmt.Errorf("matrix.search: marshal: %w", err) } req, err := http.NewRequestWithContext(ctx.Ctx, http.MethodPost, matrixdURL+"/matrix/search", bytes.NewReader(bs)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := hc.Do(req) if err != nil { return nil, fmt.Errorf("matrix.search: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("matrix.search: status %d: %s", resp.StatusCode, body) } var out map[string]any if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, fmt.Errorf("matrix.search: decode: %w", err) } return out, nil } } // ─── Helpers ───────────────────────────────────────────────────── // remarshalInput round-trips a generic input map through JSON into // the typed target struct. Same trick as the matrixd handlers — gives // us schema validation for free without writing custom field-by-field // coercion. func remarshalInput(input map[string]any, target any) error { bs, err := json.Marshal(input) if err != nil { return err } return json.Unmarshal(bs, target) }