package skills import ( "context" "encoding/json" "errors" "fmt" "slices" "strings" "time" "unicode" "unicode/utf8" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" ) // ReasonLimit is the most characters a Side quest's reason, its "Why // this?", may have. const ReasonLimit = 180 // SideQuestsRequest is what suggest_side_quests reads. type SideQuestsRequest struct { // Context is the household context (see harness.BuildContext). It must // hold at least one Regular, since every Side quest cites one. Context harness.HouseholdContext // Needed is how many Side quests the caller wants to keep. The skill // asks the model for twice as many, since validation and grounding // drop some. It must be positive. Needed int // Pending holds the names of the Side quests not yet tried or passed. Pending []string // Declined holds every passed name — each passed Side quest and each // dinner a Rotation check dropped — not only the recent ones Context // holds. Declined []string } // SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty // are the model's estimates until the dinner is grounded in a recipe. type SideQuest struct { Name string `json:"name"` Cuisine string `json:"cuisine"` // Method is one of stovetop, oven, crockpot, grill and other. Method string `json:"method"` EstMinutes int `json:"est_minutes"` // Difficulty is easy, medium or hard. Difficulty string `json:"difficulty"` // Reason is the "Why this?": at most ReasonLimit characters, naming at // least one of CitedRegulars. Reason string `json:"reason"` // CitedRegulars are names of the household's Regulars, as the // household writes them. CitedRegulars []string `json:"cited_regulars"` // SearchQuery is a web search query that finds a recipe for the dinner. SearchQuery string `json:"search_query"` } // SideQuests is suggest_side_quests' output. type SideQuests struct { // Suggestions are the Side quests that passed every rule, in the // model's order. There may be more or fewer than Needed. Suggestions []SideQuest `json:"suggestions"` // Dropped are the Side quests the model gave that broke a rule, in the // model's order. It is never part of the model's reply. Dropped []DroppedSideQuest `json:"-"` } // DroppedSideQuest is a Side quest dropped by PostProcess. type DroppedSideQuest struct { Name string // Why says which rule it broke. Why string } // SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side // quests that cite only real Regulars, give a short reason naming one of // them, and repeat no Regular, Quest log dinner, pending or passed Side // quest, nor each other. The rest are in Dropped. scope names the batch // (see harness.Run). func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) { return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in) } // suggestSideQuests is the skill. It holds its input because validation // and post-processing compare the output with it. type suggestSideQuests struct { in SideQuestsRequest } var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{} func (suggestSideQuests) Name() string { return "suggest_side_quests" } func (suggestSideQuests) Version() int { return 1 } func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout } // sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl. type sideQuestsPrompt struct { // Count is how many Side quests to ask for. Count int } // requested is how many Side quests the model is asked for. func (s suggestSideQuests) requested() int { return 2 * s.in.Needed } func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) { if in.Needed <= 0 { return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed) } if len(in.Context.Regulars) == 0 { return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on") } sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending}) return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil } func (suggestSideQuests) Schema() llm.JSONSchema { return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{ "type": "object", "properties": { "suggestions": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "cuisine": {"type": "string"}, "method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]}, "est_minutes": {"type": "integer"}, "difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]}, "reason": {"type": "string"}, "cited_regulars": {"type": "array", "items": {"type": "string"}}, "search_query": {"type": "string"} }, "required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"], "additionalProperties": false } } }, "required": ["suggestions"], "additionalProperties": false }`)} } // UnmarshalJSON decodes a Side quest, requiring each of its fields as the // schema does: encoding/json would otherwise read a missing or null one as // empty. func (q *SideQuest) UnmarshalJSON(data []byte) error { if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil { return fmt.Errorf("suggestion %s", err) } type plain SideQuest return json.Unmarshal(data, (*plain)(q)) } // UnmarshalJSON decodes the output, requiring its one field as the schema // does. func (out *SideQuests) UnmarshalJSON(data []byte) error { if err := requireFields(data, nil, "suggestions"); err != nil { return fmt.Errorf("output %s", err) } type plain SideQuests return json.Unmarshal(data, (*plain)(out)) } // Validate finds what makes the reply unusable as a whole: a wrong number // of Side quests or one that is malformed. A well-formed Side quest that // breaks a rule is dropped by PostProcess instead. func (s suggestSideQuests) Validate(out SideQuests) []string { var problems []string switch n := len(out.Suggestions); { case n == 0 && len(out.Dropped) == 0: problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested())) case n > s.requested(): problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested())) } for i, q := range out.Suggestions { for _, f := range []struct{ field, value string }{ {"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery}, } { if strings.TrimSpace(f.value) == "" { problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field)) } } if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" { problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name)) } if !slices.Contains(methods, q.Method) { problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", "))) } if q.EstMinutes <= 0 { problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes)) } if !slices.Contains(difficulties, q.Difficulty) { problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", "))) } } return problems } // PostProcess tidies every Side quest's text, writes its cited Regulars as // the household does, and moves each one that breaks a rule to Dropped // (see drop). func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) { regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}} var names []string for _, r := range s.in.Context.Regulars { regulars.add(r.Name) names = append(names, r.Name) } regulars.all = names known := harness.NewDeduper(harness.DedupeSources{ Regulars: names, QuestLog: s.in.Context.QuestLog, Pending: s.in.Pending, Declined: s.in.Declined, }) result := SideQuests{Suggestions: []SideQuest{}} for _, q := range out.Suggestions { q = SideQuest{ Name: oneLine(q.Name), Cuisine: oneLine(q.Cuisine), Method: q.Method, EstMinutes: q.EstMinutes, Difficulty: q.Difficulty, Reason: oneLine(q.Reason), CitedRegulars: q.CitedRegulars, SearchQuery: oneLine(q.SearchQuery), } cited, why := drop(q, regulars, known, result.Suggestions) if why != "" { result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why}) continue } q.CitedRegulars = cited result.Suggestions = append(result.Suggestions, q) } return result, nil } // regularNames finds the Regular a cited name means. Names need not be // unique, even ignoring case, so each key holds every Regular with it. type regularNames struct { // exact is keyed by the name in lower case on one line. exact map[string][]string // normalized is keyed by harness.Normalize of the name. normalized map[string][]string // all holds every Regular's name, in the household's order. all []string } func exactName(name string) string { return strings.ToLower(oneLine(name)) } func (r regularNames) add(name string) { r.exact[exactName(name)] = append(r.exact[exactName(name)], name) if n := harness.Normalize(name); n != "" { r.normalized[n] = append(r.normalized[n], name) } } // find returns the one Regular cited means, or "" and why there is none. // A Regular named exactly cited, ignoring case and spacing, comes first; // failing that, cited may name a Regular by its normalized name. func (r regularNames) find(cited string) (string, string) { matches := r.exact[exactName(cited)] if len(matches) == 0 { matches = r.normalized[harness.Normalize(cited)] } switch len(matches) { case 0: return "", fmt.Sprintf("cites %q, which is not a Regular", cited) case 1: return matches[0], "" default: return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches) } } // drop returns why q must be dropped, or "" and its cited Regulars as the // household writes them when it may be kept. q is dropped when it repeats // a known dinner or a Side quest already kept, cites no Regular or one // regulars finds no single Regular for, or has a reason over ReasonLimit // characters or naming none of its cited Regulars. func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) { if known.IsDuplicate(q.Name) { return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest" } for _, k := range kept { if harness.Similar(q.Name, k.Name) { return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name) } } if len(q.CitedRegulars) == 0 { return nil, "cites no Regular" } var cited []string for _, c := range q.CitedRegulars { name, why := regulars.find(c) if why != "" { return nil, why } cited = append(cited, name) } if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit { return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit) } named := regulars.named(q.Reason) if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) { return nil, "reason names none of its cited Regulars" } return cited, "" } // named returns the Regulars text names, by one rule. A mention of a // Regular is a place where text spells its name, ignoring case and spacing, // with no letter, mark or digit just outside it: the start or end of text, // white space, punctuation or a symbol must border it, so "Beef // Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef // Chilis" does not, nor does "Cafe" with a combining accent after it // mention "Cafe". Punctuation at either end of the name, with any spacing // beside it, is optional, and a mention takes in as much of it as text // spells, spaced in any way, with the border still holding after it: "Easy // Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili // !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef // Chili!!" by "Beef Chili!" with one "!". A // combining mark is part of its letter, never punctuation. A mention names // its Regular unless a mention of a different Regular covers the same text // or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef // Chili", and text that two Regulars spell alike names neither there. func (r regularNames) named(text string) []string { type mention struct { name string start, end int } text = exactName(text) bordered := func(start, end int) bool { before, _ := utf8.DecodeLastRuneInString(text[:start]) after, _ := utf8.DecodeRuneInString(text[end:]) return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after)) } var mentions []mention for _, name := range r.all { full := exactName(name) core := strings.TrimFunc(full, isOuter) if core == "" { continue } lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))] trail := full[len(lead)+len(core):] for at := 0; ; { i := strings.Index(text[at:], core) if i < 0 { break } start, end := at+i, at+i+len(core) at = start + 1 if !bordered(start, end) { continue } start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) }) end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) }) mentions = append(mentions, mention{name, start, end}) } } var names []string for _, m := range mentions { covered := slices.ContainsFunc(mentions, func(o mention) bool { return o.name != m.name && o.start <= m.start && o.end >= m.end }) if !covered && !slices.Contains(names, m.name) { names = append(names, m.name) } } return names } // spelledAfter returns where the longest start of outer that text spells // from at ends, ignoring spacing, among those after which bordered holds, // or at when there are none. func spelledAfter(text string, at int, outer string, bordered func(int) bool) int { end := at for _, c := range strings.Join(strings.Fields(outer), "") { at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace)) if !strings.HasPrefix(text[at:], string(c)) { break } at += utf8.RuneLen(c) if bordered(at) { end = at } } return end } // spelledBefore returns where the longest end of outer that text spells up // to at starts, ignoring spacing, among those before which bordered holds, // or at when there are none. func spelledBefore(text string, at int, outer string, bordered func(int) bool) int { start := at punct := []rune(strings.Join(strings.Fields(outer), "")) for i := len(punct) - 1; i >= 0; i-- { at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace)) if !strings.HasSuffix(text[:at], string(punct[i])) { break } at -= utf8.RuneLen(punct[i]) if bordered(at) { start = at } } return start } // isWord reports whether c is a letter, a mark on one or a digit. func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) } // isOuter reports whether c may be left off the end of a name: punctuation // or white space. func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) }