acute 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/data/assumptions.yml +124 -0
- data/data/words.yml +699 -0
- data/exe/acute +4 -0
- data/lib/acute/assumption.rb +13 -0
- data/lib/acute/calendar.rb +91 -0
- data/lib/acute/chronic.rb +176 -0
- data/lib/acute/chronic_duration.rb +321 -0
- data/lib/acute/cli.rb +168 -0
- data/lib/acute/context.rb +205 -0
- data/lib/acute/describe.rb +316 -0
- data/lib/acute/duration.rb +120 -0
- data/lib/acute/errors.rb +47 -0
- data/lib/acute/explain.rb +165 -0
- data/lib/acute/extract.rb +493 -0
- data/lib/acute/grammar/clock_words.rb +109 -0
- data/lib/acute/grammar/compact.rb +222 -0
- data/lib/acute/grammar/duration.rb +93 -0
- data/lib/acute/grammar/nesting.rb +104 -0
- data/lib/acute/grammar/numbers.rb +378 -0
- data/lib/acute/grammar/ordinals.rb +101 -0
- data/lib/acute/grammar/ranges.rb +112 -0
- data/lib/acute/grammar/rule.rb +277 -0
- data/lib/acute/grammar/shifts.rb +226 -0
- data/lib/acute/grammar/words.rb +307 -0
- data/lib/acute/grammar.rb +378 -0
- data/lib/acute/holidays.rb +97 -0
- data/lib/acute/lexer.rb +487 -0
- data/lib/acute/locale.rb +53 -0
- data/lib/acute/mcp.rb +224 -0
- data/lib/acute/parser.rb +66 -0
- data/lib/acute/read.rb +21 -0
- data/lib/acute/resolve/clock.rb +75 -0
- data/lib/acute/resolve/dates.rb +75 -0
- data/lib/acute/resolve/day.rb +24 -0
- data/lib/acute/resolve/fuzzy.rb +91 -0
- data/lib/acute/resolve/named.rb +116 -0
- data/lib/acute/resolve/nth.rb +47 -0
- data/lib/acute/resolve/period.rb +160 -0
- data/lib/acute/resolve/periodic.rb +274 -0
- data/lib/acute/resolve/ranges.rb +165 -0
- data/lib/acute/resolve/recur.rb +187 -0
- data/lib/acute/resolve/rel.rb +146 -0
- data/lib/acute/resolve/shift.rb +81 -0
- data/lib/acute/resolve.rb +245 -0
- data/lib/acute/result.rb +128 -0
- data/lib/acute/rule/cron.rb +222 -0
- data/lib/acute/rule/expand.rb +401 -0
- data/lib/acute/rule.rb +232 -0
- data/lib/acute/tables/assumptions.rb +39 -0
- data/lib/acute/tables/words.rb +656 -0
- data/lib/acute/tables/zones.rb +930 -0
- data/lib/acute/tool/compact.rb +82 -0
- data/lib/acute/tool/ruby_llm.rb +29 -0
- data/lib/acute/tool.rb +192 -0
- data/lib/acute/verify.rb +216 -0
- data/lib/acute/version.rb +3 -0
- data/lib/acute/zone.rb +252 -0
- data/lib/acute.rb +160 -0
- data/schema/description.json +12 -0
- data/schema/duration.json +26 -0
- data/schema/extraction.json +30 -0
- data/schema/failure.json +29 -0
- data/schema/now.json +17 -0
- data/schema/result.json +45 -0
- data/schema/rule.json +19 -0
- data/schema/tools/describe.json +13 -0
- data/schema/tools/duration.json +12 -0
- data/schema/tools/extract.json +13 -0
- data/schema/tools/now.json +9 -0
- data/schema/tools/parse.json +13 -0
- data/schema/tools/recurrence.json +12 -0
- data/schema/tools/verify.json +14 -0
- data/schema/verdict.json +25 -0
- metadata +117 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
class Tool
|
|
3
|
+
# The model pays for every char of a result on every call, so a tool gives the library's JSON without what
|
|
4
|
+
# repeats: the input's echo, a begin equal to time, a mention's kind and text again in its value, the expected
|
|
5
|
+
# reading of a verdict that holds, now's offset, which the now string ends with, a rule's ical when it holds
|
|
6
|
+
# only its dtstart and rrule, whichever of cron and cron_reason is null, and empty lists. An assumption's
|
|
7
|
+
# message is its short form, the gist in about half the chars. The schema rules make the same cuts in the output
|
|
8
|
+
# schemas, so the two can't drift.
|
|
9
|
+
# @api private
|
|
10
|
+
module Compact
|
|
11
|
+
def self.call(out)
|
|
12
|
+
case out["kind"]
|
|
13
|
+
when "time" then result(out)
|
|
14
|
+
when "extraction"
|
|
15
|
+
out.delete("text")
|
|
16
|
+
out["mentions"].each do |m|
|
|
17
|
+
m.delete("kind")
|
|
18
|
+
case m["value"]["kind"]
|
|
19
|
+
when "time" then result(m["value"])
|
|
20
|
+
when "rule" then rule(m["value"])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
when "verdict"
|
|
24
|
+
out.delete("text")
|
|
25
|
+
if out["ok"] then out.delete("expected")
|
|
26
|
+
elsif out["expected"] then result(out["expected"])
|
|
27
|
+
end
|
|
28
|
+
when "rule" then rule(out)
|
|
29
|
+
when "now" then out.delete("offset")
|
|
30
|
+
end
|
|
31
|
+
out.delete("unread") if out["unread"] == []
|
|
32
|
+
out
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.result(r)
|
|
36
|
+
r.delete("text")
|
|
37
|
+
if r["assumptions"].empty? then r.delete("assumptions")
|
|
38
|
+
else r["assumptions"].each { _1["message"] = Assumption.for(_1["code"].to_sym).short }
|
|
39
|
+
end
|
|
40
|
+
r.delete("alternatives") if r["alternatives"].empty?
|
|
41
|
+
[r, *r["alternatives"]].each { _1.delete("begin") if _1["begin"] == _1["time"] }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :result
|
|
44
|
+
|
|
45
|
+
def self.rule(r)
|
|
46
|
+
r.delete("text")
|
|
47
|
+
r.delete("ical") unless r["ical"].include?("\nEXDATE")
|
|
48
|
+
r.delete(r["cron"] ? "cron_reason" : "cron")
|
|
49
|
+
end
|
|
50
|
+
private_class_method :rule
|
|
51
|
+
|
|
52
|
+
# defs: the schemas tool's output schema holds, by file name, before they are inlined. The recurrence tool
|
|
53
|
+
# adds a rule's first occurrences, and extract a count of the mentions past its cap.
|
|
54
|
+
def self.schema(defs, tool)
|
|
55
|
+
trim(defs["result"], drop: %w[text], optional: %w[begin assumptions alternatives])
|
|
56
|
+
trim(defs.dig("result", "properties", "alternatives", "items"), optional: %w[begin])
|
|
57
|
+
trim(defs["extraction"], drop: %w[text])
|
|
58
|
+
trim(defs.dig("extraction", "properties", "mentions", "items"), drop: %w[kind])
|
|
59
|
+
defs["extraction"]["properties"]["more"] = { "type" => "integer" } if defs["extraction"]
|
|
60
|
+
trim(defs["verdict"], drop: %w[text], optional: %w[expected unread])
|
|
61
|
+
if (rule = defs["rule"])
|
|
62
|
+
trim(rule, drop: %w[text], optional: %w[ical cron cron_reason])
|
|
63
|
+
rule["properties"] = rule["properties"].merge("cron" => { "type" => "string" }, "cron_reason" => { "type" => "string" })
|
|
64
|
+
end
|
|
65
|
+
if tool == "recurrence"
|
|
66
|
+
rule["properties"]["next"] = { "type" => "array", "items" => { "type" => "string" }, "maxItems" => OCCURRENCES }
|
|
67
|
+
rule["required"] += ["next"]
|
|
68
|
+
end
|
|
69
|
+
trim(defs["now"], drop: %w[offset])
|
|
70
|
+
trim(defs["failure"], drop: %w[text]) # T5: a tool never echoes its input
|
|
71
|
+
defs
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.trim(node, drop: [], optional: [])
|
|
75
|
+
return unless node
|
|
76
|
+
node["properties"] = node["properties"].except(*drop)
|
|
77
|
+
node["required"] -= drop + optional
|
|
78
|
+
end
|
|
79
|
+
private_class_method :trim
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require "ruby_llm"
|
|
3
|
+
rescue LoadError => e
|
|
4
|
+
raise LoadError, "acute/tool/ruby_llm needs the ruby_llm gem; add it to your Gemfile (#{e.message})"
|
|
5
|
+
end
|
|
6
|
+
require_relative "../tool"
|
|
7
|
+
|
|
8
|
+
module Acute
|
|
9
|
+
class Tool
|
|
10
|
+
# One RubyLLM::Tool per tool, bound to this one's options: chat.with_tools(*Acute::Tool.new(zone: user.zone).ruby_llm)
|
|
11
|
+
def ruby_llm = definitions.map { RubyLLMTool.new(self, _1) }
|
|
12
|
+
|
|
13
|
+
# RubyLLM reads a tool's name, description and parameters_schema from the instance, and validates the
|
|
14
|
+
# arguments against execute's keywords before calling it, so execute takes any and Acute::Tool checks them
|
|
15
|
+
class RubyLLMTool < ::RubyLLM::Tool
|
|
16
|
+
attr_reader :name, :description, :parameters_schema
|
|
17
|
+
|
|
18
|
+
def initialize(tool, definition)
|
|
19
|
+
super()
|
|
20
|
+
@tool = tool
|
|
21
|
+
@name = definition[:name]
|
|
22
|
+
@description = definition[:description]
|
|
23
|
+
@parameters_schema = definition[:input_schema]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def execute(**arguments) = @tool.call(@name, arguments)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/acute/tool.rb
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
require_relative "../acute"
|
|
2
|
+
require_relative "tool/compact"
|
|
3
|
+
|
|
4
|
+
module Acute
|
|
5
|
+
# The tools an app's LLM calls, for any SDK. The app fixes the options at construction; the input schemas in
|
|
6
|
+
# schema/tools/ leave them out, so the model can't set them. What the model sends is a boundary: anything wrong
|
|
7
|
+
# with it comes back as a failure value. T5: no string the model reads as prose quotes its input; text
|
|
8
|
+
# that needs pointing at goes in unread, which the schema marks as data.
|
|
9
|
+
class Tool
|
|
10
|
+
# @api private: NAMES, SCHEMAS and UNREAD_LENGTH are read by MCP
|
|
11
|
+
NAMES = %w[parse duration recurrence extract verify describe now].freeze
|
|
12
|
+
SCHEMAS = Ractor.make_shareable(NAMES.to_h do |name|
|
|
13
|
+
[name, JSON.parse(File.read(File.expand_path("../../schema/tools/#{name}.json", __dir__)))]
|
|
14
|
+
end)
|
|
15
|
+
UNREAD = 5
|
|
16
|
+
OCCURRENCES = 3
|
|
17
|
+
MENTIONS = 50 # T34: a reply many times its text would crowd the model's context
|
|
18
|
+
UNREAD_LENGTH = 60
|
|
19
|
+
PARSE_ERRORS = {
|
|
20
|
+
unknown_word: "acute couldn't read the words in unread",
|
|
21
|
+
invalid_date: "the date or time in unread doesn't exist",
|
|
22
|
+
incomplete: "the phrase is incomplete",
|
|
23
|
+
conflict: "the words in unread don't fit with the rest of the phrase",
|
|
24
|
+
not_rule: "not a repeating time",
|
|
25
|
+
vague_rule: "the phrase says how often, not when: ask the user which days or times",
|
|
26
|
+
no_occurrence: "the rule ends before it starts",
|
|
27
|
+
dst_skipped_time: "the time in unread is skipped as the clocks go forward: ask the user for a time either side of it",
|
|
28
|
+
dst_repeated_time: "the time in unread happens twice as the clocks go back: ask the user which they meant",
|
|
29
|
+
business_day_limit: "more than #{Resolve::Period::LIMIT} business days in one call is too many to count",
|
|
30
|
+
too_long: "the text is longer than #{MAX_LENGTH} bytes",
|
|
31
|
+
invalid_encoding: "the text is not valid UTF-8"
|
|
32
|
+
}.freeze
|
|
33
|
+
OPTION_ERRORS = { value: "value must be an ISO 8601 time or date" }.freeze
|
|
34
|
+
POINTED = %i[invalid_date conflict dst_skipped_time dst_repeated_time].freeze
|
|
35
|
+
ISO_DURATION = /\AP(?!\z)(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?
|
|
36
|
+
(?:T(?=\d)(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?\z/x
|
|
37
|
+
ISO_PARTS = %i[years months weeks days hours minutes seconds].freeze
|
|
38
|
+
ISO_TIME = /\A\d{4}-\d\d-\d\d(?:[T ]|\z)/
|
|
39
|
+
private_constant :UNREAD, :OCCURRENCES, :MENTIONS, :PARSE_ERRORS, :OPTION_ERRORS, :POINTED, :ISO_DURATION, :ISO_PARTS, :ISO_TIME
|
|
40
|
+
|
|
41
|
+
attr_reader :parser, :prefix
|
|
42
|
+
|
|
43
|
+
def initialize(parser = nil, prefix: "", **options)
|
|
44
|
+
raise ArgumentError, "expected a parser Acute.with made, got #{parser.class}" unless parser.nil? || Parser === parser
|
|
45
|
+
# Tool names may hold letters, digits, _ and -, and no more than 64 of them
|
|
46
|
+
unless String === prefix && prefix.valid_encoding? && prefix.match?(/\A[A-Za-z0-9_-]{0,56}\z/)
|
|
47
|
+
raise OptionError.new("prefix: must be up to 56 letters, digits, _ or -", option: :prefix, value: prefix)
|
|
48
|
+
end
|
|
49
|
+
@parser = (parser || Acute).with(**options)
|
|
50
|
+
@prefix = -prefix
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def definitions(format = :anthropic)
|
|
55
|
+
NAMES.map do |name|
|
|
56
|
+
schema = SCHEMAS[name]
|
|
57
|
+
full = "#{@prefix}#{name}"
|
|
58
|
+
description = schema["description"].gsub(/\{(\w+)\}/) { "#{@prefix}#{$1}" }
|
|
59
|
+
input = schema.except("$schema", "$id", "title", "description")
|
|
60
|
+
case format
|
|
61
|
+
when :anthropic then { name: full, description: description, input_schema: input }
|
|
62
|
+
when :openai then { type: "function", function: { name: full, description: description, parameters: input } }
|
|
63
|
+
else raise ArgumentError, "format must be :anthropic or :openai, got #{format.inspect}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# arguments as an SDK gives them: a Hash with string or symbol keys, or OpenAI's JSON string
|
|
69
|
+
def call(name, arguments) = Compact.(answer(name, arguments))
|
|
70
|
+
|
|
71
|
+
def inspect = "#<Acute::Tool #{"prefix: #{@prefix.inspect} " unless @prefix.empty?}#{@parser.inspect}>"
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def answer(name, arguments)
|
|
76
|
+
tool = name.delete_prefix(@prefix) if String === name && name.valid_encoding? && name.start_with?(@prefix)
|
|
77
|
+
return failure(:unknown_tool, "there is no tool by the name in unread", [name]) unless NAMES.include?(tool)
|
|
78
|
+
args = arguments(arguments) or return failure(:invalid_argument, "the arguments must be a JSON object")
|
|
79
|
+
wrong = check(SCHEMAS[tool], args) and return wrong
|
|
80
|
+
run(tool, args)
|
|
81
|
+
rescue ParseError => e
|
|
82
|
+
parse_failure(e, args["text"])
|
|
83
|
+
rescue OptionError => e
|
|
84
|
+
failure(:invalid_argument, OPTION_ERRORS.fetch(e.option, "#{e.option} isn't one acute can use"), [e.value])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def arguments(given)
|
|
88
|
+
given = JSON.parse(given) if String === given
|
|
89
|
+
# A null is an argument left out, as some SDKs send every optional one
|
|
90
|
+
given.to_h { |k, v| [k.to_s, v] }.compact if Hash === given
|
|
91
|
+
rescue JSON::ParserError, EncodingError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def check(schema, args)
|
|
96
|
+
props = schema["properties"]
|
|
97
|
+
unknown = args.keys - props.keys
|
|
98
|
+
return failure(:unknown_argument, "there are no arguments by the names in unread", unknown) if unknown.any?
|
|
99
|
+
missing = schema["required"].to_a - args.keys
|
|
100
|
+
return failure(:missing_argument, "#{missing.join(" and ")} #{missing.size == 1 ? "is" : "are"} required") if missing.any?
|
|
101
|
+
args.each do |key, value|
|
|
102
|
+
prop = props[key]
|
|
103
|
+
error =
|
|
104
|
+
if prop["enum"] then "#{key} must be one of #{prop["enum"].join(", ")}" unless prop["enum"].include?(value)
|
|
105
|
+
elsif !(String === value) then "#{key} must be a string"
|
|
106
|
+
elsif !value.valid_encoding? then "#{key} is not valid UTF-8"
|
|
107
|
+
elsif value.length > prop["maxLength"] then "#{key} is longer than #{prop["maxLength"]} characters"
|
|
108
|
+
end
|
|
109
|
+
return failure(:invalid_argument, error) if error
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def run(tool, args)
|
|
115
|
+
text = args["text"]
|
|
116
|
+
context = args.key?("context") ? { context: args["context"].to_sym } : {}
|
|
117
|
+
case tool
|
|
118
|
+
when "parse" then @parser.parse!(text, **context).as_json
|
|
119
|
+
when "duration" then @parser.duration!(text).as_json
|
|
120
|
+
when "recurrence" then recurrence(text)
|
|
121
|
+
when "extract" then extract(text, context)
|
|
122
|
+
when "verify" then verify(text, args["value"], context)
|
|
123
|
+
when "describe" then describe(args["value"], args.fetch("style", "relative").to_sym)
|
|
124
|
+
else now
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def verify(text, value, context)
|
|
129
|
+
Acute.send(:verifier, text, value, **@parser.options, **context).tool_verdict.as_json.merge("unread" => [])
|
|
130
|
+
rescue ParseError => e
|
|
131
|
+
failed = parse_failure(e, text)
|
|
132
|
+
message = "The text can't be read: #{failed["error"]}. Ask the user to rephrase."
|
|
133
|
+
Verdict.new(text, :unparsed, message, nil).as_json.merge("unread" => failed["unread"])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The rule's first occurrences, DTSTART's included, so the model can check what it set up. each rather than
|
|
137
|
+
# next: next counts from now and leaves out a DTSTART equal to it. each ends when its steps run out, so a
|
|
138
|
+
# rule whose occurrences are that far apart lists fewer.
|
|
139
|
+
def recurrence(text)
|
|
140
|
+
rule = @parser.recurrence!(text)
|
|
141
|
+
rule.as_json.merge("next" => rule.each.first(OCCURRENCES).map(&:iso8601))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# more: how many mentions past the first MENTIONS were left out
|
|
145
|
+
def extract(text, context)
|
|
146
|
+
json = @parser.extract(text, **context).as_json
|
|
147
|
+
more = json["mentions"].size - MENTIONS
|
|
148
|
+
return json unless more.positive?
|
|
149
|
+
json["mentions"] = json["mentions"].first(MENTIONS)
|
|
150
|
+
json.merge("more" => more)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def describe(value, style)
|
|
154
|
+
target = duration(value) || (@parser.parse(value, strict: true) if value.match?(ISO_TIME))
|
|
155
|
+
return failure(:invalid_argument, "value must be an ISO 8601 time, date or duration", [value]) unless target
|
|
156
|
+
{ "kind" => "description", "description" => @parser.describe(target, style: style) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def duration(value)
|
|
160
|
+
m = ISO_DURATION.match(value) or return
|
|
161
|
+
Duration.new(**ISO_PARTS.zip(m.captures).to_h { |part, n| [part, n && (n.include?(".") ? n.to_r : n.to_i)] })
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def now
|
|
165
|
+
ctx = Context.new(@parser.options[:now], @parser.options[:zone], :future)
|
|
166
|
+
zone = ctx.zone
|
|
167
|
+
at = ctx.now
|
|
168
|
+
change = zone.next_change(at)
|
|
169
|
+
local = Time.at(at, in: zone.offset_at(at))
|
|
170
|
+
{ "kind" => "now", "now" => local.strftime("%FT%T%:z"), "weekday" => local.strftime("%A"), "zone" => zone.name,
|
|
171
|
+
"offset" => local.strftime("%:z"), "dst" => !change.nil? && zone.offset_at(at) > zone.offset_at(change),
|
|
172
|
+
"next_change" => change && Time.at(change, in: zone.offset_at(change)).strftime("%FT%T%:z") }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse_failure(e, text)
|
|
176
|
+
unread = e.code == :unknown_word ? e.unknown : POINTED.include?(e.code) ? [text[e.position]] : []
|
|
177
|
+
found = failure(e.code, Explain.reason(e, text) || PARSE_ERRORS.fetch(e.code), unread)
|
|
178
|
+
# a position over the whole phrase tells the model nothing the error doesn't
|
|
179
|
+
found["position"] = [e.position.begin, e.position.end] unless whole?(e.position, text)
|
|
180
|
+
# only unknown_word's hint is for the model; the others name library options the app fixes
|
|
181
|
+
found["hint"] = e.hint if e.hint && e.code == :unknown_word
|
|
182
|
+
found
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def whole?(span, text) = text[0...span.begin].strip.empty? && text[span.end..].to_s.strip.empty?
|
|
186
|
+
|
|
187
|
+
def failure(code, error, unread = [])
|
|
188
|
+
words = unread.first(UNREAD).map { _1.to_s.dup.force_encoding(Encoding::UTF_8).scrub[0, UNREAD_LENGTH] }
|
|
189
|
+
{ "kind" => "failure", "code" => code.to_s, "error" => error, "unread" => words }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
data/lib/acute/verify.rb
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
def self.verify(text, value, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
|
|
3
|
+
fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
|
|
4
|
+
verifier(text, value, now:, zone:, context:, locale:, week_start:, hemisphere:, fiscal_year_start:, strict:, dst:,
|
|
5
|
+
holidays:, business_days:, year_bias:).verdict
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Acute::Tool takes the Verify, by send, to word the verdict without the input (T5)
|
|
9
|
+
def self.verifier(text, value, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
|
|
10
|
+
fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
|
|
11
|
+
ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
|
|
12
|
+
business_days, year_bias)
|
|
13
|
+
opts = { now: Time.at(ctx.now), zone: zone, context: context, locale: locale, week_start: week_start,
|
|
14
|
+
hemisphere: hemisphere, fiscal_year_start: fiscal_year_start, strict: strict, dst: dst,
|
|
15
|
+
holidays: holidays, business_days: business_days, year_bias: year_bias }
|
|
16
|
+
Verify.new(text, value, ctx, opts)
|
|
17
|
+
end
|
|
18
|
+
private_class_method :verifier
|
|
19
|
+
|
|
20
|
+
# Whether a time someone else worked out is what the text says. The message is written to go straight back
|
|
21
|
+
# to a model (D45).
|
|
22
|
+
class Verdict
|
|
23
|
+
STATUSES = %i[match inside alternative mismatch unparsed].freeze
|
|
24
|
+
OK = %i[match inside alternative].freeze
|
|
25
|
+
private_constant :STATUSES, :OK
|
|
26
|
+
|
|
27
|
+
attr_reader :text, :status, :message, :expected
|
|
28
|
+
|
|
29
|
+
# @api private
|
|
30
|
+
def initialize(text, status, message, expected)
|
|
31
|
+
@text = text
|
|
32
|
+
@status = status
|
|
33
|
+
@message = Acute.visible(message)
|
|
34
|
+
@expected = expected
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ok? = OK.include?(@status)
|
|
39
|
+
|
|
40
|
+
# JSON can't hold invalid UTF-8, so such a text is given scrubbed
|
|
41
|
+
def to_h = { kind: "verdict", text: @text.dup.force_encoding(Encoding::UTF_8).scrub, status: @status.to_s, ok: ok?, message: @message, expected: @expected&.to_h }
|
|
42
|
+
|
|
43
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
44
|
+
|
|
45
|
+
def as_json(*) = JSON.parse(to_json)
|
|
46
|
+
|
|
47
|
+
def inspect = "#<Acute::Verdict #{@status} #{@message.inspect}>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @api private
|
|
51
|
+
class Verify
|
|
52
|
+
DAY = 86_400
|
|
53
|
+
ORDINALS = %w[first second third fourth fifth sixth seventh eighth ninth tenth].freeze
|
|
54
|
+
DATE = /\A\d{4}-\d\d-\d\d\z/
|
|
55
|
+
|
|
56
|
+
def initialize(text, value, ctx, opts)
|
|
57
|
+
@text = text
|
|
58
|
+
@ctx = ctx
|
|
59
|
+
@zone = ctx.zone
|
|
60
|
+
@opts = opts
|
|
61
|
+
@value = target(value)
|
|
62
|
+
@given = given(value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def verdict
|
|
66
|
+
judge(@given, @text.inspect)
|
|
67
|
+
rescue ParseError => e
|
|
68
|
+
Verdict.new(@text, :unparsed, "#{shorten(@text)} can't be read as a time: #{unknown(e)}. Ask the user to say it " \
|
|
69
|
+
"another way.", nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# T5: the message names the text by its readings' labels and a mismatched value as acute normalised it, never
|
|
73
|
+
# as they came in; the model knows which value it sent. A text it can't read raises the ParseError, for the caller to word.
|
|
74
|
+
def tool_verdict = judge("the value", "the text")
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def judge(value, text)
|
|
79
|
+
r = Acute.parse!(@text, **@opts)
|
|
80
|
+
also = others(r, " It can also mean %s; ask the user if that matters.")
|
|
81
|
+
status = relation(r)
|
|
82
|
+
i = r.alternatives.index { relation(_1) } unless status
|
|
83
|
+
status ||= i ? :alternative : :mismatch
|
|
84
|
+
message =
|
|
85
|
+
case status
|
|
86
|
+
when :match then "#{value} matches #{text}: #{label(r)}.#{also}"
|
|
87
|
+
when :inside then "#{value} is inside #{text}, which is #{label(r)}.#{also}"
|
|
88
|
+
when :alternative
|
|
89
|
+
"#{value} is the #{ORDINALS[i + 1] || "#{i + 2}th"} reading of #{text}; the first is #{label(r)}. Ask the user " \
|
|
90
|
+
"which they meant."
|
|
91
|
+
else "#{text} is #{label(r)}#{others(r, " (or %s)")}, not #{label(@value)}."
|
|
92
|
+
end
|
|
93
|
+
# the tool's subjects begin a sentence in lower case; the library's are a quote or a digit
|
|
94
|
+
Verdict.new(@text, status, message.sub(/\A\w/, &:upcase), r)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def unknown(error)
|
|
98
|
+
words = error.unknown
|
|
99
|
+
return error.message.length > 80 ? "#{error.message[0, 80]}…" : error.message if words.empty?
|
|
100
|
+
shown = words.first(5).map { shorten(_1, 20) }
|
|
101
|
+
"unknown #{words.size == 1 ? "word" : "words"} #{shown.join(", ")}#{" and #{words.size - 5} more" if words.size > 5}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def others(r, form) = r.ambiguous? ? format(form, r.alternatives.map { label(_1) }.join(" or ")) : ""
|
|
105
|
+
|
|
106
|
+
# A date ("2026-10-23", a Date or a range of them) is checked by its days; anything else by its instants
|
|
107
|
+
def target(value)
|
|
108
|
+
@days = Date === value || (Range === value && (Date === value.begin || Date === value.end))
|
|
109
|
+
if String === value
|
|
110
|
+
# never the lenient reading: trailing words are not an ISO 8601 string
|
|
111
|
+
r = (Acute.parse(value, **@opts, strict: true) if readable?(value) && value.match?(/\A\d{4}-\d\d-\d\d/))
|
|
112
|
+
unless r && !r.ambiguous?
|
|
113
|
+
raise OptionError.new("value must be a Time, Date, Range or ISO 8601 string, got #{shorten(value)}",
|
|
114
|
+
option: :value, value: value)
|
|
115
|
+
end
|
|
116
|
+
@days = DATE.match?(value)
|
|
117
|
+
@parsed = value = r
|
|
118
|
+
end
|
|
119
|
+
Describe::Target.for(value, @ctx)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def given(value)
|
|
123
|
+
case value
|
|
124
|
+
when String then @days ? @parsed.date.iso8601 : moment_iso(@parsed)
|
|
125
|
+
when Date then value.iso8601
|
|
126
|
+
when Range then "#{given(value.begin) if value.begin}#{value.exclude_end? ? "..." : ".."}#{given(value.end) if value.end}"
|
|
127
|
+
else value.respond_to?(:to_time) ? value.to_time.getlocal(@zone.offset_at(value.to_time.to_i)).strftime("%FT%T%:z") : value.inspect
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def readable?(string) = (string.encoding == Encoding::UTF_8 || string.encoding == Encoding::US_ASCII) && string.valid_encoding?
|
|
132
|
+
|
|
133
|
+
def shorten(string, limit = 60) = string.length > limit ? "#{string[0, limit].inspect[0..-2]}…\"" : string.inspect
|
|
134
|
+
|
|
135
|
+
def moment_iso(r)
|
|
136
|
+
return r.time.getlocal(@zone.offset_at(r.time.to_i)).strftime("%FT%T%:z") if r.instant?
|
|
137
|
+
"#{r.begin && moment_iso_at(r.begin)}..#{r.end && moment_iso_at(r.end)}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def moment_iso_at(t) = t.getlocal(@zone.offset_at(t.to_i)).strftime("%FT%T%:z")
|
|
141
|
+
|
|
142
|
+
# :match when the reading is the value, :inside when the value lies within it
|
|
143
|
+
def relation(r)
|
|
144
|
+
v = @value
|
|
145
|
+
span = [r.begin&.to_i, r.end&.to_i]
|
|
146
|
+
if @days
|
|
147
|
+
return :match if cover(r) == [v.b, v.e]
|
|
148
|
+
elsif v.instant
|
|
149
|
+
return :match if r.instant? && r.time.to_i == v.point
|
|
150
|
+
return r.instant? || !within?(span, v.point, v.point + 1) ? nil : :inside
|
|
151
|
+
elsif !r.instant? && span == [v.b, v.e]
|
|
152
|
+
return :match
|
|
153
|
+
end
|
|
154
|
+
:inside if !r.instant? && within?(span, v.b, v.e)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def within?((b, e), vb, ve) = (b.nil? || (vb && vb >= b)) && (e.nil? || (ve && ve <= e))
|
|
158
|
+
|
|
159
|
+
# The whole days a reading falls on; one of a day or less ("tonight", "3pm") is its point's day
|
|
160
|
+
def cover(r)
|
|
161
|
+
b = r.begin&.to_i
|
|
162
|
+
e = r.end&.to_i
|
|
163
|
+
if b && e && e - b <= DAY
|
|
164
|
+
d = day_of(r.time.to_i)
|
|
165
|
+
return [day_start(d), day_start(d + 1)]
|
|
166
|
+
end
|
|
167
|
+
[b && day_start(day_of(b)), e && day_start(day_of(e - 1) + 1)]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# "Fri 23 Oct", "Fri 23 Oct, 3pm", "Sat 17 Oct – Sun 18 Oct", "from Sat 24 Oct": one form whatever the
|
|
171
|
+
# locale, as the reader is a model
|
|
172
|
+
def label(x)
|
|
173
|
+
if Result === x
|
|
174
|
+
return @days ? day(day_of(x.time.to_i)) : moment(x.time.to_i) if x.instant?
|
|
175
|
+
b, e = @days ? cover(x) : [x.begin&.to_i, x.end&.to_i]
|
|
176
|
+
else
|
|
177
|
+
return moment(x.point) if x.instant
|
|
178
|
+
b = x.b
|
|
179
|
+
e = x.e
|
|
180
|
+
end
|
|
181
|
+
return "from #{edge(b)}" unless e
|
|
182
|
+
return "before #{edge(e)}" unless b
|
|
183
|
+
return days(b, e) if aligned?(b) && aligned?(e)
|
|
184
|
+
return "#{moment(b)} – #{Describe.clock(secs_of(e))}" if day_of(b) == day_of(e)
|
|
185
|
+
"#{moment(b)} – #{moment(e)}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def days(b, e)
|
|
189
|
+
first = day_of(b)
|
|
190
|
+
last = day_of(e - 1)
|
|
191
|
+
first == last ? day(first) : "#{day(first)} – #{day(last)}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def edge(t) = aligned?(t) ? day(day_of(t)) : moment(t)
|
|
195
|
+
|
|
196
|
+
def moment(t) = "#{day(day_of(t))}, #{Describe.clock(secs_of(t))}"
|
|
197
|
+
|
|
198
|
+
def day(d)
|
|
199
|
+
x = Calendar.civil_from_days(d)
|
|
200
|
+
year = x / 10_000 == Calendar.civil_from_days(@ctx.today) / 10_000 ? "" : " #{x / 10_000}"
|
|
201
|
+
"#{Result::WEEKDAYS[Calendar.weekday(d)]} #{x % 100} #{Result::MONTHS[x / 100 % 100 - 1]}#{year}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def local(t) = t + @zone.offset_at(t)
|
|
205
|
+
|
|
206
|
+
def day_of(t) = local(t).div(DAY)
|
|
207
|
+
|
|
208
|
+
def secs_of(t) = local(t) % DAY
|
|
209
|
+
|
|
210
|
+
def day_start(d) = @zone.local_to_utc(d * DAY, @ctx)
|
|
211
|
+
|
|
212
|
+
def aligned?(t) = day_start(day_of(t)) == t
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private_constant :Verify
|
|
216
|
+
end
|