|
- package mapping
-
- import (
- "fmt"
- "sort"
- "strings"
- "time"
-
- "qctextbuilder/internal/domain"
- )
-
- type SuggestionRequest struct {
- Fields []domain.TemplateField
- GlobalData map[string]any
- DraftContext *domain.DraftContext
- Existing map[string]string
- IncludeFilled bool
- }
-
- type Suggestion struct {
- FieldPath string `json:"fieldPath"`
- Slot string `json:"slot,omitempty"`
- Value string `json:"value"`
- Reason string `json:"reason,omitempty"`
- }
-
- type SuggestionResult struct {
- Suggestions []Suggestion `json:"suggestions"`
- ByFieldPath map[string]Suggestion `json:"byFieldPath"`
- }
-
- func SuggestFieldValues(req SuggestionRequest) SuggestionResult {
- existing := req.Existing
- if existing == nil {
- existing = map[string]string{}
- }
- mappingResult := MapTemplateFieldsToSemanticSlots(req.Fields)
- ctx := suggestionContextFrom(req.GlobalData, req.DraftContext)
-
- out := SuggestionResult{
- Suggestions: make([]Suggestion, 0),
- ByFieldPath: map[string]Suggestion{},
- }
- seen := map[string]struct{}{}
-
- targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...)
- sort.SliceStable(targets, func(i, j int) bool {
- if targets[i].FieldPath == targets[j].FieldPath {
- return targets[i].Slot < targets[j].Slot
- }
- return targets[i].FieldPath < targets[j].FieldPath
- })
-
- for _, target := range targets {
- if _, ok := seen[target.FieldPath]; ok {
- continue
- }
- if !req.IncludeFilled && strings.TrimSpace(existing[target.FieldPath]) != "" {
- continue
- }
- value, reason, ok := suggestValueForSlot(target.Slot, ctx)
- if !ok || strings.TrimSpace(value) == "" {
- continue
- }
- suggestion := Suggestion{
- FieldPath: target.FieldPath,
- Slot: target.Slot,
- Value: value,
- Reason: reason,
- }
- out.Suggestions = append(out.Suggestions, suggestion)
- out.ByFieldPath[target.FieldPath] = suggestion
- seen[target.FieldPath] = struct{}{}
- }
-
- return out
- }
-
- type suggestionContext struct {
- CompanyName string
- BusinessType string
- WebsiteSummary string
- LocaleStyle string
- MarketStyle string
- AddressMode string
- ContentTone string
- PromptNote string
- DescriptionShort string
- DescriptionLong string
- Mission string
- }
-
- func suggestionContextFrom(globalData map[string]any, draftContext *domain.DraftContext) suggestionContext {
- ctx := suggestionContext{
- CompanyName: getMapString(globalData, "companyName"),
- BusinessType: getMapString(globalData, "businessType"),
- DescriptionShort: getMapString(globalData, "descriptionShort"),
- DescriptionLong: getMapString(globalData, "descriptionLong"),
- Mission: getMapString(globalData, "mission"),
- }
- if draftContext == nil {
- return ctx
- }
- if strings.TrimSpace(ctx.BusinessType) == "" {
- ctx.BusinessType = strings.TrimSpace(draftContext.LLM.BusinessType)
- }
- ctx.WebsiteSummary = strings.TrimSpace(draftContext.LLM.WebsiteSummary)
- ctx.LocaleStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.LocaleStyle)
- ctx.MarketStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.MarketStyle)
- ctx.AddressMode = strings.TrimSpace(draftContext.LLM.StyleProfile.AddressMode)
- ctx.ContentTone = strings.TrimSpace(draftContext.LLM.StyleProfile.ContentTone)
- ctx.PromptNote = strings.TrimSpace(draftContext.LLM.StyleProfile.PromptInstructions)
- return ctx
- }
-
- func suggestValueForSlot(slot string, ctx suggestionContext) (string, string, bool) {
- company := fallback(ctx.CompanyName, "Ihr Unternehmen")
- business := fallback(ctx.BusinessType, "Angebot")
- toneAdj := toneAdjective(ctx.ContentTone)
- audienceLine := audienceFlavor(ctx)
-
- switch {
- case slot == "hero.title":
- return strings.TrimSpace(fmt.Sprintf("%s fuer %s mit %s Klarheit", company, business, toneAdj)), "slot-based hero headline", true
- case slot == "intro.title":
- return strings.TrimSpace(fmt.Sprintf("Was %s fuer Sie einfacher macht", company)), "slot-based intro title", true
- case slot == "intro.description":
- return firstNonEmpty(
- shortenSentence(ctx.WebsiteSummary, 180),
- fmt.Sprintf("%s unterstuetzt Kunden mit %s Leistungen, klarer Kommunikation und einem %s Auftritt.", company, business, toneAdj),
- ), "slot-based intro description", true
- case slot == "about.description":
- return firstNonEmpty(
- shortenSentence(ctx.DescriptionLong, 260),
- shortenSentence(ctx.Mission, 220),
- fmt.Sprintf("%s steht fuer %s, verlaessliche Zusammenarbeit und einen %s Anspruch in Beratung und Umsetzung.", company, business, toneAdj),
- ), "slot-based about description", true
- case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].title"):
- idx := repeatedSlotIndex(slot)
- return fmt.Sprintf("%s Leistung %d", titleCaseBusiness(business), idx+1), "slot-based service title", true
- case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].description"):
- idx := repeatedSlotIndex(slot)
- return fmt.Sprintf("Praezise Umsetzung von %s mit Fokus auf Nutzen, Verstaendlichkeit und %s Wirkung%s.", business, toneAdj, audienceLineForIndex(audienceLine, idx)), "slot-based service description", true
- case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].name"):
- idx := repeatedSlotIndex(slot)
- return fmt.Sprintf("Ansprechperson %d", idx+1), "slot-based team placeholder", true
- case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].description"):
- idx := repeatedSlotIndex(slot)
- return fmt.Sprintf("Begleitet Projekte bei %s mit fachlicher Sicherheit, klarer Kommunikation und %s Auftreten.", business, toneAdjForIndex(toneAdj, idx)), "slot-based team description", true
- case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].name"):
- idx := repeatedSlotIndex(slot)
- return fmt.Sprintf("Kundin/Kunde %d", idx+1), "slot-based testimonial placeholder", true
- case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].title"):
- return firstNonEmpty(
- testimonialLead(ctx),
- "Vertrauen durch saubere Zusammenarbeit",
- ), "slot-based testimonial title", true
- case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].description"):
- return fmt.Sprintf("%s ueberzeugt mit %s Prozessen, klaren Ergebnissen und einer Zusammenarbeit, die angenehm effizient bleibt.", company, toneAdj), "slot-based testimonial description", true
- case slot == "cta.text":
- if ctx.AddressMode == "du" {
- return "Jetzt unverbindlich anfragen", "slot-based cta", true
- }
- return "Jetzt unverbindlich anfragen", "slot-based cta", true
- default:
- return "", "", false
- }
- }
-
- func getMapString(values map[string]any, key string) string {
- if values == nil {
- return ""
- }
- raw, _ := values[key].(string)
- return strings.TrimSpace(raw)
- }
-
- func fallback(value, alt string) string {
- if strings.TrimSpace(value) == "" {
- return alt
- }
- return strings.TrimSpace(value)
- }
-
- func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
- }
-
- func shortenSentence(value string, max int) string {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" || max <= 0 {
- return ""
- }
- if len([]rune(trimmed)) <= max {
- return trimmed
- }
- runes := []rune(trimmed)
- return strings.TrimSpace(string(runes[:max])) + "..."
- }
-
- func repeatedSlotIndex(slot string) int {
- start := strings.Index(slot, "[")
- end := strings.Index(slot, "]")
- if start < 0 || end <= start+1 {
- return 0
- }
- value := strings.TrimSpace(slot[start+1 : end])
- var idx int
- _, _ = fmt.Sscanf(value, "%d", &idx)
- if idx < 0 {
- return 0
- }
- return idx
- }
-
- func toneAdjective(tone string) string {
- switch strings.ToLower(strings.TrimSpace(tone)) {
- case "locker":
- return "lockerer"
- case "modern":
- return "moderner"
- case "premium":
- return "hochwertiger"
- case "freundlich":
- return "freundlicher"
- case "professionell":
- return "professioneller"
- default:
- return "klarer"
- }
- }
-
- func toneAdjForIndex(adj string, idx int) string {
- if idx%2 == 0 {
- return adj
- }
- return adj
- }
-
- func audienceFlavor(ctx suggestionContext) string {
- parts := make([]string, 0, 2)
- if strings.TrimSpace(ctx.LocaleStyle) != "" {
- parts = append(parts, ctx.LocaleStyle)
- }
- if strings.TrimSpace(ctx.MarketStyle) != "" {
- parts = append(parts, ctx.MarketStyle)
- }
- return strings.Join(parts, " / ")
- }
-
- func audienceLineForIndex(value string, idx int) string {
- if strings.TrimSpace(value) == "" || idx != 0 {
- return ""
- }
- return " fuer " + value
- }
-
- func titleCaseBusiness(value string) string {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" {
- return "Service"
- }
- return strings.ToUpper(string([]rune(trimmed)[0])) + string([]rune(trimmed)[1:])
- }
-
- func testimonialLead(ctx suggestionContext) string {
- if strings.TrimSpace(ctx.WebsiteSummary) == "" {
- return ""
- }
- return shortenSentence(ctx.WebsiteSummary, 80)
- }
-
- func GenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState {
- next := cloneSuggestionState(current)
- if next.ByFieldPath == nil {
- next.ByFieldPath = map[string]domain.DraftSuggestion{}
- }
- generated := SuggestFieldValues(SuggestionRequest{
- Fields: req.Fields,
- GlobalData: req.GlobalData,
- DraftContext: req.DraftContext,
- Existing: req.Existing,
- IncludeFilled: true,
- })
- for _, s := range generated.Suggestions {
- if _, exists := next.ByFieldPath[s.FieldPath]; exists {
- continue
- }
- next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now)
- }
- next.UpdatedAt = now.UTC()
- return next
- }
-
- func RegenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState {
- next := cloneSuggestionState(current)
- next.ByFieldPath = map[string]domain.DraftSuggestion{}
- generated := SuggestFieldValues(SuggestionRequest{
- Fields: req.Fields,
- GlobalData: req.GlobalData,
- DraftContext: req.DraftContext,
- Existing: req.Existing,
- IncludeFilled: true,
- })
- for _, s := range generated.Suggestions {
- next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now)
- }
- next.UpdatedAt = now.UTC()
- return next
- }
-
- func RegenerateFieldSuggestion(req SuggestionRequest, current domain.DraftSuggestionState, fieldPath string, now time.Time) domain.DraftSuggestionState {
- target := strings.TrimSpace(fieldPath)
- if target == "" {
- return cloneSuggestionState(current)
- }
- next := cloneSuggestionState(current)
- if next.ByFieldPath == nil {
- next.ByFieldPath = map[string]domain.DraftSuggestion{}
- }
- generated := SuggestFieldValues(SuggestionRequest{
- Fields: req.Fields,
- GlobalData: req.GlobalData,
- DraftContext: req.DraftContext,
- Existing: req.Existing,
- IncludeFilled: true,
- })
- if suggestion, ok := generated.ByFieldPath[target]; ok {
- next.ByFieldPath[target] = toDraftSuggestion(suggestion, now)
- next.UpdatedAt = now.UTC()
- }
- return next
- }
-
- func ApplySuggestionsToEmptyFields(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) {
- values := cloneFieldValues(fieldValues)
- next := cloneSuggestionState(state)
- if next.ByFieldPath == nil {
- next.ByFieldPath = map[string]domain.DraftSuggestion{}
- }
- for path, suggestion := range next.ByFieldPath {
- if strings.TrimSpace(values[path]) != "" {
- continue
- }
- if strings.TrimSpace(suggestion.Value) == "" {
- continue
- }
- values[path] = strings.TrimSpace(suggestion.Value)
- suggestion.Status = domain.DraftSuggestionStatusApplied
- suggestion.UpdatedAt = now.UTC()
- next.ByFieldPath[path] = suggestion
- }
- next.UpdatedAt = now.UTC()
- return values, next
- }
-
- func ApplyAllSuggestions(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) {
- values := cloneFieldValues(fieldValues)
- next := cloneSuggestionState(state)
- if next.ByFieldPath == nil {
- next.ByFieldPath = map[string]domain.DraftSuggestion{}
- }
- for path, suggestion := range next.ByFieldPath {
- if strings.TrimSpace(suggestion.Value) == "" {
- continue
- }
- values[path] = strings.TrimSpace(suggestion.Value)
- suggestion.Status = domain.DraftSuggestionStatusApplied
- suggestion.UpdatedAt = now.UTC()
- next.ByFieldPath[path] = suggestion
- }
- next.UpdatedAt = now.UTC()
- return values, next
- }
-
- func ApplySuggestionToField(fieldValues map[string]string, state domain.DraftSuggestionState, fieldPath string, now time.Time) (map[string]string, domain.DraftSuggestionState) {
- target := strings.TrimSpace(fieldPath)
- values := cloneFieldValues(fieldValues)
- next := cloneSuggestionState(state)
- if target == "" || next.ByFieldPath == nil {
- return values, next
- }
- suggestion, ok := next.ByFieldPath[target]
- if !ok || strings.TrimSpace(suggestion.Value) == "" {
- return values, next
- }
- values[target] = strings.TrimSpace(suggestion.Value)
- suggestion.Status = domain.DraftSuggestionStatusApplied
- suggestion.UpdatedAt = now.UTC()
- next.ByFieldPath[target] = suggestion
- next.UpdatedAt = now.UTC()
- return values, next
- }
-
- func cloneFieldValues(values map[string]string) map[string]string {
- if values == nil {
- return map[string]string{}
- }
- out := make(map[string]string, len(values))
- for k, v := range values {
- out[k] = v
- }
- return out
- }
-
- func cloneSuggestionState(state domain.DraftSuggestionState) domain.DraftSuggestionState {
- out := domain.DraftSuggestionState{
- ByFieldPath: map[string]domain.DraftSuggestion{},
- UpdatedAt: state.UpdatedAt,
- }
- for path, suggestion := range state.ByFieldPath {
- out.ByFieldPath[path] = suggestion
- }
- return out
- }
-
- func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion {
- ts := now.UTC()
- return domain.DraftSuggestion{
- FieldPath: strings.TrimSpace(s.FieldPath),
- Slot: strings.TrimSpace(s.Slot),
- Value: strings.TrimSpace(s.Value),
- Reason: strings.TrimSpace(s.Reason),
- Source: domain.DraftSuggestionSourceRuleBased,
- Status: domain.DraftSuggestionStatusSuggested,
- GeneratedAt: ts,
- UpdatedAt: ts,
- }
- }
|