Add support for filtering by creation or completion windows of times

This commit is contained in:
Jorge Manrubia
2025-06-27 09:52:36 +02:00
parent fe8e73015c
commit 3fbfa1404c
28 changed files with 86907 additions and 60 deletions
+1
View File
@@ -8,6 +8,7 @@ module Card::Closeable
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) }
end
def closed?
+106 -58
View File
@@ -33,10 +33,10 @@ class Command::Ai::Translator
def prompt
<<~PROMPT
You are Fizzys command translator.
--------------------------- OUTPUT FORMAT ---------------------------
Return ONE valid JSON object matching **exactly**:
{
"context": { /* REQUIRED unless empty */
"terms": string[],
@@ -46,35 +46,39 @@ class Command::Ai::Translator
"card_ids": number[],
"creator_id": string,
"collection_ids": string[],
"tag_ids": string[]
"tag_ids": string[],
"creation": "today" | "yesterday" | "thisweek" | "thismonth" | "thisyear"
| "lastweek" | "lastmonth" | "lastyear",
"completion": "today" | "yesterday" | "thisweek" | "thismonth" | "thisyear"
| "lastweek" | "lastmonth" | "lastyear"
},
"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"
@@ -84,24 +88,38 @@ class Command::Ai::Translator
* creator_id — creators name
* collection_ids — array of collections
* tag_ids — array of tag names
* creation — relative range when the card was **created** (values listed above). Use it only
when the user asks for cards created in a specific timeframe.
* completion — relative range when the card was **completed/closed** (values listed above). Use it
only when the user asks for cards completed/closed in a specific timeframe.
---------------------- 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
"123" (with no action verb) → context: { terms: ["123"] }
"card 123" → context: { card_ids: [123] }
"card 1,2" context: { card_ids: [1, 2] }
"move 1 and 2 to doing" → context: { card_ids: [1, 2] }, commands: ["/do"]
Quick mnemonic
WORD “card(s)” present? → card_ids
ACTION verb present? → card_ids + command
Otherwise → terms
* "Completed/closed cards" ( **and NO words like
today, yesterday, thisweek, thismonth, thisyear,
lastweek, lastmonth, lastyear** ) → indexed_by: "closed"
Never add "completion" unless one of the eight
timeframe tokens is present in the user text.
* Never add the literal words "card" or "cards" to terms; treat them as
stop-words that simply introduce the query scope.
* "X collection" → collection_ids: ["X"]
* **Past-tense** “assigned to X” → assignee_ids: ["X"] (filter)
* **Imperative** “assign to X”, “assign to me” → command /assign X
* **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"
@@ -112,20 +130,24 @@ class Command::Ai::Translator
→ 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"
* "Completed/closed cards" (no date range) → indexed_by: "closed"
VERY IMPORTANT: Do **not** set "completion" filter unless the user explicitly supplies a timeframe
(e.g., “completed this month”, “closed last week”).
(If the timeframe is supplied with “closed” instead of “completed”, treat it the same way.)
* 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)
* /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.
@@ -142,89 +164,115 @@ class Command::Ai::Translator
* “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:
You can open these screens by using /visit 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 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').
* To filter completed or closed cards, use "indexed_by: closed", don't set a "completion" filter unless the user is
asking for cards completed in a certain window of time.
* 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
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
User: assign to jz
Output:
{
"commands": ["/assign jz"]
}
User: cards assigned to jz
User: cards assigned to jz
Output:
{
"context": { "assignee_ids": ["jz"] }
}
User: tag with #design
User: tag with #design
Output:
{
"commands": ["/tag #design"]
}
User: "cards tagged with #design" or "#design cards"
User: completed cards
Output:
{
"context": { "indexed_by": "closed" }
}
User: completed cards yesterday
Output:
{
"context": { "indexed_by": "closed", "completion": "yesterday" }
}
User: "cards tagged with #design" or "#design cards"
Output:
{
"context": { "tag_ids": ["design"] }
}
User: Unassigned cards
User: Unassigned cards
Output:
{
"context": { "assignment_status": "unassigned" }
}
User: Close Andys cards, then assign them to Kevin
User: Close Andys cards, then assign them to Kevin
Output:
{
"context": { "assignee_ids": ["andy"] },
"commands": ["/close", "/assign kevin"]
}
Fallback search example (when nothing matches):
User: cards created yesterday
Output:
{
"context": { "creation": "yesterday" }
}
User: cards completed last week
Output:
{
"context": { "completion": "lastweek", "indexed_by": "closed" }
}
Fallback search example (when nothing matches):
{ "commands": ["/search what's blocking deploy"] }
---------------------------- END OF PROMPT ---------------------------
PROMPT
end
+3 -1
View File
@@ -20,7 +20,7 @@ class Filter < ApplicationRecord
@cards ||= begin
result = creator.accessible_cards.indexed_by(indexed_by)
result = result.where(id: card_ids) if card_ids.present?
result = result.open unless indexed_by.closed? || card_ids.present?
result = result.open unless indexed_by.closed? || close_window || card_ids.present?
result = result.by_engagement_status(engagement_status) if engagement_status.present?
result = result.unassigned if assignment_status.unassigned?
result = result.assigned_to(assignees.ids) if assignees.present?
@@ -28,6 +28,8 @@ class Filter < ApplicationRecord
result = result.where(collection: collections.ids) if collections.present?
result = result.in_stage(stages.ids) if stages.present?
result = result.tagged_with(tags.ids) if tags.present?
result = result.where("cards.created_at": creation_window) if creation_window
result = result.closed_at_window(close_window) if close_window
result = terms.reduce(result) do |result, term|
result.mentioning(term)
end
+10 -1
View File
@@ -16,7 +16,8 @@ module Filter::Fields
end
included do
store_accessor :fields, :assignment_status, :indexed_by, :terms, :engagement_status, :card_ids
store_accessor :fields, :assignment_status, :indexed_by, :terms,
:engagement_status, :card_ids, :creation, :close
def assignment_status
super.to_s.inquiry
@@ -30,6 +31,14 @@ module Filter::Fields
super&.inquiry
end
def creation_window
TimeWindowParser.parse(creation)
end
def close_window
TimeWindowParser.parse(close)
end
def terms
Array(super)
end
+4
View File
@@ -5,6 +5,8 @@ module Filter::Params
:assignment_status,
:indexed_by,
:engagement_status,
:creation,
:close,
card_ids: [],
assignee_ids: [],
creator_ids: [],
@@ -38,6 +40,8 @@ module Filter::Params
{}.tap do |params|
params[:indexed_by] = indexed_by
params[:engagement_status] = engagement_status
params[:creation] = creation
params[:close] = close
params[:assignment_status] = assignment_status
params[:terms] = terms
params[:tag_ids] = tags.ids
+41
View File
@@ -0,0 +1,41 @@
class TimeWindowParser
attr_reader :now
class << self
def parse(string)
new.parse(string)
end
end
def initialize(now: Time.current)
@now = now
end
def parse(string)
case normalize(string)
when "today"
now.beginning_of_day..now.end_of_day
when "yesterday"
(now - 1.day).beginning_of_day..(now - 1.day).end_of_day
when "thisweek"
now.beginning_of_week..now.end_of_week
when "thismonth"
now.beginning_of_month..now.end_of_month
when "thisyear"
now.beginning_of_year..now.end_of_year
when "lastweek"
(now - 1.week).beginning_of_week..(now - 1.week).end_of_week
when "lastmonth"
(now - 1.month).beginning_of_month..(now - 1.month).end_of_month
when "lastyear"
(now - 1.year).beginning_of_year..(now - 1.year).end_of_year
end
end
private
def normalize(string)
if string
string.downcase.gsub(/[\s_\-]/, "")
end
end
end
@@ -121,6 +121,13 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
assert_command({ commands: [ "/add_card urgent issue" ] }, "create card urgent issue")
end
vcr_record!
test "filter by time ranges" do
assert_command({context: {completion: "thisweek", indexed_by: "closed"}}, "cards completed this week")
assert_command({context: {creation: "thisweek", tag_ids: ["design"], creator_id: "jz"}}, "cards created this week by jz tagged as #design")
end
private
def assert_command(expected, query, context: :list)
assert_equal expected, translate(query, context:)
+20
View File
@@ -133,4 +133,24 @@ class FilterTest < ActiveSupport::TestCase
assert filter.indexed_by.closed?
assert_equal [ "haggis" ], filter.terms
end
test "creation window" do
filter = users(:david).filters.new creation: "this week"
cards(:logo).update_columns created_at: 2.weeks.ago
assert_not_includes filter.cards, cards(:logo)
cards(:logo).update_columns created_at: Time.current
assert_includes filter.cards, cards(:logo)
end
test "close window" do
filter = users(:david).filters.new close: "this week"
cards(:shipping).closure.update_columns created_at: 2.weeks.ago
assert_not_includes filter.cards, cards(:shipping)
cards(:shipping).closure.update_columns created_at: Time.current
assert_includes filter.cards, cards(:shipping)
end
end
+64
View File
@@ -0,0 +1,64 @@
require "test_helper"
class TimeWindowParserTest < ActiveSupport::TestCase
setup do
@now = Time.zone.parse("2023-06-15 9am")
@parser = TimeWindowParser.new(now: @now)
end
test "parse today" do
assert_equal @now.beginning_of_day..@now.end_of_day,
@parser.parse("today")
end
test "parse yesterday" do
yesterday = @now - 1.day
assert_equal yesterday.beginning_of_day..yesterday.end_of_day,
@parser.parse("yesterday")
end
test "parse this week" do
assert_equal @now.beginning_of_week..@now.end_of_week,
@parser.parse("this week")
end
test "parse this month" do
assert_equal @now.beginning_of_month..@now.end_of_month,
@parser.parse("this month")
end
test "parse this year" do
assert_equal @now.beginning_of_year..@now.end_of_year,
@parser.parse("this year")
end
test "parse last week" do
last_week = @now - 1.week
assert_equal last_week.beginning_of_week..last_week.end_of_week,
@parser.parse("last week")
end
test "parse last month" do
last_month = @now - 1.month
assert_equal last_month.beginning_of_month..last_month.end_of_month,
@parser.parse("last month")
end
test "parse last year" do
last_year = @now - 1.year
assert_equal last_year.beginning_of_year..last_year.end_of_year,
@parser.parse("last year")
end
test "parse with unknown string returns nil" do
assert_nil @parser.parse("unknown time window")
end
test "returns nil for nil" do
assert_nil @parser.parse(nil)
end
end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff