Pulse/internal/api/chat_service_adapter.go
rcourtman 6340cd36f2 Dedupe internal/api handler families behind shared flows and generics
Clears the sixteen dupl pair groups in internal/api plus the pkg/pulsecli
pair:

- router.go: privileged settings endpoints share the
  serveSetupTokenOrSettingsWrite gate; patrol findings convert via one
  unifiedFindingFromAI; the five infrastructure-summary chart loops share
  collectGuestChartData / fillChartSeriesFromBatch; the VM/LXC workloads
  summary loops share appendGuestWorkloadSummaries.
- deploy_handlers.go: preflight and job status/SSE handlers share
  handleDeployJobStatus / handleDeployJobEvents.
- recovery_handlers.go: points series/facets share
  parseRecoveryListPointsOptions.
- truenas_handlers.go / vmware_handlers.go / router_routes_registration.go:
  the connection update flow (locate, decode-with-fallback, normalize,
  preserve masked secrets, validate, save, redact) moves to the new
  platform_connection_shared.go (updatePlatformConnection +
  decodeOptionalInstanceRequest + the admin-gated item-route builder);
  per-platform wrappers carry nolint'd declarative wiring only.
- docker_metadata.go / guest_metadata.go: GET/PUT payload semantics move
  to metadata_handlers_shared.go; the zero-record-instead-of-404 contract
  is pinned by TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404.
- kubernetes_agents.go / docker_agents.go: lifecycle PUTs share
  per-handler action helpers.
- config_node_handlers.go: PBS/PMG probes share
  testProxmoxPlatformConnection.
- cloud_handoff_handlers.go / purchase_return_redemptions.go: secrets
  sqlite stores open through openHardenedSecretsDB so permission
  hardening stays single-sourced.
- ai_handlers.go / chat_service_adapter.go: the GetMessages adapters are
  deliberate contract mirrors — suppressed with nolint and enforced by
  TestOrchestratorAndChatAdaptersMapTheSameMessageFields.
- pkg/pulsecli/actions.go: action subcommands seed env defaults via
  actionAPIDefaults (tested); the audit/events cobra registration pair is
  nolint'd parallel wiring.
- .golangci.yml: exclude gitignored tmp/ from ./... typechecking.
- subsystem_lookup_test.py: refresh the pinned api-contracts.md line
  numbers shifted by the contract additions.

golangci-lint run ./... is now fully green. Full internal/api and
pkg/pulsecli test suites pass.
2026-06-10 10:52:05 +01:00

124 lines
3.9 KiB
Go

package api
import (
"context"
"encoding/json"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// chatServiceAdapter wraps chat.Service to implement ai.ChatServiceProvider.
// This bridges the chat package (concrete) to the ai package (interface) without
// creating an import cycle.
type chatServiceAdapter struct {
svc *chat.Service
}
func (a *chatServiceAdapter) CreateSession(ctx context.Context) (*ai.ChatSession, error) {
session, err := a.svc.CreateSession(ctx)
if err != nil {
return nil, err
}
return &ai.ChatSession{ID: session.ID}, nil
}
func (a *chatServiceAdapter) ExecuteStream(ctx context.Context, req ai.ChatExecuteRequest, callback ai.ChatStreamCallback) error {
return a.svc.ExecuteStream(ctx, chat.ExecuteRequest{
Prompt: req.Prompt,
SessionID: req.SessionID,
}, adaptCallback(callback))
}
func (a *chatServiceAdapter) ExecutePatrolStream(ctx context.Context, req ai.PatrolExecuteRequest, callback ai.ChatStreamCallback) (*ai.PatrolStreamResponse, error) {
resp, err := a.svc.ExecutePatrolStream(ctx, chat.PatrolRequest{
Prompt: req.Prompt,
SystemPrompt: req.SystemPrompt,
SessionID: req.SessionID,
ExecutionID: req.ExecutionID,
UseCase: req.UseCase,
MaxTurns: req.MaxTurns,
}, adaptCallback(callback))
if err != nil {
if resp != nil {
return &ai.PatrolStreamResponse{
Content: resp.Content,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
}, err
}
return nil, err
}
return &ai.PatrolStreamResponse{
Content: resp.Content,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
}, nil
}
//nolint:dupl // mirrors orchestratorChatAdapter.GetMessages: same source messages mapped onto a deliberately separate output contract that may diverge
func (a *chatServiceAdapter) GetMessages(ctx context.Context, sessionID string) ([]ai.ChatMessage, error) {
messages, err := a.svc.GetMessages(ctx, sessionID)
if err != nil {
return nil, err
}
result := make([]ai.ChatMessage, len(messages))
for i, m := range messages {
msg := ai.ChatMessage{
ID: m.ID,
Role: m.Role,
Content: m.Content,
ReasoningContent: m.ReasoningContent,
Timestamp: m.Timestamp,
}
for _, tc := range m.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, ai.ChatToolCall{
ID: tc.ID,
Name: tc.Name,
Input: tc.Input,
})
}
if m.ToolResult != nil {
msg.ToolResult = &ai.ChatToolResult{
ToolUseID: m.ToolResult.ToolUseID,
Content: m.ToolResult.Content,
IsError: m.ToolResult.IsError,
}
}
result[i] = msg.NormalizeCollections()
}
return result, nil
}
func (a *chatServiceAdapter) DeleteSession(ctx context.Context, sessionID string) error {
return a.svc.DeleteSession(ctx, sessionID)
}
func (a *chatServiceAdapter) ReloadConfig(ctx context.Context, cfg *config.AIConfig) error {
return a.svc.Restart(ctx, cfg)
}
func (a *chatServiceAdapter) SetPatrolProviderFactory(factory func(string) (providers.StreamingProvider, error)) {
a.svc.SetPatrolProviderFactory(factory)
}
// GetExecutor exposes the underlying chat service's tool executor so that
// patrol can set the finding creator. This satisfies the
// chatServiceExecutorAccessor interface in the ai package.
func (a *chatServiceAdapter) GetExecutor() *tools.PulseToolExecutor {
return a.svc.GetExecutor()
}
// adaptCallback converts an ai.ChatStreamCallback to a chat.StreamCallback.
// The ai package uses []byte for event data, while the chat package uses json.RawMessage.
func adaptCallback(callback ai.ChatStreamCallback) chat.StreamCallback {
return func(event chat.StreamEvent) {
callback(ai.ChatStreamEvent{
Type: event.Type,
Data: json.RawMessage(event.Data),
})
}
}