This commit is contained in:
2026-08-23 20:49:40 +02:00
parent 3b678ca857
commit f3d4fb56ed
31 changed files with 3608 additions and 111 deletions
+409
View File
@@ -0,0 +1,409 @@
// Command formula-e2e runs the real processing pipeline against the local database
// and reports whether category formulas actually drove the generated copy.
//
// It is a local proof, not a test double: it builds processing.Pipeline the way
// cmd/worker does (same AI resolution, same prompts service, same Engine), starts a
// real job, processes it, and then reads processed_products back.
//
// go run ./cmd/mock-llm -addr 127.0.0.1:18767 &
// go run ./cmd/formula-e2e -company "A1 Slovenija" -limit 3
// go run ./cmd/formula-e2e -company "A1 Slovenija" -gtin 8022068075495
// go run ./cmd/formula-e2e -new-tenant # English defaults on a fresh company
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
companyName := flag.String("company", "A1 Slovenija", "company name to process")
gtin := flag.String("gtin", "", "specific GTIN to process (default: first products with a category)")
limit := flag.Int("limit", 2, "how many products to process")
ptype := flag.String("type", "full", "processing_type")
newTenant := flag.Bool("new-tenant", false, "create a throwaway English tenant + category + product and process that")
keep := flag.Bool("keep", false, "keep the throwaway tenant instead of deleting it")
llmBase := flag.String("llm-base", "", "override the resolved LLM base URL (e.g. http://127.0.0.1:18767/v1 for cmd/mock-llm)")
llmKey := flag.String("llm-key", "local-test", "API key for -llm-base")
llmModel := flag.String("llm-model", "mock-llm", "model id for -llm-base")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("db ping: %v", err)
}
pipeline := newWorkerLikePipeline(ctx, pool, cfg)
if strings.TrimSpace(*llmBase) != "" {
// Everything else stays production-identical; only the provider endpoint is
// pinned so a local run does not depend on the configured remote model.
pipeline.AI = fixedCompleter{
client: processing.NewOpenAIClient(*llmKey, *llmBase, *llmModel, 0, 2),
}
log.Printf("LLM override: base=%s model=%s", *llmBase, *llmModel)
}
if *newTenant {
if err := runNewTenant(ctx, pool, pipeline, *keep); err != nil {
log.Fatalf("new tenant: %v", err)
}
return
}
if err := runExisting(ctx, pool, pipeline, *companyName, *gtin, *limit, *ptype); err != nil {
log.Fatalf("run: %v", err)
}
}
// fixedCompleter pins every job to one endpoint (local mock-llm) while leaving the
// rest of the pipeline exactly as the worker builds it.
type fixedCompleter struct{ client processing.Completer }
func (f fixedCompleter) ResolveCompleter(context.Context, uuid.UUID) (processing.Completer, string, bool, error) {
return f.client, processing.AIProviderInternal, false, nil
}
func (f fixedCompleter) ResolveCompleterForRole(ctx context.Context, id uuid.UUID, _ string) (processing.Completer, string, bool, error) {
return f.ResolveCompleter(ctx, id)
}
// newWorkerLikePipeline mirrors cmd/worker/main.go so this proof exercises the same
// code path production does.
func newWorkerLikePipeline(ctx context.Context, pool *pgxpool.Pool, cfg config.Config) *processing.Pipeline {
platSettings := platformsettings.NewService(pool, platformsettings.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
})
aiSvc := aiprovider.NewService(pool, aiprovider.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
ProcessingRPM: cfg.ProcessingRPM,
ProcessingMaxRetries: cfg.ProcessingMaxRetries,
})
aiSvc.Platform = platSettings
p := processing.NewPipeline(pool)
p.AI = aiSvc
p.Prompts = aiprompts.NewService(pool)
p.Limiter = nil
p.Engine = &processing.Engine{
Vector: processing.NoopVectorCategorizer{},
ProviderMode: processing.AIProviderInternal,
}
if oi, err := platSettings.ResolveOpenAI(ctx); err != nil {
log.Printf("WARNING: platform OpenAI resolve failed: %v", err)
} else if strings.TrimSpace(oi.APIKey) == "" {
log.Printf("WARNING: platform OpenAI unset — AI enhance will be skipped")
} else {
log.Printf("OpenAI: source=%s base=%s model=%s", oi.Source, oi.BaseURL, oi.Model)
}
return p
}
func runExisting(ctx context.Context, pool *pgxpool.Pool, p *processing.Pipeline, companyName, gtin string, limit int, ptype string) error {
var companyID uuid.UUID
var lang string
err := pool.QueryRow(ctx, `
SELECT id, COALESCE(language, '') FROM companies WHERE name = $1`, companyName).Scan(&companyID, &lang)
if err != nil {
return fmt.Errorf("company %q: %w", companyName, err)
}
fmt.Printf("\n=== %s (%s, language=%s)\n", companyName, companyID, lang)
var rawIDs []uuid.UUID
if strings.TrimSpace(gtin) != "" {
rows, err := pool.Query(ctx, `
SELECT id FROM raw_products WHERE company_id = $1 AND gtin = $2 LIMIT $3`,
companyID, strings.TrimSpace(gtin), limit)
if err != nil {
return err
}
rawIDs, err = collectIDs(rows)
if err != nil {
return err
}
} else {
rows, err := pool.Query(ctx, `
SELECT id FROM raw_products
WHERE company_id = $1
AND COALESCE(NULLIF(trim(mapped_data->>'category'), ''), '') <> ''
AND length(COALESCE(mapped_data->>'description', '')) > 120
ORDER BY id
LIMIT $2`, companyID, limit)
if err != nil {
return err
}
rawIDs, err = collectIDs(rows)
if err != nil {
return err
}
}
if len(rawIDs) == 0 {
return fmt.Errorf("no matching raw products")
}
return processAndReport(ctx, pool, p, companyID, rawIDs, ptype)
}
func processAndReport(ctx context.Context, pool *pgxpool.Pool, p *processing.Pipeline, companyID uuid.UUID, rawIDs []uuid.UUID, ptype string) error {
// Clear prior enhance hashes so the run cannot be skipped as "unchanged".
if _, err := pool.Exec(ctx, `
UPDATE processed_products
SET field_sources = field_sources - 'enhance_input_hash'
WHERE company_id = $1 AND raw_product_id = ANY($2)`, companyID, rawIDs); err != nil {
return err
}
var userID uuid.UUID
if err := pool.QueryRow(ctx, `
SELECT user_id FROM memberships WHERE company_id = $1 ORDER BY created_at LIMIT 1`, companyID).Scan(&userID); err != nil {
return fmt.Errorf("membership: %w", err)
}
jobs, err := p.StartJob(ctx, companyID, userID, rawIDs, ptype)
if err != nil {
return fmt.Errorf("start job: %w", err)
}
for _, j := range jobs {
if err := p.ProcessJob(ctx, j.ID); err != nil {
return fmt.Errorf("process job %s: %w", j.ID, err)
}
}
return report(ctx, pool, companyID, rawIDs)
}
func report(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, rawIDs []uuid.UUID) error {
rows, err := pool.Query(ctx, `
SELECT COALESCE(rp.gtin, ''),
COALESCE(pp.category, ''),
COALESCE(rp.mapped_data->>'name', ''),
COALESCE(rp.mapped_data->>'description', ''),
COALESCE(pp.processed_name, ''),
COALESCE(pp.processed_description, ''),
COALESCE(pp.field_sources::text, '{}'),
COALESCE(pp.processed_attributes::text, '{}')
FROM raw_products rp
LEFT JOIN processed_products pp ON pp.raw_product_id = rp.id AND pp.company_id = rp.company_id
WHERE rp.company_id = $1 AND rp.id = ANY($2)`, companyID, rawIDs)
if err != nil {
return err
}
defer rows.Close()
fails := 0
for rows.Next() {
var gtin, category, feedName, feedDesc, name, desc, sources, attrs string
if err := rows.Scan(&gtin, &category, &feedName, &feedDesc, &name, &desc, &sources, &attrs); err != nil {
return err
}
fmt.Printf("\n---------------- GTIN %s category=%q\n", gtin, category)
fmt.Printf("FEED name : %s\n", trunc(feedName, 140))
fmt.Printf("FEED desc : %s\n", trunc(plain(feedDesc), 200))
fmt.Printf("NEW name : %s\n", trunc(name, 140))
fmt.Printf("NEW desc : %s\n", trunc(desc, 400))
fmt.Printf("attrs : %s\n", trunc(attrs, 200))
fmt.Printf("sources : %s\n", trunc(sources, 240))
var problems []string
if strings.TrimSpace(name) == "" || strings.TrimSpace(desc) == "" {
problems = append(problems, "empty processed output")
}
if strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(feedName)) {
problems = append(problems, "NAME copied from feed")
}
if strings.EqualFold(strings.TrimSpace(plain(desc)), strings.TrimSpace(plain(feedDesc))) {
problems = append(problems, "DESCRIPTION copied from feed")
}
if !strings.Contains(strings.ToLower(desc), "<h") && !strings.Contains(strings.ToLower(desc), "<p") {
problems = append(problems, "description is not formula HTML")
}
if len(problems) == 0 {
fmt.Printf("VERDICT : OK — generated from formula\n")
} else {
fails++
fmt.Printf("VERDICT : FAIL — %s\n", strings.Join(problems, "; "))
}
}
if err := rows.Err(); err != nil {
return err
}
if fails > 0 {
// Returned, never os.Exit: the throwaway-tenant cleanup is a defer.
return fmt.Errorf("%d product(s) did not generate from their category formula", fails)
}
fmt.Printf("\nall products generated from their category formula\n")
return nil
}
// runNewTenant proves the platform default: a brand-new company and category get
// English formulas at create time and enhance into that shape.
func runNewTenant(ctx context.Context, pool *pgxpool.Pool, p *processing.Pipeline, keep bool) error {
suffix := uuid.NewString()[:8]
companyName := "formula-e2e-" + suffix
var companyID uuid.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO companies (name, language, content_languages)
VALUES ($1, 'en', ARRAY['en']) RETURNING id`, companyName).Scan(&companyID); err != nil {
return err
}
if !keep {
defer func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID); err != nil {
log.Printf("cleanup: %v", err)
} else {
fmt.Printf("\ncleaned up throwaway tenant %s\n", companyName)
}
}()
}
var userID uuid.UUID
if err := pool.QueryRow(ctx, `SELECT id FROM users ORDER BY created_at LIMIT 1`).Scan(&userID); err != nil {
return fmt.Errorf("need at least one user: %w", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO memberships (user_id, company_id, role) VALUES ($1, $2, 'admin')
ON CONFLICT DO NOTHING`, userID, companyID); err != nil {
return err
}
if err := grantAIPlan(ctx, pool, companyID); err != nil {
return err
}
// Category created through the real service — that is where defaults are applied.
svc := &catalog.Service{Pool: pool}
cat, err := svc.CreateCategory(ctx, companyID, "High chairs", "high-chairs", nil, nil)
if err != nil {
return fmt.Errorf("create category: %w", err)
}
fmt.Printf("\n=== new tenant %s (%s)\n", companyName, companyID)
printCategoryFormulas(cat)
mapped := map[string]any{
"name": "BRUNNER folding camping chair ONE SHOT grey black 0404164N.C20",
"description": "An elegant and very light chair designed for art directors. Folding aluminium frame, comfortable seat and armrests.",
"brand": "BRUNNER",
"gtin": "8022068075495",
"category": "high-chairs",
"specifications": map[string]any{
"Colour": "grey black",
"Material": "aluminium",
},
}
mappedJSON, _ := json.Marshal(mapped)
var rawID uuid.UUID
if err := pool.QueryRow(ctx, `
INSERT INTO raw_products (company_id, gtin, mapped_data, raw_data)
VALUES ($1, $2, $3::jsonb, '{}'::jsonb) RETURNING id`,
companyID, "8022068075495", string(mappedJSON)).Scan(&rawID); err != nil {
return err
}
return processAndReport(ctx, pool, p, companyID, []uuid.UUID{rawID}, "full")
}
func grantAIPlan(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) error {
// Reuse whichever plan the A1 tenant is on so entitlements (can_use_ai) match a
// real paid tenant rather than a hand-built feature map.
var planID int64
err := pool.QueryRow(ctx, `
SELECT cp.plan_id FROM company_plans cp
JOIN companies c ON c.id = cp.company_id
WHERE c.name = 'A1 Slovenija' LIMIT 1`).Scan(&planID)
if err != nil {
log.Printf("no reference plan found (%v) — enhance may be gated", err)
return nil
}
if _, err := pool.Exec(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start,
next_billing_date, total_credits_allocated)
VALUES ($1, $2, true, now(), now() + interval '30 days', 100000)`, companyID, planID); err != nil {
return err
}
_, err = pool.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits)
VALUES ($1, 100000, 0)
ON CONFLICT (company_id) DO UPDATE SET total_credits = 100000, used_credits = 0`, companyID)
return err
}
func printCategoryFormulas(cat map[string]any) {
for _, k := range []string{"prompts", "title_template", "description_template"} {
v, ok := cat[k]
if !ok || v == nil {
fmt.Printf("%-22s: (none)\n", k)
continue
}
b, _ := json.Marshal(v)
fmt.Printf("%-22s: %s\n", k, trunc(string(b), 420))
}
}
func collectIDs(rows interface {
Next() bool
Scan(...any) error
Err() error
Close()
}) ([]uuid.UUID, error) {
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
func plain(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 trunc(s string, n int) string {
s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ")
rs := []rune(s)
if len(rs) <= n {
return s
}
return string(rs[:n]) + "…"
}
+344
View File
@@ -0,0 +1,344 @@
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]))
}
+13 -4
View File
@@ -140,10 +140,19 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
system, user := splitMessages(req.Messages)
comp, err := processing.HeuristicCompleter{}.Complete(context.Background(), system, user)
if err != nil {
http.Error(w, `{"error":{"message":"completer failed"}}`, http.StatusInternalServerError)
return
var comp processing.Completion
// A category formula in the prompt is answered in that formula's shape, so a
// local run proves the formula reached the model and the reply passes the
// pipeline's formula gate. Everything else keeps the heuristic fallback.
if text, ok := buildFormulaReply(system, user); ok {
comp = processing.Completion{Text: text}
} else {
var err error
comp, err = processing.HeuristicCompleter{}.Complete(context.Background(), system, user)
if err != nil {
http.Error(w, `{"error":{"message":"completer failed"}}`, http.StatusInternalServerError)
return
}
}
model := strings.TrimSpace(req.Model)
if model == "" {