Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

435 linhas
14KB

  1. package mapping
  2. import (
  3. "fmt"
  4. "sort"
  5. "strings"
  6. "time"
  7. "qctextbuilder/internal/domain"
  8. )
  9. type SuggestionRequest struct {
  10. Fields []domain.TemplateField
  11. GlobalData map[string]any
  12. DraftContext *domain.DraftContext
  13. Existing map[string]string
  14. IncludeFilled bool
  15. }
  16. type Suggestion struct {
  17. FieldPath string `json:"fieldPath"`
  18. Slot string `json:"slot,omitempty"`
  19. Value string `json:"value"`
  20. Reason string `json:"reason,omitempty"`
  21. }
  22. type SuggestionResult struct {
  23. Suggestions []Suggestion `json:"suggestions"`
  24. ByFieldPath map[string]Suggestion `json:"byFieldPath"`
  25. }
  26. func SuggestFieldValues(req SuggestionRequest) SuggestionResult {
  27. existing := req.Existing
  28. if existing == nil {
  29. existing = map[string]string{}
  30. }
  31. mappingResult := MapTemplateFieldsToSemanticSlots(req.Fields)
  32. ctx := suggestionContextFrom(req.GlobalData, req.DraftContext)
  33. out := SuggestionResult{
  34. Suggestions: make([]Suggestion, 0),
  35. ByFieldPath: map[string]Suggestion{},
  36. }
  37. seen := map[string]struct{}{}
  38. targets := append([]SemanticSlotTarget(nil), mappingResult.Targets...)
  39. sort.SliceStable(targets, func(i, j int) bool {
  40. if targets[i].FieldPath == targets[j].FieldPath {
  41. return targets[i].Slot < targets[j].Slot
  42. }
  43. return targets[i].FieldPath < targets[j].FieldPath
  44. })
  45. for _, target := range targets {
  46. if _, ok := seen[target.FieldPath]; ok {
  47. continue
  48. }
  49. if !req.IncludeFilled && strings.TrimSpace(existing[target.FieldPath]) != "" {
  50. continue
  51. }
  52. value, reason, ok := suggestValueForSlot(target.Slot, ctx)
  53. if !ok || strings.TrimSpace(value) == "" {
  54. continue
  55. }
  56. suggestion := Suggestion{
  57. FieldPath: target.FieldPath,
  58. Slot: target.Slot,
  59. Value: value,
  60. Reason: reason,
  61. }
  62. out.Suggestions = append(out.Suggestions, suggestion)
  63. out.ByFieldPath[target.FieldPath] = suggestion
  64. seen[target.FieldPath] = struct{}{}
  65. }
  66. return out
  67. }
  68. type suggestionContext struct {
  69. CompanyName string
  70. BusinessType string
  71. WebsiteSummary string
  72. LocaleStyle string
  73. MarketStyle string
  74. AddressMode string
  75. ContentTone string
  76. PromptNote string
  77. DescriptionShort string
  78. DescriptionLong string
  79. Mission string
  80. }
  81. func suggestionContextFrom(globalData map[string]any, draftContext *domain.DraftContext) suggestionContext {
  82. ctx := suggestionContext{
  83. CompanyName: getMapString(globalData, "companyName"),
  84. BusinessType: getMapString(globalData, "businessType"),
  85. DescriptionShort: getMapString(globalData, "descriptionShort"),
  86. DescriptionLong: getMapString(globalData, "descriptionLong"),
  87. Mission: getMapString(globalData, "mission"),
  88. }
  89. if draftContext == nil {
  90. return ctx
  91. }
  92. if strings.TrimSpace(ctx.BusinessType) == "" {
  93. ctx.BusinessType = strings.TrimSpace(draftContext.LLM.BusinessType)
  94. }
  95. ctx.WebsiteSummary = strings.TrimSpace(draftContext.LLM.WebsiteSummary)
  96. ctx.LocaleStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.LocaleStyle)
  97. ctx.MarketStyle = strings.TrimSpace(draftContext.LLM.StyleProfile.MarketStyle)
  98. ctx.AddressMode = strings.TrimSpace(draftContext.LLM.StyleProfile.AddressMode)
  99. ctx.ContentTone = strings.TrimSpace(draftContext.LLM.StyleProfile.ContentTone)
  100. ctx.PromptNote = strings.TrimSpace(draftContext.LLM.StyleProfile.PromptInstructions)
  101. return ctx
  102. }
  103. func suggestValueForSlot(slot string, ctx suggestionContext) (string, string, bool) {
  104. company := fallback(ctx.CompanyName, "Ihr Unternehmen")
  105. business := fallback(ctx.BusinessType, "Angebot")
  106. toneAdj := toneAdjective(ctx.ContentTone)
  107. audienceLine := audienceFlavor(ctx)
  108. switch {
  109. case slot == "hero.title":
  110. return strings.TrimSpace(fmt.Sprintf("%s fuer %s mit %s Klarheit", company, business, toneAdj)), "slot-based hero headline", true
  111. case slot == "intro.title":
  112. return strings.TrimSpace(fmt.Sprintf("Was %s fuer Sie einfacher macht", company)), "slot-based intro title", true
  113. case slot == "intro.description":
  114. return firstNonEmpty(
  115. shortenSentence(ctx.WebsiteSummary, 180),
  116. fmt.Sprintf("%s unterstuetzt Kunden mit %s Leistungen, klarer Kommunikation und einem %s Auftritt.", company, business, toneAdj),
  117. ), "slot-based intro description", true
  118. case slot == "about.description":
  119. return firstNonEmpty(
  120. shortenSentence(ctx.DescriptionLong, 260),
  121. shortenSentence(ctx.Mission, 220),
  122. fmt.Sprintf("%s steht fuer %s, verlaessliche Zusammenarbeit und einen %s Anspruch in Beratung und Umsetzung.", company, business, toneAdj),
  123. ), "slot-based about description", true
  124. case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].title"):
  125. idx := repeatedSlotIndex(slot)
  126. return fmt.Sprintf("%s Leistung %d", titleCaseBusiness(business), idx+1), "slot-based service title", true
  127. case strings.HasPrefix(slot, "service_items[") && strings.HasSuffix(slot, "].description"):
  128. idx := repeatedSlotIndex(slot)
  129. 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
  130. case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].name"):
  131. idx := repeatedSlotIndex(slot)
  132. return fmt.Sprintf("Ansprechperson %d", idx+1), "slot-based team placeholder", true
  133. case strings.HasPrefix(slot, "team_items[") && strings.HasSuffix(slot, "].description"):
  134. idx := repeatedSlotIndex(slot)
  135. return fmt.Sprintf("Begleitet Projekte bei %s mit fachlicher Sicherheit, klarer Kommunikation und %s Auftreten.", business, toneAdjForIndex(toneAdj, idx)), "slot-based team description", true
  136. case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].name"):
  137. idx := repeatedSlotIndex(slot)
  138. return fmt.Sprintf("Kundin/Kunde %d", idx+1), "slot-based testimonial placeholder", true
  139. case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].title"):
  140. return firstNonEmpty(
  141. testimonialLead(ctx),
  142. "Vertrauen durch saubere Zusammenarbeit",
  143. ), "slot-based testimonial title", true
  144. case strings.HasPrefix(slot, "testimonial_items[") && strings.HasSuffix(slot, "].description"):
  145. return fmt.Sprintf("%s ueberzeugt mit %s Prozessen, klaren Ergebnissen und einer Zusammenarbeit, die angenehm effizient bleibt.", company, toneAdj), "slot-based testimonial description", true
  146. case slot == "cta.text":
  147. if ctx.AddressMode == "du" {
  148. return "Jetzt unverbindlich anfragen", "slot-based cta", true
  149. }
  150. return "Jetzt unverbindlich anfragen", "slot-based cta", true
  151. default:
  152. return "", "", false
  153. }
  154. }
  155. func getMapString(values map[string]any, key string) string {
  156. if values == nil {
  157. return ""
  158. }
  159. raw, _ := values[key].(string)
  160. return strings.TrimSpace(raw)
  161. }
  162. func fallback(value, alt string) string {
  163. if strings.TrimSpace(value) == "" {
  164. return alt
  165. }
  166. return strings.TrimSpace(value)
  167. }
  168. func firstNonEmpty(values ...string) string {
  169. for _, value := range values {
  170. if strings.TrimSpace(value) != "" {
  171. return strings.TrimSpace(value)
  172. }
  173. }
  174. return ""
  175. }
  176. func shortenSentence(value string, max int) string {
  177. trimmed := strings.TrimSpace(value)
  178. if trimmed == "" || max <= 0 {
  179. return ""
  180. }
  181. if len([]rune(trimmed)) <= max {
  182. return trimmed
  183. }
  184. runes := []rune(trimmed)
  185. return strings.TrimSpace(string(runes[:max])) + "..."
  186. }
  187. func repeatedSlotIndex(slot string) int {
  188. start := strings.Index(slot, "[")
  189. end := strings.Index(slot, "]")
  190. if start < 0 || end <= start+1 {
  191. return 0
  192. }
  193. value := strings.TrimSpace(slot[start+1 : end])
  194. var idx int
  195. _, _ = fmt.Sscanf(value, "%d", &idx)
  196. if idx < 0 {
  197. return 0
  198. }
  199. return idx
  200. }
  201. func toneAdjective(tone string) string {
  202. switch strings.ToLower(strings.TrimSpace(tone)) {
  203. case "locker":
  204. return "lockerer"
  205. case "modern":
  206. return "moderner"
  207. case "premium":
  208. return "hochwertiger"
  209. case "freundlich":
  210. return "freundlicher"
  211. case "professionell":
  212. return "professioneller"
  213. default:
  214. return "klarer"
  215. }
  216. }
  217. func toneAdjForIndex(adj string, idx int) string {
  218. if idx%2 == 0 {
  219. return adj
  220. }
  221. return adj
  222. }
  223. func audienceFlavor(ctx suggestionContext) string {
  224. parts := make([]string, 0, 2)
  225. if strings.TrimSpace(ctx.LocaleStyle) != "" {
  226. parts = append(parts, ctx.LocaleStyle)
  227. }
  228. if strings.TrimSpace(ctx.MarketStyle) != "" {
  229. parts = append(parts, ctx.MarketStyle)
  230. }
  231. return strings.Join(parts, " / ")
  232. }
  233. func audienceLineForIndex(value string, idx int) string {
  234. if strings.TrimSpace(value) == "" || idx != 0 {
  235. return ""
  236. }
  237. return " fuer " + value
  238. }
  239. func titleCaseBusiness(value string) string {
  240. trimmed := strings.TrimSpace(value)
  241. if trimmed == "" {
  242. return "Service"
  243. }
  244. return strings.ToUpper(string([]rune(trimmed)[0])) + string([]rune(trimmed)[1:])
  245. }
  246. func testimonialLead(ctx suggestionContext) string {
  247. if strings.TrimSpace(ctx.WebsiteSummary) == "" {
  248. return ""
  249. }
  250. return shortenSentence(ctx.WebsiteSummary, 80)
  251. }
  252. func GenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState {
  253. next := cloneSuggestionState(current)
  254. if next.ByFieldPath == nil {
  255. next.ByFieldPath = map[string]domain.DraftSuggestion{}
  256. }
  257. generated := SuggestFieldValues(SuggestionRequest{
  258. Fields: req.Fields,
  259. GlobalData: req.GlobalData,
  260. DraftContext: req.DraftContext,
  261. Existing: req.Existing,
  262. IncludeFilled: true,
  263. })
  264. for _, s := range generated.Suggestions {
  265. if _, exists := next.ByFieldPath[s.FieldPath]; exists {
  266. continue
  267. }
  268. next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now)
  269. }
  270. next.UpdatedAt = now.UTC()
  271. return next
  272. }
  273. func RegenerateAllSuggestions(req SuggestionRequest, current domain.DraftSuggestionState, now time.Time) domain.DraftSuggestionState {
  274. next := cloneSuggestionState(current)
  275. next.ByFieldPath = map[string]domain.DraftSuggestion{}
  276. generated := SuggestFieldValues(SuggestionRequest{
  277. Fields: req.Fields,
  278. GlobalData: req.GlobalData,
  279. DraftContext: req.DraftContext,
  280. Existing: req.Existing,
  281. IncludeFilled: true,
  282. })
  283. for _, s := range generated.Suggestions {
  284. next.ByFieldPath[s.FieldPath] = toDraftSuggestion(s, now)
  285. }
  286. next.UpdatedAt = now.UTC()
  287. return next
  288. }
  289. func RegenerateFieldSuggestion(req SuggestionRequest, current domain.DraftSuggestionState, fieldPath string, now time.Time) domain.DraftSuggestionState {
  290. target := strings.TrimSpace(fieldPath)
  291. if target == "" {
  292. return cloneSuggestionState(current)
  293. }
  294. next := cloneSuggestionState(current)
  295. if next.ByFieldPath == nil {
  296. next.ByFieldPath = map[string]domain.DraftSuggestion{}
  297. }
  298. generated := SuggestFieldValues(SuggestionRequest{
  299. Fields: req.Fields,
  300. GlobalData: req.GlobalData,
  301. DraftContext: req.DraftContext,
  302. Existing: req.Existing,
  303. IncludeFilled: true,
  304. })
  305. if suggestion, ok := generated.ByFieldPath[target]; ok {
  306. next.ByFieldPath[target] = toDraftSuggestion(suggestion, now)
  307. next.UpdatedAt = now.UTC()
  308. }
  309. return next
  310. }
  311. func ApplySuggestionsToEmptyFields(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) {
  312. values := cloneFieldValues(fieldValues)
  313. next := cloneSuggestionState(state)
  314. if next.ByFieldPath == nil {
  315. next.ByFieldPath = map[string]domain.DraftSuggestion{}
  316. }
  317. for path, suggestion := range next.ByFieldPath {
  318. if strings.TrimSpace(values[path]) != "" {
  319. continue
  320. }
  321. if strings.TrimSpace(suggestion.Value) == "" {
  322. continue
  323. }
  324. values[path] = strings.TrimSpace(suggestion.Value)
  325. suggestion.Status = domain.DraftSuggestionStatusApplied
  326. suggestion.UpdatedAt = now.UTC()
  327. next.ByFieldPath[path] = suggestion
  328. }
  329. next.UpdatedAt = now.UTC()
  330. return values, next
  331. }
  332. func ApplyAllSuggestions(fieldValues map[string]string, state domain.DraftSuggestionState, now time.Time) (map[string]string, domain.DraftSuggestionState) {
  333. values := cloneFieldValues(fieldValues)
  334. next := cloneSuggestionState(state)
  335. if next.ByFieldPath == nil {
  336. next.ByFieldPath = map[string]domain.DraftSuggestion{}
  337. }
  338. for path, suggestion := range next.ByFieldPath {
  339. if strings.TrimSpace(suggestion.Value) == "" {
  340. continue
  341. }
  342. values[path] = strings.TrimSpace(suggestion.Value)
  343. suggestion.Status = domain.DraftSuggestionStatusApplied
  344. suggestion.UpdatedAt = now.UTC()
  345. next.ByFieldPath[path] = suggestion
  346. }
  347. next.UpdatedAt = now.UTC()
  348. return values, next
  349. }
  350. func ApplySuggestionToField(fieldValues map[string]string, state domain.DraftSuggestionState, fieldPath string, now time.Time) (map[string]string, domain.DraftSuggestionState) {
  351. target := strings.TrimSpace(fieldPath)
  352. values := cloneFieldValues(fieldValues)
  353. next := cloneSuggestionState(state)
  354. if target == "" || next.ByFieldPath == nil {
  355. return values, next
  356. }
  357. suggestion, ok := next.ByFieldPath[target]
  358. if !ok || strings.TrimSpace(suggestion.Value) == "" {
  359. return values, next
  360. }
  361. values[target] = strings.TrimSpace(suggestion.Value)
  362. suggestion.Status = domain.DraftSuggestionStatusApplied
  363. suggestion.UpdatedAt = now.UTC()
  364. next.ByFieldPath[target] = suggestion
  365. next.UpdatedAt = now.UTC()
  366. return values, next
  367. }
  368. func cloneFieldValues(values map[string]string) map[string]string {
  369. if values == nil {
  370. return map[string]string{}
  371. }
  372. out := make(map[string]string, len(values))
  373. for k, v := range values {
  374. out[k] = v
  375. }
  376. return out
  377. }
  378. func cloneSuggestionState(state domain.DraftSuggestionState) domain.DraftSuggestionState {
  379. out := domain.DraftSuggestionState{
  380. ByFieldPath: map[string]domain.DraftSuggestion{},
  381. UpdatedAt: state.UpdatedAt,
  382. }
  383. for path, suggestion := range state.ByFieldPath {
  384. out.ByFieldPath[path] = suggestion
  385. }
  386. return out
  387. }
  388. func toDraftSuggestion(s Suggestion, now time.Time) domain.DraftSuggestion {
  389. ts := now.UTC()
  390. return domain.DraftSuggestion{
  391. FieldPath: strings.TrimSpace(s.FieldPath),
  392. Slot: strings.TrimSpace(s.Slot),
  393. Value: strings.TrimSpace(s.Value),
  394. Reason: strings.TrimSpace(s.Reason),
  395. Source: domain.DraftSuggestionSourceRuleBased,
  396. Status: domain.DraftSuggestionStatusSuggested,
  397. GeneratedAt: ts,
  398. UpdatedAt: ts,
  399. }
  400. }