Last active
December 24, 2025 17:51
-
-
Save imran31415/369c0d9b3bd5afa849fc0b100bdcd7ae to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package llm_server | |
| import ( | |
| "comments/models" | |
| pb "comments/proto/llm/proto" | |
| "context" | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "net/http" | |
| "os" | |
| "strings" | |
| "time" | |
| ) | |
| // HandleFormGreeting generates a natural language greeting message for form fields | |
| func (s *LLMServer) HandleFormGreeting( | |
| ctx context.Context, | |
| req *pb.AskQuestionRequest, | |
| user *models.User, | |
| startTime time.Time, | |
| ) (*pb.AskQuestionResponse, error) { | |
| env := os.Getenv("ENVIRONMENT") | |
| var greeting string | |
| var displayTemplate string | |
| var copyTemplate string | |
| switch env { | |
| case "", "development", "local": | |
| // Dev mode - generate greeting and templates from form fields | |
| greeting, displayTemplate, copyTemplate = s.generateMockFormGreetingWithTemplate(req) | |
| default: | |
| // Production mode - use LLM | |
| prompt := s.buildFormGreetingPrompt(req) | |
| ollamaReq := OllamaRequest{ | |
| Model: "phi3:mini", | |
| Prompt: prompt, | |
| Stream: false, | |
| NumPredict: 300, | |
| Temperature: 0.7, | |
| TopP: 0.9, | |
| Threads: 3, | |
| } | |
| jsonData, err := json.Marshal(ollamaReq) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to marshal request: %v", err) | |
| } | |
| ollamaURL := os.Getenv("OLLAMA_API_URL") | |
| if ollamaURL == "" { | |
| ollamaURL = "envurl/api/generate" | |
| } | |
| client := &http.Client{ | |
| Timeout: 30 * time.Second, | |
| } | |
| resp, err := client.Post(ollamaURL, "application/json", strings.NewReader(string(jsonData))) | |
| if err != nil { | |
| // Fallback to mock on error | |
| greeting, displayTemplate, copyTemplate = s.generateMockFormGreetingWithTemplate(req) | |
| } else { | |
| defer resp.Body.Close() | |
| if resp.StatusCode == http.StatusOK { | |
| bodyBytes, err := io.ReadAll(resp.Body) | |
| if err == nil { | |
| var ollamaResp struct { | |
| Response string `json:"response"` | |
| } | |
| if json.Unmarshal(bodyBytes, &ollamaResp) == nil && ollamaResp.Response != "" { | |
| greeting = strings.TrimSpace(ollamaResp.Response) | |
| } | |
| } | |
| } | |
| if greeting == "" { | |
| greeting, displayTemplate, copyTemplate = s.generateMockFormGreetingWithTemplate(req) | |
| } else { | |
| // Generate templates separately for production | |
| displayTemplate, copyTemplate = s.generateFormTemplates(req) | |
| } | |
| } | |
| } | |
| return &pb.AskQuestionResponse{ | |
| Response: &pb.LLMResponse{ | |
| Answer: greeting, | |
| ConfidenceScore: 0.9, | |
| ModelName: "phi3:mini", | |
| TokensUsed: 50, | |
| LatencyMs: time.Since(startTime).Milliseconds(), | |
| UserPromptTemplate: displayTemplate, | |
| UserPromptTemplateSimple: copyTemplate, | |
| }, | |
| }, nil | |
| } | |
| // generateFormTemplates creates two template strings: one with help text for display, one simplified for copy | |
| func (s *LLMServer) generateFormTemplates(req *pb.AskQuestionRequest) (displayTemplate string, copyTemplate string) { | |
| fields := req.GetFormFields() | |
| var displayLines []string | |
| var copyLines []string | |
| for _, field := range fields { | |
| if !field.Required { | |
| continue | |
| } | |
| // Use AI hint or convert field name to readable format | |
| label := field.AiHint | |
| if label == "" { | |
| name := strings.ReplaceAll(field.FieldName, "_", " ") | |
| name = strings.ReplaceAll(name, "String", "") | |
| // Title case | |
| words := strings.Fields(name) | |
| for i, word := range words { | |
| if len(word) > 0 { | |
| words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) | |
| } | |
| } | |
| label = strings.Join(words, " ") | |
| } else { | |
| // Extract just the label part from AI hint | |
| if idx := strings.Index(label, "("); idx > 0 { | |
| label = strings.TrimSpace(label[:idx]) | |
| } | |
| } | |
| // Skip confirmation fields | |
| if strings.Contains(strings.ToLower(label), "confirm") { | |
| continue | |
| } | |
| // Build the simple template line (just field name) | |
| simpleLine := label + ": " | |
| copyLines = append(copyLines, simpleLine) | |
| // Build the display template line with options | |
| displayLine := label + ":" | |
| if len(field.Options) > 0 && (field.InputType == "select" || field.InputType == "tagSelect") { | |
| // Show options in parentheses | |
| optionsStr := strings.Join(field.Options, ", ") | |
| displayLine = label + " (" + optionsStr + "):" | |
| } | |
| displayLines = append(displayLines, displayLine+" ") | |
| } | |
| return strings.Join(displayLines, "\n"), strings.Join(copyLines, "\n") | |
| } | |
| // generateFormTemplate creates a template string for users to fill out (kept for backward compatibility) | |
| func (s *LLMServer) generateFormTemplate(req *pb.AskQuestionRequest) string { | |
| display, _ := s.generateFormTemplates(req) | |
| return display | |
| } | |
| // generateMockFormGreetingWithTemplate generates a friendly greeting and both templates for dev mode | |
| func (s *LLMServer) generateMockFormGreetingWithTemplate(req *pb.AskQuestionRequest) (greeting string, displayTemplate string, copyTemplate string) { | |
| fields := req.GetFormFields() | |
| fmt.Printf("[FORM_GREETING] Received %d fields\n", len(fields)) | |
| // Extract field labels for natural language | |
| var fieldDescriptions []string | |
| for _, field := range fields { | |
| fmt.Printf("[FORM_GREETING] Field: %s, Required: %t, AiHint: %s\n", field.FieldName, field.Required, field.AiHint) | |
| if !field.Required { | |
| continue | |
| } | |
| // Use AI hint or create from field name | |
| desc := field.AiHint | |
| if desc == "" { | |
| // Convert field name to readable format | |
| name := strings.ReplaceAll(field.FieldName, "_", " ") | |
| name = strings.ReplaceAll(name, "String", "") | |
| desc = strings.ToLower(name) | |
| } else { | |
| // Extract just the label part | |
| if idx := strings.Index(desc, "("); idx > 0 { | |
| desc = strings.TrimSpace(desc[:idx]) | |
| } | |
| desc = strings.ToLower(desc) | |
| } | |
| if desc != "" && desc != "confirmation" { | |
| fieldDescriptions = append(fieldDescriptions, desc) | |
| } | |
| } | |
| // Limit to first 4 fields | |
| if len(fieldDescriptions) > 4 { | |
| fieldDescriptions = fieldDescriptions[:4] | |
| } | |
| var fieldList string | |
| if len(fieldDescriptions) > 0 { | |
| if len(fieldDescriptions) == 1 { | |
| fieldList = fieldDescriptions[0] | |
| } else if len(fieldDescriptions) == 2 { | |
| fieldList = fieldDescriptions[0] + " and " + fieldDescriptions[1] | |
| } else { | |
| last := fieldDescriptions[len(fieldDescriptions)-1] | |
| fieldList = strings.Join(fieldDescriptions[:len(fieldDescriptions)-1], ", ") + ", and " + last | |
| } | |
| } | |
| greeting = "Hi! I can help you fill out this form quickly. Just describe what you want to create in natural language, and I'll extract the relevant information." | |
| if fieldList != "" { | |
| greeting += fmt.Sprintf("\n\nI'll need details like %s.", fieldList) | |
| } | |
| greeting += "\n\nYou can also copy the template below and fill it out:" | |
| // Generate both templates | |
| displayTemplate, copyTemplate = s.generateFormTemplates(req) | |
| return greeting, displayTemplate, copyTemplate | |
| } | |
| // buildFormGreetingPrompt builds the prompt for generating a form greeting | |
| func (s *LLMServer) buildFormGreetingPrompt(req *pb.AskQuestionRequest) string { | |
| fields := req.GetFormFields() | |
| var sb strings.Builder | |
| sb.WriteString("You are a friendly AI assistant helping users fill out forms. Generate a brief, welcoming greeting message.\n\n") | |
| sb.WriteString("FORM FIELDS:\n") | |
| for _, field := range fields { | |
| if field.Required { | |
| sb.WriteString(fmt.Sprintf("- %s", field.AiHint)) | |
| if field.AiHint == "" { | |
| sb.WriteString(field.FieldName) | |
| } | |
| sb.WriteString("\n") | |
| } | |
| } | |
| sb.WriteString("\nGENERATE a friendly greeting that:\n") | |
| sb.WriteString("1. Welcomes the user warmly\n") | |
| sb.WriteString("2. Explains they can describe what they want in natural language\n") | |
| sb.WriteString("3. Mentions 3-4 key pieces of information needed (in plain English, not field names)\n") | |
| sb.WriteString("4. Gives a brief example\n") | |
| sb.WriteString("5. Keep it under 100 words\n\n") | |
| sb.WriteString("Return ONLY the greeting message, no extra formatting.") | |
| return sb.String() | |
| } | |
| // HandleFormSynthesis handles generic form field extraction from conversation | |
| func (s *LLMServer) HandleFormSynthesis( | |
| ctx context.Context, | |
| req *pb.AskQuestionRequest, | |
| user *models.User, | |
| startTime time.Time, | |
| ) (*pb.AskQuestionResponse, error) { | |
| var answer string | |
| var formData *pb.FormSynthesisData | |
| // Always use LLM for form synthesis - it's critical functionality | |
| systemPrompt := s.buildFormSynthesisPrompt(req) | |
| userPrompt := s.buildFormSynthesisUserPrompt(req) | |
| fullPrompt := systemPrompt + "\n\nUser: " + userPrompt + "\n\nAssistant:" | |
| ollamaReq := OllamaRequest{ | |
| Model: "phi3:mini", | |
| Prompt: fullPrompt, | |
| Stream: false, | |
| NumPredict: 1000, | |
| Temperature: 0.1, // Very low for deterministic JSON output | |
| TopP: 0.9, | |
| Threads: 3, | |
| } | |
| jsonData, err := json.Marshal(ollamaReq) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to marshal request: %v", err) | |
| } | |
| ollamaURL := os.Getenv("OLLAMA_API_URL") | |
| if ollamaURL == "" { | |
| ollamaURL = "http://envurl/api/generate" | |
| } | |
| client := &http.Client{ | |
| Timeout: 120 * time.Second, | |
| } | |
| resp, err := client.Post(ollamaURL, "application/json", strings.NewReader(string(jsonData))) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to call Ollama: %v", err) | |
| } | |
| defer resp.Body.Close() | |
| if resp.StatusCode != http.StatusOK { | |
| bodyBytes, _ := io.ReadAll(resp.Body) | |
| return nil, fmt.Errorf("Ollama service returned non-200: %d. Response: %s", resp.StatusCode, string(bodyBytes)) | |
| } | |
| bodyBytes, err := io.ReadAll(resp.Body) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to read response body: %v", err) | |
| } | |
| var ollamaResp struct { | |
| Response string `json:"response"` | |
| } | |
| if err := json.Unmarshal(bodyBytes, &ollamaResp); err != nil { | |
| return nil, fmt.Errorf("failed to decode JSON response: %v. Raw response: %s", err, string(bodyBytes[:Min(200, len(bodyBytes))])) | |
| } | |
| if ollamaResp.Response == "" { | |
| return nil, fmt.Errorf("received empty response from Ollama service") | |
| } | |
| fmt.Printf("[FORM_SYNTHESIS] Raw LLM Response: %s\n", ollamaResp.Response) | |
| // Parse the form synthesis response | |
| formData, answer = s.parseFormSynthesisResponse(ollamaResp.Response, req.GetFormFields()) | |
| fmt.Printf("[FORM_SYNTHESIS] System prompt: %d chars | Fields: %d | Complete: %t | Latency: %d ms\n", | |
| len(systemPrompt), len(req.GetFormFields()), formData.IsComplete, time.Since(startTime).Milliseconds()) | |
| return &pb.AskQuestionResponse{ | |
| Response: &pb.LLMResponse{ | |
| Answer: answer, | |
| ConfidenceScore: 0.85, | |
| ModelName: "phi3:mini", | |
| TokensUsed: 100, | |
| LatencyMs: time.Since(startTime).Milliseconds(), | |
| }, | |
| FormSynthesisData: formData, | |
| }, nil | |
| } | |
| // generateMockFormSynthesis generates mock form synthesis data for development | |
| func (s *LLMServer) generateMockFormSynthesis(req *pb.AskQuestionRequest) *pb.FormSynthesisData { | |
| fields := req.GetFormFields() | |
| currentData := req.GetCurrentFormData() | |
| question := req.GetQuestion() | |
| conversationHistory := req.GetConversationHistory() | |
| extractedFields := make([]*pb.ExtractedField, 0) | |
| missingFields := make([]string, 0) | |
| // In dev mode, try to extract some fields based on simple heuristics | |
| questionLower := strings.ToLower(question) | |
| // Try to extract field values from patterns like "fieldName is value" or "fieldName: value" | |
| extractedFromMessage := s.extractFieldValuesFromMessage(questionLower, fields) | |
| // Check conversation history to understand context - if the last assistant message | |
| // was asking about a specific field and the user response is just a value, map it | |
| contextField := s.extractContextFromHistory(conversationHistory, fields, question) | |
| if contextField != "" { | |
| // User provided a bare value in response to a specific question | |
| if _, exists := extractedFromMessage[contextField]; !exists { | |
| extractedFromMessage[contextField] = strings.TrimSpace(question) | |
| fmt.Printf("[FORM_SYNTHESIS] Extracted '%s' for field '%s' from conversation context\n", question, contextField) | |
| } | |
| } | |
| for _, field := range fields { | |
| // Skip if already filled | |
| if val, exists := currentData[field.FieldName]; exists && val != "" { | |
| extractedFields = append(extractedFields, &pb.ExtractedField{ | |
| FieldName: field.FieldName, | |
| Value: val, | |
| Confidence: 1.0, | |
| }) | |
| continue | |
| } | |
| // Check if this field was extracted from the message | |
| var extracted string | |
| var confidence float32 | |
| if val, exists := extractedFromMessage[field.FieldName]; exists { | |
| extracted = val | |
| confidence = 0.9 | |
| } | |
| // If not extracted from message patterns, try other heuristics | |
| if extracted == "" { | |
| switch field.InputType { | |
| case "text": | |
| if strings.Contains(field.FieldName, "name") || strings.Contains(field.FieldName, "title") { | |
| // Extract first quoted string or capitalized phrase | |
| if idx := strings.Index(question, "\""); idx != -1 { | |
| endIdx := strings.Index(question[idx+1:], "\"") | |
| if endIdx != -1 { | |
| extracted = question[idx+1 : idx+1+endIdx] | |
| confidence = 0.9 | |
| } | |
| } | |
| } | |
| case "datetime": | |
| // Check for common time patterns | |
| if strings.Contains(questionLower, "today") || strings.Contains(questionLower, "tomorrow") || | |
| strings.Contains(questionLower, "pm") || strings.Contains(questionLower, "am") { | |
| loc, _ := time.LoadLocation("America/Los_Angeles") | |
| t := time.Now().In(loc).Add(2 * time.Hour) | |
| extracted = t.Format(time.RFC3339) | |
| confidence = 0.7 | |
| } | |
| case "select": | |
| // Check if any option is mentioned | |
| for _, opt := range field.Options { | |
| if strings.Contains(questionLower, strings.ToLower(opt)) { | |
| extracted = opt | |
| confidence = 0.9 | |
| break | |
| } | |
| } | |
| } | |
| } | |
| if extracted != "" { | |
| extractedFields = append(extractedFields, &pb.ExtractedField{ | |
| FieldName: field.FieldName, | |
| Value: extracted, | |
| Confidence: confidence, | |
| }) | |
| } else { | |
| // Use default value or skip value instead of marking as missing | |
| var defaultVal string | |
| var defaultConfidence float32 = 0.5 | |
| if field.DefaultValue != "" { | |
| defaultVal = field.DefaultValue | |
| } else if field.SkipValue != "" { | |
| defaultVal = field.SkipValue | |
| } else if field.CanUseDefault { | |
| // Generate sensible defaults based on field type/name | |
| switch { | |
| case strings.Contains(strings.ToLower(field.FieldName), "location") || strings.Contains(strings.ToLower(field.FieldName), "city"): | |
| defaultVal = "Santa Barbara" | |
| case strings.Contains(strings.ToLower(field.FieldName), "address"): | |
| defaultVal = "Santa Barbara, CA" | |
| case strings.Contains(strings.ToLower(field.FieldName), "duration"): | |
| defaultVal = "2" | |
| case strings.Contains(strings.ToLower(field.FieldName), "public") || strings.Contains(strings.ToLower(field.FieldName), "visibility"): | |
| if len(field.Options) > 0 { | |
| defaultVal = field.Options[0] | |
| } else { | |
| defaultVal = "true" | |
| } | |
| case strings.Contains(strings.ToLower(field.FieldName), "confirm"): | |
| defaultVal = "yes" | |
| case strings.Contains(strings.ToLower(field.FieldName), "tag") || strings.Contains(strings.ToLower(field.FieldName), "interest"): | |
| defaultVal = "" | |
| case len(field.Options) > 0: | |
| defaultVal = field.Options[0] | |
| default: | |
| defaultVal = "" | |
| } | |
| } | |
| if defaultVal != "" || field.CanUseDefault { | |
| extractedFields = append(extractedFields, &pb.ExtractedField{ | |
| FieldName: field.FieldName, | |
| Value: defaultVal, | |
| Confidence: defaultConfidence, | |
| }) | |
| } else if field.Required { | |
| // Only mark as missing if truly required and no default available | |
| missingFields = append(missingFields, field.FieldName) | |
| } | |
| } | |
| } | |
| // In dev mode with defaults, most forms should be complete | |
| isComplete := len(missingFields) == 0 | |
| // Generate follow-up question only if there are truly missing required fields | |
| var followupQuestion string | |
| if len(missingFields) > 0 { | |
| followupQuestion = s.generateFollowupQuestion(missingFields, fields) | |
| } | |
| // Generate summary | |
| var summary strings.Builder | |
| summary.WriteString("DEV MODE - Extracted fields: ") | |
| for i, field := range extractedFields { | |
| if i > 0 { | |
| summary.WriteString(", ") | |
| } | |
| summary.WriteString(fmt.Sprintf("%s=%s", field.FieldName, field.Value)) | |
| } | |
| return &pb.FormSynthesisData{ | |
| ExtractedFields: extractedFields, | |
| MissingFields: missingFields, | |
| FollowupQuestion: followupQuestion, | |
| IsComplete: isComplete, | |
| Summary: summary.String(), | |
| } | |
| } | |
| // buildFormSynthesisPrompt builds the system prompt for form synthesis | |
| func (s *LLMServer) buildFormSynthesisPrompt(req *pb.AskQuestionRequest) string { | |
| fields := req.GetFormFields() | |
| currentData := req.GetCurrentFormData() | |
| loc, _ := time.LoadLocation("America/Los_Angeles") | |
| now := time.Now().In(loc) | |
| nowFormatted := now.Format("Mon Jan 2, 2006 3:04 PM MST") | |
| todayDate := now.Format("2006-01-02") | |
| var sb strings.Builder | |
| sb.WriteString("You are a helpful form-filling assistant. Your goal is to FILL OUT AS MUCH AS POSSIBLE using sensible defaults. The user can always edit later.\n\n") | |
| sb.WriteString("CURRENT DATE & TIME: ") | |
| sb.WriteString(nowFormatted) | |
| sb.WriteString("\n\n") | |
| sb.WriteString("FORM FIELDS TO FILL:\n") | |
| for _, field := range fields { | |
| sb.WriteString(fmt.Sprintf("- %s (%s)", field.FieldName, field.InputType)) | |
| if field.Required { | |
| sb.WriteString(" [REQUIRED]") | |
| } else { | |
| sb.WriteString(" [OPTIONAL]") | |
| } | |
| sb.WriteString("\n") | |
| sb.WriteString(fmt.Sprintf(" Prompt: %s\n", field.Prompt)) | |
| if field.AiHint != "" { | |
| sb.WriteString(fmt.Sprintf(" Hint: %s\n", field.AiHint)) | |
| } | |
| if len(field.Options) > 0 { | |
| sb.WriteString(fmt.Sprintf(" Options: %s\n", strings.Join(field.Options, ", "))) | |
| } | |
| // Show default value info | |
| if field.CanUseDefault { | |
| if field.DefaultValue != "" { | |
| sb.WriteString(fmt.Sprintf(" DEFAULT: Use '%s' if not specified\n", field.DefaultValue)) | |
| } else if field.SkipValue != "" { | |
| sb.WriteString(fmt.Sprintf(" SKIPPABLE: Use '%s' to skip\n", field.SkipValue)) | |
| } else { | |
| sb.WriteString(" CAN DEFAULT: Use sensible default if not specified\n") | |
| } | |
| } | |
| } | |
| if len(currentData) > 0 { | |
| sb.WriteString("\nALREADY FILLED FIELDS (keep these values):\n") | |
| for key, value := range currentData { | |
| sb.WriteString(fmt.Sprintf("- %s: %s\n", key, value)) | |
| } | |
| } | |
| sb.WriteString("\nGUIDELINES:\n") | |
| sb.WriteString("- If the user provides a value, use it\n") | |
| sb.WriteString("- Ask follow-up questions for important missing fields (name, date/time, location)\n") | |
| sb.WriteString("- If a field has a DEFAULT value, use it when user doesn't specify after 1-2 exchanges\n") | |
| sb.WriteString("- For select fields, default to the first option if not specified\n") | |
| sb.WriteString(fmt.Sprintf("- Today's date is: %s\n", todayDate)) | |
| sb.WriteString("- If the conversation has gone 3+ exchanges, fill remaining fields with defaults and complete the form\n") | |
| sb.WriteString("- Balance being helpful (asking questions) with being efficient (using defaults)\n\n") | |
| sb.WriteString("RESPONSE FORMAT - Use this EXACT format:\n") | |
| sb.WriteString("```\n") | |
| sb.WriteString("FIELD: field_name = value\n") | |
| sb.WriteString("FIELD: another_field = another value\n") | |
| sb.WriteString("SUMMARY: Brief description\n") | |
| sb.WriteString("```\n\n") | |
| sb.WriteString("RULES:\n") | |
| sb.WriteString("- Output one FIELD line for EVERY field\n") | |
| sb.WriteString("- Values must be CLEAN - no explanations, no parentheses, no annotations\n") | |
| sb.WriteString("- WRONG: FIELD: name = My Business (default value)\n") | |
| sb.WriteString("- RIGHT: FIELD: name = My Business\n") | |
| sb.WriteString("- Use the exact field_name from the form fields\n") | |
| sb.WriteString("- For datetime fields, use ISO 8601 format (e.g., ") | |
| sb.WriteString(todayDate) | |
| sb.WriteString("T14:00:00-08:00)\n") | |
| sb.WriteString("- For select fields, use values from the provided options list\n") | |
| sb.WriteString("- Keep already-filled field values unless user explicitly changes them\n") | |
| sb.WriteString("- End with a SUMMARY line\n") | |
| return sb.String() | |
| } | |
| // buildFormSynthesisUserPrompt builds the user prompt including conversation history | |
| func (s *LLMServer) buildFormSynthesisUserPrompt(req *pb.AskQuestionRequest) string { | |
| var sb strings.Builder | |
| // Include conversation history | |
| if len(req.GetConversationHistory()) > 0 { | |
| sb.WriteString("CONVERSATION HISTORY:\n") | |
| for _, msg := range req.GetConversationHistory() { | |
| sb.WriteString(fmt.Sprintf("%s: %s\n", strings.Title(msg.Role), msg.Content)) | |
| } | |
| sb.WriteString("\n") | |
| } | |
| sb.WriteString("LATEST USER MESSAGE:\n") | |
| sb.WriteString(req.GetQuestion()) | |
| return sb.String() | |
| } | |
| // parseFormSynthesisResponse parses the LLM response for form synthesis | |
| // Expects format: "FIELD: field_name = value" lines | |
| func (s *LLMServer) parseFormSynthesisResponse(response string, fields []*pb.FormFieldDefinition) (*pb.FormSynthesisData, string) { | |
| fmt.Printf("[FORM_SYNTHESIS] Parsing response: %s\n", response) | |
| // Build a map of valid field names for quick lookup | |
| validFields := make(map[string]bool) | |
| fmt.Printf("[FORM_SYNTHESIS] Expected fields: ") | |
| for i, field := range fields { | |
| validFields[field.FieldName] = true | |
| // Also add lowercase version | |
| validFields[strings.ToLower(field.FieldName)] = true | |
| if i > 0 { | |
| fmt.Printf(", ") | |
| } | |
| fmt.Printf("%s", field.FieldName) | |
| } | |
| fmt.Printf("\n") | |
| // Parse the simple line-based format | |
| extractedFields := make([]*pb.ExtractedField, 0) | |
| extractedFieldNames := make(map[string]bool) | |
| var summary string | |
| lines := strings.Split(response, "\n") | |
| for _, line := range lines { | |
| line = strings.TrimSpace(line) | |
| // Skip empty lines and comments | |
| if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { | |
| continue | |
| } | |
| // Parse FIELD: field_name = value | |
| if strings.HasPrefix(line, "FIELD:") { | |
| rest := strings.TrimPrefix(line, "FIELD:") | |
| rest = strings.TrimSpace(rest) | |
| // Split on first "=" | |
| parts := strings.SplitN(rest, "=", 2) | |
| if len(parts) == 2 { | |
| fieldName := strings.TrimSpace(parts[0]) | |
| value := strings.TrimSpace(parts[1]) | |
| // Find the actual field name (case-insensitive match) | |
| actualFieldName := fieldName | |
| for _, field := range fields { | |
| if strings.EqualFold(field.FieldName, fieldName) { | |
| actualFieldName = field.FieldName | |
| break | |
| } | |
| } | |
| if value != "" && value != "null" && value != "N/A" { | |
| extractedFields = append(extractedFields, &pb.ExtractedField{ | |
| FieldName: actualFieldName, | |
| Value: value, | |
| Confidence: 0.9, | |
| }) | |
| extractedFieldNames[actualFieldName] = true | |
| } | |
| } | |
| } | |
| // Parse SUMMARY: text | |
| if strings.HasPrefix(line, "SUMMARY:") { | |
| summary = strings.TrimSpace(strings.TrimPrefix(line, "SUMMARY:")) | |
| } | |
| } | |
| // Find missing required fields | |
| missingFields := make([]string, 0) | |
| for _, field := range fields { | |
| if field.Required && !extractedFieldNames[field.FieldName] { | |
| missingFields = append(missingFields, field.FieldName) | |
| fmt.Printf("[FORM_SYNTHESIS] Missing required field: %s\n", field.FieldName) | |
| } | |
| } | |
| // Determine if complete | |
| isComplete := len(missingFields) == 0 | |
| // Generate follow-up question if needed | |
| var followupQuestion string | |
| if !isComplete { | |
| followupQuestion = s.generateFollowupQuestion(missingFields, fields) | |
| } | |
| if summary == "" { | |
| summary = fmt.Sprintf("Extracted %d fields", len(extractedFields)) | |
| } | |
| formData := &pb.FormSynthesisData{ | |
| ExtractedFields: extractedFields, | |
| MissingFields: missingFields, | |
| FollowupQuestion: followupQuestion, | |
| IsComplete: isComplete, | |
| Summary: summary, | |
| } | |
| var answer string | |
| if isComplete { | |
| answer = fmt.Sprintf("Form complete! %s", summary) | |
| } else { | |
| answer = fmt.Sprintf("Extracted %d fields. %s", len(extractedFields), followupQuestion) | |
| } | |
| fmt.Printf("[FORM_SYNTHESIS] Parsed %d fields, %d missing, complete: %t\n", | |
| len(extractedFields), len(missingFields), isComplete) | |
| return formData, answer | |
| } | |
| // getRequiredFieldNames returns the names of all required fields | |
| func (s *LLMServer) getRequiredFieldNames(fields []*pb.FormFieldDefinition) []string { | |
| required := make([]string, 0) | |
| for _, field := range fields { | |
| if field.Required { | |
| required = append(required, field.FieldName) | |
| } | |
| } | |
| return required | |
| } | |
| // generateFollowupQuestion generates a natural follow-up question for missing fields | |
| func (s *LLMServer) generateFollowupQuestion(missingFields []string, fields []*pb.FormFieldDefinition) string { | |
| if len(missingFields) == 0 { | |
| return "" | |
| } | |
| // Get the prompts and hints for missing fields | |
| fieldInfo := make(map[string]*pb.FormFieldDefinition) | |
| for _, field := range fields { | |
| fieldInfo[field.FieldName] = field | |
| } | |
| // Convert field names to natural language descriptions | |
| getNaturalName := func(fieldName string) string { | |
| field := fieldInfo[fieldName] | |
| if field == nil { | |
| return fieldName | |
| } | |
| // Use AI hint if available, it's usually more descriptive | |
| if field.AiHint != "" { | |
| hint := strings.ToLower(field.AiHint) | |
| // Extract just the label part before any parentheses | |
| if idx := strings.Index(hint, "("); idx > 0 { | |
| hint = strings.TrimSpace(hint[:idx]) | |
| } | |
| if hint != "" { | |
| return hint | |
| } | |
| } | |
| // Convert common field names to natural language | |
| nameLower := strings.ToLower(fieldName) | |
| switch { | |
| case strings.Contains(nameLower, "addressstring") || strings.Contains(nameLower, "address"): | |
| return "the address" | |
| case strings.Contains(nameLower, "duration"): | |
| return "the duration" | |
| case strings.Contains(nameLower, "name"): | |
| return "a name" | |
| case strings.Contains(nameLower, "description"): | |
| return "a description" | |
| case strings.Contains(nameLower, "location") || strings.Contains(nameLower, "city"): | |
| return "the location" | |
| case strings.Contains(nameLower, "time") || strings.Contains(nameLower, "date"): | |
| return "the date and time" | |
| case strings.Contains(nameLower, "image"): | |
| return "an image" | |
| case strings.Contains(nameLower, "interest") || strings.Contains(nameLower, "tag"): | |
| return "the categories" | |
| default: | |
| return fieldName | |
| } | |
| } | |
| if len(missingFields) == 1 { | |
| // For single field, ask directly | |
| naturalName := getNaturalName(missingFields[0]) | |
| return fmt.Sprintf("Could you tell me %s?", naturalName) | |
| } | |
| // Multiple missing fields - create a combined question | |
| var sb strings.Builder | |
| sb.WriteString("I still need a few details: ") | |
| for i, fieldName := range missingFields { | |
| naturalName := getNaturalName(fieldName) | |
| if i > 0 { | |
| if i == len(missingFields)-1 { | |
| sb.WriteString(", and ") | |
| } else { | |
| sb.WriteString(", ") | |
| } | |
| } | |
| sb.WriteString(naturalName) | |
| } | |
| sb.WriteString(". Could you provide these?") | |
| return sb.String() | |
| } | |
| // extractContextFromHistory checks the conversation history to determine what field | |
| // the assistant was asking about, so we can map bare user responses to the correct field | |
| func (s *LLMServer) extractContextFromHistory(history []*pb.ConversationMessage, fields []*pb.FormFieldDefinition, userResponse string) string { | |
| if len(history) == 0 { | |
| return "" | |
| } | |
| // Find the last assistant message | |
| var lastAssistantMsg string | |
| for i := len(history) - 1; i >= 0; i-- { | |
| if history[i].Role == "assistant" { | |
| lastAssistantMsg = strings.ToLower(history[i].Content) | |
| break | |
| } | |
| } | |
| if lastAssistantMsg == "" { | |
| return "" | |
| } | |
| // Check if the user response looks like just a value (no field names mentioned) | |
| userResponseLower := strings.ToLower(userResponse) | |
| hasFieldMention := false | |
| for _, field := range fields { | |
| fieldNameLower := strings.ToLower(field.FieldName) | |
| if strings.Contains(userResponseLower, fieldNameLower) { | |
| hasFieldMention = true | |
| break | |
| } | |
| } | |
| // If user mentioned a field name, let the normal extraction handle it | |
| if hasFieldMention { | |
| return "" | |
| } | |
| // Find which field the assistant was asking about | |
| // Check for patterns like "Could you tell me X?" or "I still need X" | |
| for _, field := range fields { | |
| fieldNameLower := strings.ToLower(field.FieldName) | |
| // Convert camelCase/snake_case to readable form for matching | |
| readableName := strings.ReplaceAll(fieldNameLower, "_", " ") | |
| readableName = strings.ReplaceAll(readableName, "string", "") | |
| readableName = strings.TrimSpace(readableName) | |
| // Check various patterns that indicate the assistant was asking about this field | |
| patterns := []string{ | |
| "could you tell me " + readableName, | |
| "could you tell me " + fieldNameLower, | |
| "could you provide " + readableName, | |
| "could you provide " + fieldNameLower, | |
| "what is the " + readableName, | |
| "what is the " + fieldNameLower, | |
| "what's the " + readableName, | |
| "what's the " + fieldNameLower, | |
| "need " + readableName, | |
| "need " + fieldNameLower, | |
| "missing: " + readableName, | |
| "missing: " + fieldNameLower, | |
| } | |
| // Also check AI hints | |
| if field.AiHint != "" { | |
| hintLower := strings.ToLower(field.AiHint) | |
| // Extract key phrase from hint | |
| if idx := strings.Index(hintLower, "("); idx > 0 { | |
| hintLower = strings.TrimSpace(hintLower[:idx]) | |
| } | |
| patterns = append(patterns, "could you tell me "+hintLower) | |
| patterns = append(patterns, "need "+hintLower) | |
| } | |
| for _, pattern := range patterns { | |
| if strings.Contains(lastAssistantMsg, pattern) { | |
| fmt.Printf("[FORM_SYNTHESIS] Found context: assistant asked about '%s' (field: %s)\n", pattern, field.FieldName) | |
| return field.FieldName | |
| } | |
| } | |
| } | |
| // Fallback: if the assistant message ends with asking about exactly one missing field | |
| // Look for "I still need a few details: X" or "Could you tell me X?" | |
| for _, field := range fields { | |
| fieldNameLower := strings.ToLower(field.FieldName) | |
| readableName := strings.ReplaceAll(fieldNameLower, "_", " ") | |
| readableName = strings.ReplaceAll(readableName, "string", "") | |
| readableName = strings.TrimSpace(readableName) | |
| // Check if this is the ONLY field mentioned in the question | |
| if strings.Contains(lastAssistantMsg, readableName+"?") || strings.HasSuffix(strings.TrimSpace(lastAssistantMsg), readableName+"?") { | |
| fmt.Printf("[FORM_SYNTHESIS] Found single-field question for '%s'\n", field.FieldName) | |
| return field.FieldName | |
| } | |
| } | |
| return "" | |
| } | |
| func (s *LLMServer) extractFieldValuesFromMessage(message string, fields []*pb.FormFieldDefinition) map[string]string { | |
| extracted := make(map[string]string) | |
| // Build a list of all possible field labels (for finding boundaries) | |
| var allFieldLabels []string | |
| for _, field := range fields { | |
| fieldNameLower := strings.ToLower(field.FieldName) | |
| readableName := strings.ReplaceAll(fieldNameLower, "_", " ") | |
| readableName = strings.ReplaceAll(readableName, "string", "") | |
| readableName = strings.TrimSpace(readableName) | |
| allFieldLabels = append(allFieldLabels, fieldNameLower) | |
| if readableName != fieldNameLower { | |
| allFieldLabels = append(allFieldLabels, readableName) | |
| } | |
| } | |
| for _, field := range fields { | |
| fieldNameLower := strings.ToLower(field.FieldName) | |
| // Also try readable name (e.g., "business name" instead of "name") | |
| readableName := strings.ReplaceAll(fieldNameLower, "_", " ") | |
| readableName = strings.ReplaceAll(readableName, "string", "") | |
| readableName = strings.TrimSpace(readableName) | |
| // Try multiple patterns to extract values | |
| patterns := []string{ | |
| fieldNameLower + " is ", | |
| fieldNameLower + ": ", | |
| fieldNameLower + "=", | |
| fieldNameLower + " = ", | |
| } | |
| // Also add readable name patterns | |
| if readableName != fieldNameLower { | |
| patterns = append(patterns, | |
| readableName + " is ", | |
| readableName + ": ", | |
| readableName + "=", | |
| readableName + " = ", | |
| ) | |
| } | |
| // Also match patterns with options in parentheses like "status (active, inactive):" | |
| patterns = append(patterns, fieldNameLower+" (") | |
| if readableName != fieldNameLower { | |
| patterns = append(patterns, readableName+" (") | |
| } | |
| for _, pattern := range patterns { | |
| idx := strings.Index(message, pattern) | |
| if idx != -1 { | |
| // If pattern ends with "(", find the closing ")" and then ":" | |
| valueStart := idx + len(pattern) | |
| if strings.HasSuffix(pattern, "(") { | |
| // Find closing parenthesis | |
| closeIdx := strings.Index(message[valueStart:], "):") | |
| if closeIdx != -1 { | |
| valueStart = valueStart + closeIdx + 2 // Skip past "):" | |
| } else { | |
| continue // Malformed, skip this pattern | |
| } | |
| } | |
| valueEnd := len(message) | |
| // Find end of value - check for newline first (template format) | |
| newlineIdx := strings.Index(message[valueStart:], "\n") | |
| if newlineIdx != -1 { | |
| valueEnd = valueStart + newlineIdx | |
| } | |
| // Also check for next field patterns on same line | |
| for _, nextLabel := range allFieldLabels { | |
| for _, nextPattern := range []string{" and " + nextLabel, ", " + nextLabel, "; " + nextLabel, "\n" + nextLabel} { | |
| nextIdx := strings.Index(message[valueStart:], nextPattern) | |
| if nextIdx != -1 && valueStart+nextIdx < valueEnd { | |
| valueEnd = valueStart + nextIdx | |
| } | |
| } | |
| } | |
| value := strings.TrimSpace(message[valueStart:valueEnd]) | |
| // Clean up trailing punctuation | |
| value = strings.TrimSuffix(value, ".") | |
| value = strings.TrimSuffix(value, ",") | |
| value = strings.TrimSuffix(value, ";") | |
| if value != "" { | |
| extracted[field.FieldName] = value | |
| } | |
| break | |
| } | |
| } | |
| } | |
| return extracted | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment