Files
sm-golem-plugin/classes/factories/prompt_factory.go
Jakub Zych c93e2d9142 feat: AI models admin, settings import, Anthropic adapter and the rest of AIService
- golem15_golem_models (encrypted write-only api_key) with its cabana list
  and form under golem15.golem.access_settings, and the Settings lookups
  (Models, DefaultModel with PHP's position-0 quirk, Vision, Image, File,
  ByName), every one Trusted
- golem:import-settings copies the WinterCMS golem_settings repeater once
- AnthropicAdapter; SendStream, SendFile/SendFilePath/SendFileReader,
  SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask
- PromptFactory; README documents the config keys, the environment
  variable, the command and the admin screen
2026-10-03 23:09:02 +02:00

213 lines
7.4 KiB
Go

// Package factories ports the WinterCMS plugin's PromptFactory: ready-made
// prompts for form field suggestions, categorisation, chats and
// completions.
package factories
import (
"regexp"
"strings"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
)
// Pair is one ordered key and value: a context field, a dropdown option.
type Pair struct {
Key string
Value string
}
// FormField is one field to suggest a value for.
type FormField struct {
Name string
Type string
}
// Category is one existing category an item may be assigned to.
type Category struct {
ID string
Name string
}
var fieldStrategies = map[string]string{
"text": "Generate a concise, descriptive text value",
"textarea": "Generate longer form text content",
"slug": "Generate a URL-friendly slug (lowercase, hyphens instead of spaces, no special characters)",
"richeditor": "Generate formatted HTML content with appropriate tags",
"markdown": "Generate Markdown-formatted content",
"code": "Generate code in the appropriate language",
"taglist": "Generate comma-separated tags/keywords",
"dropdown": "Select the most appropriate option from available choices",
}
// PromptFactory builds prompts (PHP PromptFactory's static methods).
type PromptFactory struct{}
// CreateSuggestionForField is the prompt for one form field's value.
// currentValue is nil when the field is empty; options are listed only
// for dropdown, radio and checkboxlist fields.
func (PromptFactory) CreateSuggestionForField(fieldName, fieldType string, currentValue *string, context []Pair, options []Pair) valueobjects.Prompt {
strategy, ok := fieldStrategies[fieldType]
if !ok {
strategy = "Generate an appropriate value"
}
system := "You are a helpful assistant that generates content for form fields. " +
"Your task is to: " + strategy + ". " +
"Respond with ONLY the value, no explanations or quotes."
return valueobjects.NewPrompt().
WithSystem(system).
AddUserMessage(buildFieldPrompt(fieldName, fieldType, currentValue, context, options))
}
// SuggestFieldsValues is the prompt for a JSON object of values for
// several fields. notes may be nil.
func (PromptFactory) SuggestFieldsValues(fields []FormField, context []Pair, notes *string) valueobjects.Prompt {
system := "You are an expert content generation assistant. " +
"Based on the provided context, suggest appropriate values for the specified fields. " +
"Respond in JSON format with field names as keys and generated values as values. " +
"Do not include any explanations or additional text, respond in raw json without any markdown. Never enclose result in ``` markdown."
var b strings.Builder
b.WriteString("Fields to generate values for:\n")
for _, f := range fields {
name, typ := f.Name, f.Type
if name == "" {
name = "unnamed"
}
if typ == "" {
typ = "text"
}
b.WriteString("- " + name + " (type: " + typ + ")\n")
}
if len(context) > 0 {
b.WriteString("\nContext to base the suggestions on:\n")
writeContext(&b, context, "- ", "\n")
}
if notes != nil && *notes != "" {
b.WriteString("\nAdditional notes:\n" + *notes + "\n")
}
return valueobjects.NewPrompt().WithSystem(system).AddUserMessage(b.String())
}
// SuggestCategoriesForItem is the prompt for the ids of the existing
// categories that fit an item. notes may be nil.
func (PromptFactory) SuggestCategoriesForItem(categories []Category, context []Pair, notes *string) valueobjects.Prompt {
system := "You are an expert content categorization assistant. " +
"Based on the provided context, suggest relevant categories from the existing list. " +
"Respond with a comma-separated list of category ids that best fit the content. " +
"Do not suggest categories that are not in the existing list. Respond with ids only, no quotes or explanations."
var b strings.Builder
b.WriteString("Existing categories:\n")
for _, c := range categories {
id, name := c.ID, c.Name
if id == "" {
id = "unknown"
}
if name == "" {
name = "unnamed"
}
b.WriteString("- " + id + ": " + name + "\n")
}
if len(context) > 0 {
b.WriteString("\nContext to assign categories to:\n")
writeContext(&b, context, "- ", "\n")
}
if notes != nil && *notes != "" {
b.WriteString("\nAdditional notes:\n" + *notes + "\n")
}
return valueobjects.NewPrompt().WithSystem(system).AddUserMessage(b.String())
}
// CreateChat is an empty chat with an optional system prompt.
func (PromptFactory) CreateChat(system string) valueobjects.Prompt {
p := valueobjects.NewPrompt()
if system != "" {
p = p.WithSystem(system)
}
return p
}
// CreateCompletion is a one-message prompt with an optional system prompt.
func (f PromptFactory) CreateCompletion(userMessage, system string) valueobjects.Prompt {
return f.CreateChat(system).AddUserMessage(userMessage)
}
func buildFieldPrompt(fieldName, fieldType string, currentValue *string, context, options []Pair) string {
var b strings.Builder
b.WriteString("Generate a value for the '" + HumanizeFieldName(fieldName) + "' field.")
if len(context) > 0 {
b.WriteString("\n\nContext from related fields:")
writeContext(&b, context, "\n- ", "")
}
if currentValue != nil && *currentValue != "" {
b.WriteString("\n\nCurrent value (improve or keep if already good): " + *currentValue)
}
if len(options) > 0 && (fieldType == "dropdown" || fieldType == "radio" || fieldType == "checkboxlist") {
b.WriteString("\n\nAvailable options:")
for _, o := range options {
b.WriteString("\n- " + o.Key + ": " + o.Value)
}
b.WriteString("\n\nRespond with ONLY the option key.")
}
switch fieldType {
case "slug":
b.WriteString("\n\nFormat: lowercase letters, numbers, and hyphens only. No spaces or special characters.")
case "taglist":
b.WriteString("\n\nFormat: comma-separated list of relevant keywords/tags.")
case "richeditor":
b.WriteString("\n\nFormat: valid HTML with appropriate tags (p, h2, h3, ul, li, strong, em).")
case "markdown":
b.WriteString("\n\nFormat: valid Markdown syntax.")
}
return b.String()
}
// writeContext writes each non-empty context value (PHP skips empty()
// values, "0" included) as prefix + humanized key + ": " + value + suffix.
func writeContext(b *strings.Builder, context []Pair, prefix, suffix string) {
for _, c := range context {
if c.Value == "" || c.Value == "0" {
continue
}
b.WriteString(prefix + HumanizeFieldName(c.Key) + ": " + c.Value + suffix)
}
}
var (
fieldPrefix = regexp.MustCompile(`^(field_|input_|txt_)`)
camelBreak = regexp.MustCompile(`([a-z])([A-Z])`)
)
// HumanizeFieldName turns a field name into words: the field_, input_ or
// txt_ prefix dropped, camelCase split, underscores and hyphens to
// spaces, each word capitalised (PHP ucwords(strtolower(...))).
func HumanizeFieldName(name string) string {
name = fieldPrefix.ReplaceAllString(name, "")
name = camelBreak.ReplaceAllString(name, "$1 $2")
name = strings.NewReplacer("_", " ", "-", " ").Replace(name)
return ucwords(asciiLower(name))
}
// asciiLower is PHP 8's strtolower: ASCII letters only.
func asciiLower(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 'a' - 'A'
}
}
return string(b)
}
// ucwords upper-cases the first ASCII letter of each word, words being
// separated by PHP's default delimiters.
func ucwords(s string) string {
b := []byte(s)
start := true
for i, c := range b {
if start && c >= 'a' && c <= 'z' {
b[i] = c - 'a' + 'A'
}
start = c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'
}
return string(b)
}