Files
miti99bot/internal/modules/twentyq/parser.go
T
tiennm99 ce99633e25 feat(modules): port new command modules + update registry
- Add new modules: doantu, semantle, twentyq, ai (Gemini integration)
- Update module registry with new command registration
- Update tests and documentation for module system
- Update README with new module references
2026-05-10 02:29:58 +07:00

128 lines
3.1 KiB
Go

package twentyq
import (
"encoding/json"
"regexp"
"strings"
)
// Judgement is the canonical shape after parse + normalize. JS-parity field
// names — IsGuess for "is_guess", Answer ∈ {"yes","no"}.
type Judgement struct {
IsGuess bool `json:"is_guess"`
Answer string `json:"answer"`
Hint string `json:"hint"`
}
// RoundStart is the LLM's reply for the round-start prompt.
type RoundStart struct {
Category string `json:"category"`
InitialHint string `json:"initialHint"`
}
const defaultHint = "I couldn't fully parse that — try a clear yes/no question."
// fenceRe strips ```json / ``` code fences if the model disobeys the prompt.
var fenceRe = regexp.MustCompile("(?i)```(?:json)?")
// parseJSON returns the first balanced {...} JSON object found in `text`.
// Returns nil on parse failure — matches JS parseJudgementJson tolerance.
func parseJSON(text string) map[string]any {
if text == "" {
return nil
}
unfenced := strings.ReplaceAll(fenceRe.ReplaceAllString(text, ""), "```", "")
start := strings.IndexByte(unfenced, '{')
if start < 0 {
return nil
}
depth := 0
inString := false
escaped := false
for i := start; i < len(unfenced); i++ {
ch := unfenced[i]
if inString {
switch {
case escaped:
escaped = false
case ch == '\\':
escaped = true
case ch == '"':
inString = false
}
continue
}
switch ch {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
slice := unfenced[start : i+1]
var out map[string]any
if err := json.Unmarshal([]byte(slice), &out); err != nil {
return nil
}
return out
}
}
}
return nil
}
// normalizeJudgement coerces a parsed payload to the canonical Judgement
// shape with safe defaults. JS-parity behaviour.
func normalizeJudgement(payload map[string]any) Judgement {
out := Judgement{Answer: "no", Hint: defaultHint}
if payload == nil {
return out
}
if v, ok := payload["is_guess"].(bool); ok {
out.IsGuess = v
}
if v, ok := payload["answer"].(string); ok {
if strings.EqualFold(v, "yes") {
out.Answer = "yes"
}
}
if v, ok := payload["hint"].(string); ok {
if t := strings.TrimSpace(v); t != "" {
out.Hint = t
}
}
return out
}
// redactSecret blanks any whole-word case-insensitive match of `target` in
// `hint`. Defense-in-depth — the prompt forbids it, but never trust the
// model. Word boundaries: ASCII-letter neighbours.
func redactSecret(hint, target string) string {
if target == "" {
return hint
}
pattern := `(?i)\b` + regexp.QuoteMeta(target) + `\b`
re, err := regexp.Compile(pattern)
if err != nil {
return hint
}
return re.ReplaceAllString(hint, "(redacted)")
}
// parseRoundStart returns (category, initialHint) or zero values + nil on
// any failure. Caller substitutes JS-parity fallbacks.
func parseRoundStart(payload map[string]any) (string, string, bool) {
if payload == nil {
return "", "", false
}
cat, _ := payload["category"].(string)
hint, _ := payload["initialHint"].(string)
cat = strings.TrimSpace(cat)
hint = strings.TrimSpace(hint)
if cat == "" || hint == "" {
return "", "", false
}
return cat, hint, true
}