package api import ( "context" "errors" "net/http" "strings" "github.com/loyaly/behavision-server/internal/auth" ) // The assistant, as an HTTP route. // // Session-authenticated like every other person-facing endpoint, and the // principal it derives is handed to every tool the model calls - so the // assistant can only ever see what the person asking could already see. // Assistant is what the API needs from the assistant package. // // Declared HERE with the api package's own types, because `assistant` imports // `api` for the report and camera shapes - so the dependency can only run one // way, and main.go supplies a small adapter. That also makes the handler // testable with no API key and no network call. type Assistant interface { Configured() bool Ask(ctx context.Context, p auth.Principal, history []AssistantTurn) (AssistantAnswer, error) } // ErrAssistantOff is returned when no API key is configured. A supported // state, not a fault. var ErrAssistantOff = errors.New("the assistant is not switched on for this server") // ErrAssistantMisconfigured means the credentials are present but incomplete - // today, an identity-linked API key with no workspace id. var ErrAssistantMisconfigured = errors.New("the assistant is configured incorrectly") type AssistantTurn struct { Role string `json:"role"` Text string `json:"text"` } type AssistantAnswer struct { Text string `json:"text"` Used []string `json:"used,omitempty"` } // AssistantRequest is one question plus the conversation so far. The client // holds the history: this server keeps no chat state, so there is no per-user // transcript sitting in a database that nobody agreed to. type AssistantRequest struct { History []AssistantTurn `json:"history"` } const ( // A conversation longer than this is not a support question any more, and // every turn is resent on every request. maxAssistantTurns = 24 maxQuestionChars = 2000 ) func (s *Server) handleAssistant(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if s.Assistant == nil || !s.Assistant.Configured() { // 501, not 500. "This deployment has no assistant" is a supported // configuration; the UI hides the panel rather than showing an error. writeErr(w, http.StatusNotImplemented, "assistant_off", "The assistant is not switched on for this server.") return } var body AssistantRequest if err := decode(w, r, &body); err != nil { badRequest(w, err.Error()) return } if len(body.History) == 0 { badRequest(w, "ask a question") return } if len(body.History) > maxAssistantTurns { // Keep the most recent turns rather than refusing: a long conversation // is a person still trying to solve their problem. body.History = body.History[len(body.History)-maxAssistantTurns:] } for i := range body.History { body.History[i].Text = clip(trim(body.History[i].Text), maxQuestionChars) if body.History[i].Role != "assistant" { body.History[i].Role = "user" } } if strings.TrimSpace(body.History[len(body.History)-1].Text) == "" { badRequest(w, "ask a question") return } answer, err := s.Assistant.Ask(r.Context(), p, body.History) if err != nil { if errors.Is(err, ErrAssistantOff) { writeErr(w, http.StatusNotImplemented, "assistant_off", "The assistant is not switched on for this server.") return } if errors.Is(err, ErrAssistantMisconfigured) { // Told to the operator, not swallowed. "Something went wrong at our // end" is true and useless when the fix is one environment // variable, and this failure happens on the very first request so // it is exactly when a clear message is worth most. s.logf("ERROR assistant: %v", err) writeErr(w, http.StatusServiceUnavailable, "assistant_misconfigured", "The assistant is switched on but not configured correctly. "+ "This API key needs ANTHROPIC_WORKSPACE_ID set on the server.") return } s.serverError(w, "assistant", err) return } writeJSON(w, http.StatusOK, answer) }