Changeset 0.29.3 (#198)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -1259,15 +1259,10 @@ func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context,
|
||||
// buildWorkflowFormHint loads the current workflow stage's form_template
|
||||
// and returns a system message instructing the persona what to collect.
|
||||
// Returns empty string if not a workflow or no template defined.
|
||||
// v0.29.3: migrated from raw database.DB to stores, stage_mode aware.
|
||||
func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string {
|
||||
var workflowID *string
|
||||
var currentStage int
|
||||
_ = database.DB.QueryRowContext(ctx, database.Q(`
|
||||
SELECT workflow_id, COALESCE(current_stage, 0)
|
||||
FROM channels WHERE id = $1 AND type = 'workflow'
|
||||
`), channelID).Scan(&workflowID, ¤tStage)
|
||||
|
||||
if workflowID == nil || *workflowID == "" {
|
||||
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil || ws.WorkflowID == nil || *ws.WorkflowID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1275,17 +1270,47 @@ func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID
|
||||
return ""
|
||||
}
|
||||
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, *workflowID)
|
||||
if err != nil || currentStage >= len(stages) {
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil || ws.CurrentStage >= len(stages) {
|
||||
return ""
|
||||
}
|
||||
|
||||
stage := stages[ws.CurrentStage]
|
||||
|
||||
// v0.29.3: form_only stages don't use LLM — no hint needed
|
||||
if stage.StageMode == models.StageModeFormOnly {
|
||||
return ""
|
||||
}
|
||||
|
||||
stage := stages[currentStage]
|
||||
if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse form template to extract field descriptions
|
||||
// v0.29.3: Try typed form template first
|
||||
if tpl := models.ParseTypedFormTemplate(stage.FormTemplate); tpl != nil {
|
||||
hint := "You are in workflow stage: " + stage.Name + ".\n\n"
|
||||
if stage.StageMode == models.StageModeFormChat {
|
||||
hint += "A form has been provided for the visitor to fill in structured data. " +
|
||||
"You can assist them with questions about the form fields or discuss related topics.\n\n" +
|
||||
"Form fields:\n"
|
||||
} else {
|
||||
hint += "Collect the following information from the user through natural conversation:\n\n"
|
||||
}
|
||||
for _, f := range tpl.Fields {
|
||||
hint += "- " + f.Label
|
||||
if f.Required {
|
||||
hint += " (required)"
|
||||
}
|
||||
hint += "\n"
|
||||
}
|
||||
if stage.StageMode != models.StageModeFormChat {
|
||||
hint += "\nWhen you have gathered all required information, call the workflow_advance tool " +
|
||||
"with the collected data as a JSON object. Do not advance until all required fields are filled."
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
// Legacy format: map[string]interface{} with {description, required}
|
||||
var fields map[string]interface{}
|
||||
if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil {
|
||||
return ""
|
||||
|
||||
@@ -123,10 +123,15 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
|
||||
// Set session cookie (30 day expiry, matching v0.24.3)
|
||||
c.SetCookie("sb_session", sessionToken, 30*24*60*60, "/", "", false, true)
|
||||
|
||||
// Redirect to chat — visitor lands on existing /w/:channelId
|
||||
// Redirect to workflow page — visitor lands on existing /w/:channelId
|
||||
stageMode := stages[0].StageMode
|
||||
if stageMode == "" {
|
||||
stageMode = "chat_only"
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"channel_id": ch.ID,
|
||||
"session_id": sess.ID,
|
||||
"redirect_to": "/w/" + ch.ID,
|
||||
"stage_mode": stageMode,
|
||||
})
|
||||
}
|
||||
|
||||
287
server/handlers/workflow_forms.go
Normal file
287
server/handlers/workflow_forms.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
// ── Workflow Form Handler ─────────────────────
|
||||
// Handles typed form submission for workflow stages.
|
||||
|
||||
type WorkflowFormHandler struct {
|
||||
stores store.Stores
|
||||
runner *sandbox.Runner
|
||||
hub *events.Hub
|
||||
}
|
||||
|
||||
func NewWorkflowFormHandler(stores store.Stores, runner *sandbox.Runner, hub *events.Hub) *WorkflowFormHandler {
|
||||
return &WorkflowFormHandler{stores: stores, runner: runner, hub: hub}
|
||||
}
|
||||
|
||||
// GetFormTemplate returns the current stage's typed form template.
|
||||
// GET /api/v1/w/:id/form
|
||||
func (h *WorkflowFormHandler) GetFormTemplate(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
channelID := c.Param("id")
|
||||
|
||||
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
if ws.WorkflowID == nil || *ws.WorkflowID == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not a workflow channel"})
|
||||
return
|
||||
}
|
||||
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil || ws.CurrentStage >= len(stages) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "stage not found"})
|
||||
return
|
||||
}
|
||||
|
||||
stage := stages[ws.CurrentStage]
|
||||
tpl := models.ParseTypedFormTemplate(stage.FormTemplate)
|
||||
|
||||
// Check if form was already submitted for this stage
|
||||
var formSubmitted bool
|
||||
if len(ws.StageData) > 0 {
|
||||
var sd map[string]interface{}
|
||||
if json.Unmarshal(ws.StageData, &sd) == nil {
|
||||
_, formSubmitted = sd["_form_submitted"]
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stage_name": stage.Name,
|
||||
"stage_mode": stage.StageMode,
|
||||
"form_template": tpl,
|
||||
"form_submitted": formSubmitted,
|
||||
"current_stage": ws.CurrentStage,
|
||||
"status": ws.Status,
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitForm handles form data submission for a workflow stage.
|
||||
// POST /api/v1/w/:id/form-submit
|
||||
func (h *WorkflowFormHandler) SubmitForm(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
channelID := c.Param("id")
|
||||
|
||||
// 1. Verify workflow channel
|
||||
ws, err := h.stores.Channels.GetWorkflowStatus(ctx, channelID)
|
||||
if err != nil || ws == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow channel not found"})
|
||||
return
|
||||
}
|
||||
if ws.Status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow is " + ws.Status + ", cannot submit form"})
|
||||
return
|
||||
}
|
||||
if ws.WorkflowID == nil || *ws.WorkflowID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a workflow channel"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Load stage definition
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
|
||||
if err != nil || ws.CurrentStage >= len(stages) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage not found"})
|
||||
return
|
||||
}
|
||||
stage := stages[ws.CurrentStage]
|
||||
|
||||
if stage.StageMode == models.StageModeChatOnly {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this stage does not accept form submissions"})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Parse typed form template
|
||||
tpl := models.ParseTypedFormTemplate(stage.FormTemplate)
|
||||
if tpl == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage has no typed form template"})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Parse submitted data (flat fields per ICD spec)
|
||||
var formData map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&formData); err != nil || len(formData) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "form data is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Go-level validation
|
||||
if errs := models.ValidateFormData(tpl, formData); len(errs) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": errs})
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Starlark validate hook (if configured)
|
||||
if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.Validate != "" {
|
||||
if hookErrs := h.runValidateHook(c, tpl.Hooks, channelID, formData, ws.StageData, stage.Name); hookErrs != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation failed", "errors": hookErrs})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Merge into stage_data
|
||||
formData["_form_submitted"] = true
|
||||
dataJSON, _ := json.Marshal(formData)
|
||||
mergedData := tools.MergeWorkflowStageData(ctx, h.stores.Channels, channelID, dataJSON)
|
||||
|
||||
// 8. Starlark on_submit hook (fire-and-forget)
|
||||
if tpl.Hooks != nil && tpl.Hooks.PackageID != "" && tpl.Hooks.OnSubmit != "" {
|
||||
go h.runOnSubmitHook(tpl.Hooks, channelID, formData, json.RawMessage(mergedData), stage.Name)
|
||||
}
|
||||
|
||||
// 9. Auto-advance if form_only + auto_transition
|
||||
if stage.StageMode == models.StageModeFormOnly && stage.AutoTransition {
|
||||
nextStage := ws.CurrentStage + 1
|
||||
if nextStage >= len(stages) {
|
||||
// Complete
|
||||
_ = h.stores.Channels.CompleteWorkflow(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
||||
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "")
|
||||
h.emitFormEvent(channelID, "workflow.completed", ws.CurrentStage)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage})
|
||||
return
|
||||
}
|
||||
_ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, nextStage, json.RawMessage(mergedData))
|
||||
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "")
|
||||
h.emitFormEvent(channelID, "workflow.advanced", nextStage)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "advanced", "current_stage": nextStage, "stage": stages[nextStage]})
|
||||
return
|
||||
}
|
||||
|
||||
// 10. Not auto-advancing — persist stage_data and confirm submission
|
||||
_ = h.stores.Channels.AdvanceWorkflowStage(ctx, channelID, ws.CurrentStage, json.RawMessage(mergedData))
|
||||
tools.CreateWorkflowStageNote(ctx, h.stores, channelID, ws.CurrentStage, dataJSON, "")
|
||||
h.emitFormEvent(channelID, "workflow.form_submitted", ws.CurrentStage)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "submitted", "current_stage": ws.CurrentStage})
|
||||
}
|
||||
|
||||
// ── Starlark Hooks ──────────────────────────
|
||||
|
||||
func (h *WorkflowFormHandler) runValidateHook(c *gin.Context, hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) []models.FieldError {
|
||||
if h.runner == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), hooks.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
log.Printf("[workflow-forms] validate hook: package %s not found", hooks.PackageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctxDict := starlark.NewDict(4)
|
||||
_ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data))
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData)))
|
||||
_ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName))
|
||||
_ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID))
|
||||
|
||||
val, _, err := h.runner.CallEntryPoint(c.Request.Context(), pkg, hooks.Validate,
|
||||
starlark.Tuple{ctxDict}, nil, nil)
|
||||
if err != nil {
|
||||
log.Printf("[workflow-forms] validate hook error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return parseValidationResult(val)
|
||||
}
|
||||
|
||||
func (h *WorkflowFormHandler) runOnSubmitHook(hooks *models.FormHooks, channelID string, data map[string]interface{}, stageData json.RawMessage, stageName string) {
|
||||
if h.runner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pkg, err := h.stores.Packages.Get(ctx, hooks.PackageID)
|
||||
if err != nil || pkg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctxDict := starlark.NewDict(4)
|
||||
_ = ctxDict.SetKey(starlark.String("data"), jsonToStarlark(data))
|
||||
_ = ctxDict.SetKey(starlark.String("stage_data"), starlark.String(string(stageData)))
|
||||
_ = ctxDict.SetKey(starlark.String("stage_name"), starlark.String(stageName))
|
||||
_ = ctxDict.SetKey(starlark.String("channel_id"), starlark.String(channelID))
|
||||
|
||||
_, _, err = h.runner.CallEntryPoint(ctx, pkg, hooks.OnSubmit,
|
||||
starlark.Tuple{ctxDict}, nil, nil)
|
||||
if err != nil {
|
||||
log.Printf("[workflow-forms] on_submit hook error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func (h *WorkflowFormHandler) emitFormEvent(channelID, label string, stage int) {
|
||||
if h.hub == nil {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"stage": stage,
|
||||
})
|
||||
ctx := context.Background()
|
||||
pids, err := h.stores.Channels.ListUserParticipantIDs(ctx, channelID, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
evt := events.Event{Label: label, Payload: payload}
|
||||
for _, uid := range pids {
|
||||
h.hub.SendToUser(uid, evt)
|
||||
}
|
||||
}
|
||||
|
||||
// parseValidationResult extracts field errors from a Starlark return value.
|
||||
// Expected: None (pass) or {"errors": [{"key": "...", "message": "..."}, ...]}
|
||||
func parseValidationResult(val starlark.Value) []models.FieldError {
|
||||
if val == nil || val == starlark.None {
|
||||
return nil
|
||||
}
|
||||
d, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
errVal, found, _ := d.Get(starlark.String("errors"))
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
list, ok := errVal.(*starlark.List)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var errs []models.FieldError
|
||||
iter := list.Iterate()
|
||||
defer iter.Done()
|
||||
var item starlark.Value
|
||||
for iter.Next(&item) {
|
||||
ed, ok := item.(*starlark.Dict)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyVal, _, _ := ed.Get(starlark.String("key"))
|
||||
msgVal, _, _ := ed.Get(starlark.String("message"))
|
||||
k, _ := keyVal.(starlark.String)
|
||||
m, _ := msgVal.(starlark.String)
|
||||
if k != "" && m != "" {
|
||||
errs = append(errs, models.FieldError{Key: string(k), Message: string(m)})
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -192,8 +192,8 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) {
|
||||
|
||||
nextStageDef := stages[nextStage]
|
||||
|
||||
// Bind next persona
|
||||
if nextStageDef.PersonaID != nil {
|
||||
// Bind next persona (skip for form_only — no LLM needed)
|
||||
if nextStageDef.StageMode != models.StageModeFormOnly && nextStageDef.PersonaID != nil {
|
||||
alreadyIn, _ := h.stores.Channels.IsParticipant(ctx, channelID, "persona", *nextStageDef.PersonaID)
|
||||
if !alreadyIn {
|
||||
_ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{
|
||||
|
||||
@@ -188,6 +188,13 @@ func (h *WorkflowHandler) CreateStage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||
return
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeChatOnly
|
||||
}
|
||||
if !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"})
|
||||
return
|
||||
}
|
||||
if st.Ordinal == 0 {
|
||||
existing, _ := h.stores.Workflows.ListStages(c.Request.Context(), st.WorkflowID)
|
||||
st.Ordinal = len(existing)
|
||||
@@ -213,6 +220,10 @@ func (h *WorkflowHandler) UpdateStage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "history_mode must be full, summary, or fresh"})
|
||||
return
|
||||
}
|
||||
if st.StageMode != "" && !models.ValidStageModes[st.StageMode] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "stage_mode must be chat_only, form_only, or form_chat"})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Workflows.UpdateStage(c.Request.Context(), &st); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update stage"})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user