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.

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