345 lines
10 KiB
Go
345 lines
10 KiB
Go
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 <name> 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 != "<nil>" {
|
||
|
|
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</%s>", blk.tag, escapeHTML(heading), blk.tag)
|
||
|
|
case "ul", "li":
|
||
|
|
items := facts
|
||
|
|
if len(items) == 0 {
|
||
|
|
items = []string{"Category: " + f.category}
|
||
|
|
}
|
||
|
|
b.WriteString("<ul>")
|
||
|
|
for _, it := range items {
|
||
|
|
fmt.Fprintf(&b, "<li>%s</li>", escapeHTML(it))
|
||
|
|
}
|
||
|
|
b.WriteString("</ul>")
|
||
|
|
case "b":
|
||
|
|
fmt.Fprintf(&b, "<b>%s</b>", 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, "<p>%s</p>", 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 == "<nil>" {
|
||
|
|
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]))
|
||
|
|
}
|