package main import ( "encoding/json" "fmt" "regexp" "sort" "strings" ) // Formula-aware local generator. // // processing.HeuristicCompleter is the production offline fallback and stays // deliberately dumb. For local proofs that is not enough: a run only shows the // category formula works if the reply actually follows the formula in the prompt. // This reader parses the product template out of the user message and answers with // copy shaped by it, built ONLY from the supplied name / description / category / // attrs — never invented facts — which is exactly the contract the pipeline gates // the real model against. var ( reTemplateHeader = regexp.MustCompile(`(?is)(?:GPT predloga|Product template)\s*:\s*(.*?)(?:\n\s*(?:Odgovori SAMO|Reply with ONE)|$)`) reNameBlock = regexp.MustCompile(`(?is)<\s*name\s*>\s*\{?(.*?)\}?\s*<\s*/\s*name\s*>`) reMetaBlock = regexp.MustCompile(`(?is)<\s*metaDescription\s*>\s*\{?(.*?)\}?\s*<\s*/\s*metaDescription\s*>`) reBodyBlock = regexp.MustCompile(`(?is)<\s*(h[1-4]|p|b|ul|li)\s*>\s*\{?(.*?)\}?\s*<\s*/\s*(?:h[1-4]|p|b|ul|li)\s*>`) reAttrsLine = regexp.MustCompile(`(?im)^\s*Attrs:\s*(\{.*\})\s*$`) reQuotedSlot = regexp.MustCompile(`"([^"]+)"`) ) type formulaBlock struct { tag string text string } type promptFacts struct { name string desc string category string attrs map[string]any } // buildFormulaReply returns an enhance JSON reply that satisfies the template in // user, or ok=false when the prompt carries no template (caller falls back). func buildFormulaReply(system, user string) (string, bool) { m := reTemplateHeader.FindStringSubmatch(user) if m == nil { return "", false } tpl := m[1] nameRule := "" if nm := reNameBlock.FindStringSubmatch(tpl); nm != nil { nameRule = strings.TrimSpace(nm[1]) } metaRule := "" if mm := reMetaBlock.FindStringSubmatch(tpl); mm != nil { metaRule = strings.TrimSpace(mm[1]) } body := reNameBlock.ReplaceAllString(tpl, "") body = reMetaBlock.ReplaceAllString(body, "") var blocks []formulaBlock for _, b := range reBodyBlock.FindAllStringSubmatch(body, -1) { tag := strings.ToLower(strings.TrimSpace(b[1])) blocks = append(blocks, formulaBlock{tag: tag, text: strings.TrimSpace(b[2])}) } if nameRule == "" && len(blocks) == 0 { return "", false } facts := readPromptFacts(user) name := buildFormulaName(nameRule, facts) desc := buildFormulaBody(blocks, name, facts) payload := map[string]any{ "name": name, "description": desc, } if strings.Contains(strings.ToLower(system), "meta_title") && metaRule != "" { payload["meta_title"] = truncateRunesLocal(name, 60) payload["meta_description"] = truncateRunesLocal(name+" — "+plainText(desc), 155) } if strings.Contains(strings.ToLower(system), `"attrs"`) { payload["attrs"] = facts.attrs } b, err := json.Marshal(payload) if err != nil { return "", false } return string(b), true } func readPromptFacts(user string) promptFacts { f := promptFacts{attrs: map[string]any{}} f.name = labeledLine(user, "Staro_ime_izdelka:", "Old_product_name:", "Name:") f.desc = labeledLine(user, "Star_opis_izdelka:", "Old_product_description:", "Description:") f.category = labeledLine(user, "Kategorija:", "Category:") if m := reAttrsLine.FindStringSubmatch(user); m != nil { var attrs map[string]any if err := json.Unmarshal([]byte(m[1]), &attrs); err == nil { f.attrs = attrs } } return f } func labeledLine(user string, labels ...string) string { for _, line := range strings.Split(user, "\n") { t := strings.TrimSpace(line) for _, label := range labels { if strings.HasPrefix(t, label) { if v := strings.TrimSpace(strings.TrimPrefix(t, label)); v != "" { return v } } } } return "" } // buildFormulaName fills the quoted slots of the formula from attrs. Slots // with no matching attribute are dropped rather than guessed. func buildFormulaName(nameRule string, f promptFacts) string { if nameRule == "" { return f.name } var parts []string seen := map[string]bool{} for _, m := range reQuotedSlot.FindAllStringSubmatch(nameRule, -1) { slot := strings.ToLower(strings.Trim(strings.TrimSpace(m[1]), `",. `)) if slot == "" { continue } v := valueForSlot(slot, f) if v == "" || seen[strings.ToLower(v)] { continue } seen[strings.ToLower(v)] = true parts = append(parts, v) } if len(parts) == 0 { return f.name } return strings.Join(parts, " ") } func valueForSlot(slot string, f promptFacts) string { switch { case strings.Contains(slot, "znamka") || strings.Contains(slot, "brand"): return attrString(f.attrs, "brand") case strings.Contains(slot, "poln model") || strings.Contains(slot, "product model") || strings.Contains(slot, "model izdelka") || slot == "model": return attrString(f.attrs, "product_model", "model", "sku") case strings.Contains(slot, "tip izdelka") || strings.Contains(slot, "product type") || strings.Contains(slot, "vrsta izdelka"): if v := attrString(f.attrs, "product_type"); v != "" { return v } return singularCategory(f.category) case strings.Contains(slot, "barva") || strings.Contains(slot, "colour") || strings.Contains(slot, "color"): return attrString(f.attrs, "barva", "color", "colour") case strings.Contains(slot, "procesor") || strings.Contains(slot, "processor"): return attrString(f.attrs, "procesor", "processor", "cpu") case strings.Contains(slot, "pomnilnik") || strings.Contains(slot, "memory"): return attrString(f.attrs, "kapaciteta_ram_pomnilnika", "memory", "ram") case strings.Contains(slot, "kapaciteta") || strings.Contains(slot, "capacity"): return attrString(f.attrs, "kapaciteta", "capacity") case strings.Contains(slot, "dimenzij") || strings.Contains(slot, "dimensions"): return attrString(f.attrs, "dimensions", "dimenzije") case strings.Contains(slot, "diagonal") || strings.Contains(slot, "screen"): return attrString(f.attrs, "diagonala_zaslona", "screen_size") default: return "" } } func attrString(attrs map[string]any, keys ...string) string { for _, k := range keys { for ak, av := range attrs { if !strings.EqualFold(strings.TrimSpace(ak), k) { continue } if s := strings.TrimSpace(fmt.Sprint(av)); s != "" && s != "" { return s } } } return "" } func singularCategory(cat string) string { c := strings.TrimSpace(cat) if c == "" { return "" } return strings.ToLower(c) } // buildFormulaBody emits one HTML element per template block, in order, with the // tag the block asks for. Paragraph text is drawn from the supplied description and // attributes so the reply stays inside the evidence the prompt provided. func buildFormulaBody(blocks []formulaBlock, name string, f promptFacts) string { sentences := splitSentences(f.desc) facts := attrSentences(f.attrs) var b strings.Builder para := 0 for _, blk := range blocks { instr := strings.ToLower(blk.text) switch blk.tag { case "h1", "h2", "h3", "h4": heading := name if strings.Contains(instr, "ne napiši") || strings.Contains(instr, "do not write") || strings.Contains(instr, "brez") || strings.Contains(instr, "without") { heading = benefitHeading(f) } fmt.Fprintf(&b, "<%s>%s", blk.tag, escapeHTML(heading), blk.tag) case "ul", "li": items := facts if len(items) == 0 { items = []string{"Category: " + f.category} } b.WriteString("") case "b": fmt.Fprintf(&b, "%s", escapeHTML(strings.TrimSpace(blk.text))) default: // p includeName := strings.Contains(instr, "vključi") || strings.Contains(instr, "does include") || strings.Contains(instr, "include the new product name") excludeName := strings.Contains(instr, "ne vsebuje") || strings.Contains(instr, "not contain") || strings.Contains(instr, "not include") text := paragraphFor(sentences, para, f) if includeName && !excludeName { text = name + " — " + text } fmt.Fprintf(&b, "

%s

", escapeHTML(text)) para++ } } return b.String() } func paragraphFor(sentences []string, idx int, f promptFacts) string { if len(sentences) == 0 { if c := strings.TrimSpace(f.category); c != "" { return "Listed under " + c + " with the details supplied by the supplier feed." } return "Details as supplied by the supplier feed." } // Rotate through the supplied sentences so repeated paragraphs differ, then pad // with attribute facts to a body length the quality gate accepts. var parts []string for i := 0; i < len(sentences); i++ { parts = append(parts, sentences[(idx+i)%len(sentences)]) } out := strings.Join(parts, " ") for _, fact := range attrSentences(f.attrs) { if len([]rune(out)) >= 220 { break } out += " " + fact + "." } return strings.TrimSpace(out) } func benefitHeading(f promptFacts) string { if c := strings.TrimSpace(f.category); c != "" { return "Built for everyday " + strings.ToLower(c) } return "Built for everyday use" } func attrSentences(attrs map[string]any) []string { keys := make([]string, 0, len(attrs)) for k := range attrs { keys = append(keys, k) } sort.Strings(keys) out := make([]string, 0, len(keys)) for _, k := range keys { v := strings.TrimSpace(fmt.Sprint(attrs[k])) if v == "" || v == "" { continue } out = append(out, prettyKey(k)+": "+v) if len(out) >= 8 { break } } return out } func prettyKey(k string) string { k = strings.ReplaceAll(strings.TrimSpace(k), "_", " ") if k == "" { return k } return strings.ToUpper(k[:1]) + k[1:] } func splitSentences(s string) []string { s = strings.TrimSpace(plainText(s)) if s == "" { return nil } var out []string for _, part := range strings.FieldsFunc(s, func(r rune) bool { return r == '.' || r == '!' || r == '?' }) { p := strings.TrimSpace(part) if len([]rune(p)) < 3 { continue } out = append(out, p+".") } return out } func plainText(s string) string { var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } return strings.Join(strings.Fields(b.String()), " ") } func escapeHTML(s string) string { r := strings.NewReplacer("<", "", ">", "", "&", "and") return strings.TrimSpace(r.Replace(s)) } func truncateRunesLocal(s string, n int) string { rs := []rune(strings.TrimSpace(s)) if len(rs) <= n { return string(rs) } return strings.TrimSpace(string(rs[:n])) }