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:
2026-03-18 00:15:18 +00:00
committed by xcaliber
parent 115004a3ab
commit 7f191e18cd
22 changed files with 1625 additions and 77 deletions

View File

@@ -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, &currentStage)
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 ""

View File

@@ -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,
})
}

View 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
}

View File

@@ -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{

View File

@@ -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