Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

474 lines
15KB

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