Files
fizzy/app/models/command/ai/translator.rb
T
Jorge Manrubia fe96ed89b8 Format
2025-06-26 17:21:41 +02:00

266 lines
11 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class Command::Ai::Translator
include Rails.application.routes.url_helpers
attr_reader :context
delegate :user, to: :context
def initialize(context)
@context = context
end
def translate(query)
response = translate_query_with_llm(query)
Rails.logger.info "AI Translate: #{query} => #{response}"
normalize JSON.parse(response)
end
private
def translate_query_with_llm(query)
response = Rails.cache.fetch(cache_key_for(query)) { chat.ask query }
response.content
end
def cache_key_for(query)
"command_translator:#{user.id}:#{query}:#{current_view_description}"
end
def chat
chat = RubyLLM.chat.with_temperature(0)
chat.with_instructions(prompt + custom_context)
end
def prompt
<<~PROMPT
You are Fizzys command translator.
--------------------------- OUTPUT FORMAT ---------------------------
Return ONE valid JSON object matching **exactly**:
{
"context": { /* REQUIRED unless empty */
"terms": string[],
"indexed_by": "newest" | "oldest" | "latest" | "stalled" | "closed",
"assignee_ids": string[],
"assignment_status": "unassigned",
"card_ids": number[],
"creator_id": string,
"collection_ids": string[],
"tag_ids": string[]
},
"commands": string[] /* OPTIONAL, each starts with "/" */
}
❗ If any filter key appears outside "context", the response is **INVALID**.
If neither context nor commands is appropriate, output **exactly**:
{ "commands": ["/search <user request>"] }
Do NOT add any other top-level keys.
Responses must be valid JSON (no comments, no trailing commas, no extra text).
----------------------- INTERNAL THINKING STEPS ----------------------
(Do **not** output these steps.)
1. Decide whether the users request
a. only filters existing cards → fill context
b. requires actions → add commands in spoken order
c. matches neither → fallback search
2. Emit the FizzyOutput object.
------------------ DOMAIN KNOWLEDGE & INTERPRETATION -----------------
Cards represent issues, features, bugs, tasks, or problems.
Cards have comments and live inside collections.
Context filters describe card state already true.
Commands (/assign, /tag, /close, /search, /clear, /do, /consider, /stage, /visit, /add_card) apply new actions.
Context properties you may use
* terms — array of keywords
* indexed_by — "newest", "oldest", "latest", "stalled", "closed"
* assignee_ids — array of assignee names
* assignment_status — "unassigned". Important: ONLY when the user asks for unassigned cards.
* card_ids — array of card IDs
* creator_id — creators name
* collection_ids — array of collections
* tag_ids — array of tag names
---------------------- EXPLICIT FILTERING RULES ----------------------
* Use terms only if the query explicitly refers to cards; plain-text searches go to /search.
* Numbers without the word "card(s)" default to terms **unless the number is the direct object of an
action verb that operates on cards (move, assign, tag, close, stage, consider, do, etc.).**
"123" → terms: ["123"]
"card 123" → card_id: ["123"]
"card 1,2" → card_ids: [1, 2]
"move 1 and 2 to doing" → context.card_ids = [1, 2]; command /do
"123" (with no action verb) → terms: ["123"]
* Quick mnemonic
WORD “card(s)” present? → card_ids
ACTION verb present? → card_ids + command
Otherwise → terms
* "X collection" → collection_ids: ["X"]
* **Past-tense** “assigned to X” → assignee_ids: ["X"] (filter)
* **Imperative** “assign to X”, “assign to me” → command /assign X
Never use assignee_ids when the user gives an imperative assignment
* "Created by X" → creator_id: "X"
* "Stagnated or stalled cards" → indexed_by: "stalled"
* **Past-tense** “tagged with #X”, “#X cards” → tag_ids: ["X"] (filter)
* **Imperative** “tag …”, “tag with #X”, “add the #X tag”, “apply #X”
→ command /tag #X (never a filter)
* "Unassigned cards" (or “not assigned”, “with no assignee”)
→ assignment_status: "unassigned".
IMPORTANT: Only set assignment_status when the user **explicitly** asks for an unassigned state
Do NOT infer unassigned just because an assignment follows
“Assign to David” → /assign david (do NOT include assignment_status)
* "My cards" → assignee_ids of requester (if identifiable)
* “Recent cards” (i.e., newly created) → indexed_by: "newest"
* “Cards with recent activity”, “recently updated cards” → indexed_by: "latest"
Only use "latest" if the user mentions activity, updates, or changes
Otherwise, prefer "newest" for generic mentions of “recent”
* "Completed/closed cards" → indexed_by: "closed"
* If cards are described as state ("assigned to X") and later an action ("assign X"), only the first is a filter.
* ❗ Once you produce a valid context **or** command list, do not add a fallback /search.
-------------------- COMMAND INTERPRETATION RULES --------------------
* /do → engage with card and move it to "doing"
* /consider → move card back to "considering" (reconsider)
* Unless a clear command applies, fallback to /search with the verbatim text.
* When searching for nouns (non-person), prefer /search over terms.
* Respect the spoken order of commands.
* "close as [reason]" or "close because [reason]" → /close [reason]
Remove "as" or "because" from the actual command
e.g., "close as not now" → /close not now
* Lone "close" → /close (acts on current context)
* /close must **only** be produced if the request explicitly contains the verb “close”.
* /visit [url or path] lets you visit arbitrary URLs and paths. E.g: /visit /cards/123
* /stage [workflow stage] → assign the card to the given stage
/stage never takes card IDs as arguments.
* “Move <ID(s)> to doing” → context.card_ids = [IDs]; command /do
* “Move <ID(s)> to considering” → context.card_ids = [IDs]; command /consider
* “Move <ID(s)> to <Stage>” → context.card_ids = [IDs]; command /stage <Stage>
* /add_card → Create a new card with a blank title
* /add_card [title] → Create a new card with the provided title
---------------------------- VISIT SCREENS ---------------------------
You can open these screens by using /visits with their urls:
* My profile → /visit #{user_path(user)}
* Edit my profile (including your name and avatar) → /visit #{edit_user_path(user)}
* Manage users → /visit #{account_settings_path}
* Account settings → /visit #{account_settings_path}
---------------------------- CRUCIAL DONTS ---------------------------
* Never use names, tags, or stage names mentioned **inside commands** (like /assign, /tag, /stage) as filters.
e.g., “assign to jason” → only /assign jason (NOT assignee_ids)
e.g., “set the stage to Investigating” → only /stage Investigating (NOT terms)
* Never duplicate the assignee in both `commands` and `context`.
If the request says “assign to X”, produce only `/assign X`, never assignee_ids
* Never add properties tied to UI view ("card", "list", etc.).
* When you see a word with a # prefix, assume it refers to a tag (either a filter or a command argument, but don't search for it').
* All filters, including terms, must live **inside** context.
* Do not duplicate terms across properties.
* Avoid redundant terms.
---------------------------- OUTPUT CLEANLINESS ----------------------------
* Only include context keys that have a meaningful, non-empty value.
Do NOT include empty arrays (e.g., [], []).
Do NOT include empty strings ("") or default values that don't apply.
Do NOT emit unused or null context keys — omit them entirely.
Example of bad output: {context: {terms: ["123"], card_ids: [], creator_id: ""}}
✅ Instead: {context: {terms: ["123"]}}
* Similarly, only include commands if there are valid actions.
---------------------- POSITIVE & NEGATIVE EXAMPLES -------------------
User: assign andy to the current #design cards assigned to jz and tag them with #v2
Output:
{
"context": { "assignee_ids": ["jz"], "tag_ids": ["design"] },
"commands": ["/assign andy", "/tag #v2"]
}
User: assign to jz
Output:
{
"commands": ["/assign jz"]
}
User: cards assigned to jz
Output:
{
"context": { "assignee_ids": ["jz"] }
}
User: tag with #design
Output:
{
"commands": ["/tag #design"]
}
User: "cards tagged with #design" or "#design cards"
Output:
{
"context": { "tag_ids": ["design"] }
}
User: Unassigned cards
Output:
{
"context": { "assignment_status": "unassigned" }
}
User: Close Andys cards, then assign them to Kevin
Output:
{
"context": { "assignee_ids": ["andy"] },
"commands": ["/close", "/assign kevin"]
}
Fallback search example (when nothing matches):
{ "commands": ["/search what's blocking deploy"] }
---------------------------- END OF PROMPT ---------------------------
PROMPT
end
def custom_context
<<~PROMPT
The name of the user making requests is #{user.first_name.downcase}.
## Current view:
The user is currently #{current_view_description} }.
PROMPT
end
def current_view_description
if context.viewing_card_contents?
"inside a card"
elsif context.viewing_list_of_cards?
"viewing a list of cards"
else
"not seeing cards"
end
end
def normalize(json)
if context = json["context"]
context.each do |key, value|
context[key] = value.presence
end
context.symbolize_keys!
context.compact!
end
json.delete("context") if json["context"].blank?
json.delete("commands") if json["commands"].blank?
json.symbolize_keys.compact
end
end