internal/autogen/promptenhancer.go
Rama main · réplica de lectura del repositorio original.
package autogen
import (
"path/filepath"
"strings"
)
// A prompt enhancer is architecturally invisible. Qwen's PE models are Qwen3.5-VL
// finetunes and their gguf header says so: general.architecture is "qwen3vl",
// identical to every other VL chat model on disk. So unlike the asr / tts /
// embedding classifiers in this package, which key on the arch (the gate that
// "matters", per family.go), this one has nothing but the NAME to go on.
//
// The name is unusually strong here, because a PE model is named after the IMAGE
// model it serves rather than after its own base: "Qwen-Image-2.1-PE-I2I" is a
// Qwen3.5-VL, and nothing in it says qwen3.5. That is what makes auto-pairing
// possible at all, and it is also why the marker must be narrow.
//
// Two things follow from a hit, and the second is why this file exists at all:
//
// 1. The id is offered as an enhancer, and pre-wired onto the image model whose
// name it carries. Low risk: a rewrite never runs without a click and always
// lands in the prompt box, so a wrong guess costs one visible bad rewrite and
// an Undo, never a wrong picture.
// 2. The file is withheld from the text-encoder pool. This is the one that bites
// today: a BF16 PE (17.9GB) and Qwen3VL-8B-Q8_0 (8.9GB) both rank qwen under
// encoderArchRank, so `better` breaks the tie on FILE SIZE and hands
// Qwen-Image 2.1 its own prompt rewriter as the text encoder it conditions
// on. That fails the way every mis-picked encoder fails: not an error, just a
// confidently unrelated image. Until now the only defence was a manual
// textEncoderPath pin.
// PE direction markers. t2i composes a scene from nothing; i2i rewrites an
// instruction about a picture that already exists.
const (
PEDirText = "t2i"
PEDirEdit = "i2i"
)
// promptEnhancerName reports whether an id/filename names a prompt-rewrite model,
// which direction it serves, and the family key of the image model it belongs to.
//
// Deliberately narrow: a bare "pe" token is not enough, because tier 2 changes
// which file feeds a diffusion model's caption projection, and reclassifying
// somebody's "Pegasus-7B" there is a worse failure than missing an oddly named
// enhancer. Either the "pe" carries a direction beside it, or the name spells
// "prompt-enhancer" out.
//
// dir is "" when the name says enhancer but not which way, which is the honest
// answer for a single-model setup: the caller treats it as the txt2img slot,
// which is also the fallback for both directions.
func promptEnhancerName(id string) (dir, family string, ok bool) {
s := strings.ToLower(strings.TrimSpace(id))
if s == "" {
return "", "", false
}
// Filenames arrive here too (the encoder pool holds paths, not ids).
s = strings.TrimSuffix(filepath.Base(s), ".gguf")
// One separator alphabet: publishers mix "-", "_" and "." freely
// ("qwen_image_2.1_pe_t2i" is the same model as "Qwen-Image-2.1-PE-T2I").
norm := strings.NewReplacer("_", "-", " ", "-").Replace(s)
parts := strings.Split(norm, "-")
// Spelled out: "prompt-enhancer", "prompt-enhance", "promptenhancer".
spelled := strings.Contains(strings.ReplaceAll(norm, "-", ""), "promptenhanc")
peAt := -1
dirAt := -1
for i, p := range parts {
// Match on the token up to its first ".": the quant tag usually rides
// on the last word ("Qwen-Image-2.1-PE-I2I.Q5_K_M"), which left the
// direction reading "i2i.q5" and the file passing as a plain encoder.
// The cut below still slices the untrimmed parts, so "2.1" survives.
if j := strings.IndexByte(p, '.'); j > 0 {
p = p[:j]
}
switch p {
case "pe":
if peAt < 0 {
peAt = i
}
case PEDirText, PEDirEdit:
if dirAt < 0 {
dirAt = i
dir = p
}
}
}
// A "pe" counts only next to its direction; a direction token counts on its
// own only when the name also spells the role out. Neither alone is enough.
adjacent := peAt >= 0 && dirAt == peAt+1
if !adjacent && !spelled {
return "", "", false
}
// Everything before the marker is the image model this enhancer serves. Cut
// at whichever marker came first: "qwen-image-2.1-pe-i2i" -> "qwen-image-2.1".
cut := len(parts)
if peAt >= 0 && peAt < cut {
cut = peAt
}
if dirAt >= 0 && dirAt < cut {
cut = dirAt
}
if spelled {
for i, p := range parts {
if strings.Contains(p, "prompt") && i < cut {
cut = i
}
}
}
if cut <= 0 {
// The whole name is the marker, so it names no family. Still an enhancer,
// just not one anything can be paired to automatically.
return dir, "", true
}
family = enhancerFamilyKey(strings.Join(parts[:cut], "-"))
return dir, family, true
}
// enhancerFamilyKey reduces a model id to the key both sides of the pairing are
// matched on. Separators are folded first: an id keeps whatever the publisher's
// filename used, and ModelBaseKey splits on "-" alone, so "qwen_image_2.1-q8_0"
// and "qwen-image-2.1-q8_0" would otherwise be two different families.
func enhancerFamilyKey(id string) string {
norm := strings.NewReplacer("_", "-", " ", "-").Replace(strings.ToLower(strings.TrimSpace(id)))
return ModelBaseKey(norm)
}
// IsPromptEnhancerFile reports whether a gguf path names a prompt-rewrite model.
// Used to withhold it from the text-encoder pool (see the file comment).
func IsPromptEnhancerFile(path string) bool {
_, _, ok := promptEnhancerName(path)
return ok
}
// autoEnhancers maps an image model's base key to the enhancer ids discovered
// beside it, one per direction.
type autoEnhancers map[string]*autoEnhancerPair
type autoEnhancerPair struct {
Text string // txt2img, also the fallback when only one was found
Edit string // img2img
}
// detectEnhancers builds the pairing table from the discovered catalog. Ids are
// row.ID, matching the name the emit loop gives a row unless two rows share an
// ID, where the loop appends a publisher tag to the SECOND one; first-wins here
// agrees with that.
//
// The base id is the only profile an enhancer gets (see enhancerCtx), so it is
// what both directions pair to.
func detectEnhancers(rows []GgufRow) autoEnhancers {
out := autoEnhancers{}
for _, row := range rows {
// An image/video/SAM row is never an enhancer, whatever it is called:
// the rewriter is a chat model.
if row.IsSam || row.IsTrellis {
continue
}
dir, family, ok := promptEnhancerName(row.ID)
if !ok || family == "" {
continue
}
p := out[family]
if p == nil {
p = &autoEnhancerPair{}
out[family] = p
}
switch dir {
case PEDirEdit:
if p.Edit == "" {
p.Edit = row.ID
}
default:
if p.Text == "" {
p.Text = row.ID
}
}
}
return out
}
// enhancerCtx is the context window a prompt enhancer is emitted with when
// nothing pins one. Its whole job fits in a few thousand tokens (the system
// prompt, a reference image, a paragraph out, plus reasoning), and the sizer's
// default of "as much as the budget allows" put a 262k window and ~16GB of VRAM
// behind a rewrite, which is also what made every press evict the image model.
// 32k leaves room for a large reference image and long reasoning.
const enhancerCtx = 32768
// For returns the enhancers discovered for an image model id, or nil. The lookup
// is on the model's base key, so every quant of an image model finds the same
// pair, and a finetune named after its base does too.
func (a autoEnhancers) For(imageID string) *autoEnhancerPair {
if len(a) == 0 {
return nil
}
return a[enhancerFamilyKey(imageID)]
}
// PEDisabled is the id that means "this model wants NO enhancer", as opposed to
// the empty string, which now means "nothing chosen, so use what was detected".
//
// The sentinel exists because auto-pairing gave the empty string a second
// meaning. Without it, clearing the field in the model modal would write "" and
// the next regeneration would helpfully put the enhancer straight back, and the
// user would have no way at all to say no. The UI writes this value when a field
// with a detected candidate is cleared.
const PEDisabled = "none"
// enhancerDisabled reports the sentinel. Case-insensitive: the id is typed by
// hand into a free-text field.
func enhancerDisabled(id string) bool {
return strings.EqualFold(strings.TrimSpace(id), PEDisabled)
}
// resolveEnhancerIDs decides which enhancer ids an image model actually carries:
// what it was configured with, else what was detected beside it on disk.
//
// Auto-pairing is safe HERE in a way it would not be on the encoder path,
// because of where the result lands: a rewrite never runs without a click and
// always appears in the prompt box for the user to read, edit or undo. A wrong
// guess costs one visible bad rewrite; the same guess made about a text encoder
// costs a confidently unrelated picture with nothing to look at.
//
// A configured id always wins, including the PEDisabled sentinel, so a user who
// has said no is never overruled by a filename.
func resolveEnhancerIDs(s Settings, imageID, text, edit string) (string, string) {
text, edit = enhancerBaseID(text), enhancerBaseID(edit)
if text != "" && edit != "" {
return text, edit
}
auto := s.autoEnhancers.For(imageID)
if auto == nil {
return text, edit
}
if text == "" {
text = auto.Text
}
if edit == "" {
edit = auto.Edit
}
return text, edit
}
// enhancerBaseID maps a configured "<enhancer>-vision" id to the enhancer's base
// id. Enhancers used to get a vision twin, and an i2i one was paired to it; the
// twin is no longer emitted (the base profile carries the projector in VRAM), so
// a pin saved back then would otherwise name a model that does not exist.
func enhancerBaseID(id string) string {
id = strings.TrimSpace(id)
if base, ok := strings.CutSuffix(id, "-vision"); ok {
if _, _, isPE := promptEnhancerName(base); isPE {
return base
}
}
return id
}
// PromptEnhancerID is the exported classifier: whether a catalog model id names
// a prompt rewriter, which direction (PEDirText / PEDirEdit, or "" when the name
// does not say) and the family key of the image model it was named after.
//
// The server uses it twice: to suggest the ids it found as enhancer candidates,
// and to decide whether one with no settings row should be sent the reference
// image. An "-i2i" rewriter that cannot see the picture it is rewriting an
// instruction about is the one case where guessing is better than not.
func PromptEnhancerID(id string) (dir, family string, ok bool) {
return promptEnhancerName(id)
}