This commit is contained in:
2026-08-23 23:14:57 +02:00
parent 296dc3c841
commit a246ed8fe5
6 changed files with 399 additions and 38 deletions
+64 -9
View File
@@ -31,14 +31,15 @@ import (
)
type server struct {
apiKey string
model string
logReq atomic.Int64
apiKey string
model string
categorizeConfidence float64
logReq atomic.Int64
}
type chatRequest struct {
Model string `json:"model"`
Messages []struct {
Model string `json:"model"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
@@ -50,11 +51,16 @@ func main() {
addr := flag.String("addr", "127.0.0.1:18767", "listen address")
key := flag.String("key", envOr("MOCK_LLM_API_KEY", "local-test"), "Bearer API key (non-empty placeholder)")
model := flag.String("model", envOr("MOCK_LLM_MODEL", "mock-llm"), "model id returned by /v1/models")
// Lets a local run reproduce a low-confidence taxonomy pick (see
// processing.DefaultMinCategorizeConfidence).
categorizeConfidence := flag.Float64("categorize-confidence", 0,
"when > 0, answer categorize calls with this confidence score")
flag.Parse()
s := &server{
apiKey: strings.TrimSpace(*key),
model: strings.TrimSpace(*model),
apiKey: strings.TrimSpace(*key),
model: strings.TrimSpace(*model),
categorizeConfidence: *categorizeConfidence,
}
if s.apiKey == "" {
log.Fatal("mock-llm: API key must be non-empty (Descrybe Completer.Enabled requires it)")
@@ -77,6 +83,13 @@ func main() {
}
}
func truncTrace(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func envOr(k, def string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
@@ -140,11 +153,17 @@ func (s *server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
system, user := splitMessages(req.Messages)
if os.Getenv("MOCK_LLM_TRACE") != "" {
log.Printf("mock-llm: roles=%d system_len=%d user_len=%d system_head=%q",
len(req.Messages), len(system), len(user), truncTrace(system, 60))
}
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 {
if text, ok := s.categorizeReply(system, user); ok {
comp = processing.Completion{Text: text}
} else if text, ok := buildFormulaReply(system, user); ok {
comp = processing.Completion{Text: text}
} else {
var err error
@@ -204,6 +223,39 @@ func (s *server) handleEmbeddings(w http.ResponseWriter, r *http.Request) {
})
}
// categorizeReply overrides the heuristic taxonomy pick so a local run can
// reproduce a low-confidence answer.
func (s *server) categorizeReply(system, user string) (string, bool) {
if s.categorizeConfidence <= 0 || !strings.Contains(strings.ToLower(system), "categoryid") {
return "", false
}
id := firstCategoryIDFromPrompt(user)
if id == "" {
return "", false
}
b, err := json.Marshal(map[string]any{"categoryId": id, "confidence": s.categorizeConfidence})
if err != nil {
return "", false
}
return string(b), true
}
// firstCategoryIDFromPrompt mirrors the "(ID: …)" format of the categorize prompt.
func firstCategoryIDFromPrompt(user string) string {
const marker = "(id:"
lower := strings.ToLower(user)
at := strings.Index(lower, marker)
if at < 0 {
return ""
}
rest := strings.TrimSpace(user[at+len(marker):])
end := strings.Index(rest, ")")
if end <= 0 {
return ""
}
return strings.TrimSpace(rest[:end])
}
func splitMessages(msgs []struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -211,7 +263,10 @@ func splitMessages(msgs []struct {
var users []string
for _, m := range msgs {
switch strings.ToLower(strings.TrimSpace(m.Role)) {
case "system":
// Reasoning-model requests send the system message as "developer"
// (see processing.OpenAIClient sysRole). Treating it as a user turn made the
// mock mis-route every call whose branch depends on the system prompt.
case "system", "developer":
if system == "" {
system = m.Content
} else {