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,493 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
# Times and durations mentioned inside prose. Lexing is lenient: unknown words split the text into runs of
|
|
3
|
+
# known ones. Each run is cut into the longest pieces the strict grammar accepts, leftmost first (E2, E3).
|
|
4
|
+
class Extraction
|
|
5
|
+
# offset: a character Range into the text; value: a Result, a Duration or a Rule
|
|
6
|
+
Mention = Data.define(:kind, :text, :offset, :value)
|
|
7
|
+
|
|
8
|
+
attr_reader :text, :mentions
|
|
9
|
+
|
|
10
|
+
# @api private
|
|
11
|
+
def initialize(text, mentions)
|
|
12
|
+
@text = text
|
|
13
|
+
@mentions = mentions.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The text without its mentions, nor the "on", "at", "for", "by" or "in" left dangling before one (E4)
|
|
18
|
+
def remainder
|
|
19
|
+
out = +""
|
|
20
|
+
at = 0
|
|
21
|
+
@mentions.each do |m|
|
|
22
|
+
out << @text[at...m.offset.begin].sub(/(?:\b(?:on|at|for|by|in)\s*)+\z/i, "")
|
|
23
|
+
at = m.offset.end
|
|
24
|
+
end
|
|
25
|
+
out << @text[at..]
|
|
26
|
+
out.gsub(/\s+/, " ").gsub(/\s+([,.;])/, '\1').gsub(/\A[\s,;]+|[\s,;]+\z/, "")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{ kind: "extraction", text: @text, remainder: remainder,
|
|
31
|
+
mentions: @mentions.map { |m| { kind: m.kind.to_s, text: m.text, offset: [m.offset.begin, m.offset.end], value: m.value.to_h } } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
35
|
+
|
|
36
|
+
def as_json(*) = JSON.parse(to_json)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
module Extract
|
|
41
|
+
L = Lexer
|
|
42
|
+
S = Lexer::STRIDE
|
|
43
|
+
PUNCT = [44, 46, 59, 33, 63].freeze
|
|
44
|
+
GLUE = { L::DASH => true, L::SLASH => true, L::DOT => true }.freeze
|
|
45
|
+
BUDGET = 20_000
|
|
46
|
+
FEW = 8
|
|
47
|
+
|
|
48
|
+
# whole: the text each Result is for, when it is not the mention's own (parse with strict: false)
|
|
49
|
+
def self.call(text, make_context, whole = nil)
|
|
50
|
+
unknown = []
|
|
51
|
+
tokens = Lexer.lex(lexable(text), unknown)
|
|
52
|
+
listed_days(text, tokens)
|
|
53
|
+
mentions = []
|
|
54
|
+
from = Chars.new(text)
|
|
55
|
+
to = Chars.new(text)
|
|
56
|
+
spent = [0]
|
|
57
|
+
budget = BUDGET + 32 * tokens.size
|
|
58
|
+
runs(text, tokens, unknown).each do |a, b|
|
|
59
|
+
i = a
|
|
60
|
+
e = a
|
|
61
|
+
# The words in the run that make a rule. Lenient parse (whole) wants a time, so it looks for none.
|
|
62
|
+
marks = whole ? [] : (a...b).select { |k| Grammar.rule?(tokens[k * S, S]) }
|
|
63
|
+
while i < b
|
|
64
|
+
# No mention is longer than a phrase parse takes, so the grammar sees no more than that
|
|
65
|
+
start = from[tokens[i * S + 3]]
|
|
66
|
+
e = i + 1 if e <= i
|
|
67
|
+
e += 1 while e < b && to[tokens[e * S + 4]] - start <= MAX_LENGTH
|
|
68
|
+
# Text that makes every start parse far and fail, as "the the the…", has the grammar see a few words
|
|
69
|
+
# at each once it has cost far more than prose does
|
|
70
|
+
w = spent[0] > budget && e > i + FEW ? i + FEW : e
|
|
71
|
+
starts = starts_mention?(text, tokens, i)
|
|
72
|
+
found = starts && longest(text, tokens[(i * S)...(w * S)], make_context, whole, spent)
|
|
73
|
+
marks.shift while marks.any? && marks[0] < i
|
|
74
|
+
rule = starts && marks.any? && marks[0] < w && rule_at(text, tokens, i, w, marks[0], make_context, spent)
|
|
75
|
+
if rule && (!found || rule[0].offset.end >= found[0].offset.end)
|
|
76
|
+
mentions << rule[0]
|
|
77
|
+
i = rule[1]
|
|
78
|
+
elsif found
|
|
79
|
+
k = i
|
|
80
|
+
look = ->(n) { longest(text, tokens[(n * S)...(w * S)], make_context, whole, spent) }
|
|
81
|
+
# D51: "for 3 days from today" is a span with its "for", where "3 days from today" is a shift
|
|
82
|
+
if i > a && tokens[(i - 1) * S] == L::WORD && tokens[(i - 1) * S + 1] == :for && (with = look.(i - 1)) &&
|
|
83
|
+
with[0].kind == :time && with[0].offset.end >= found[0].offset.end && !same?(with[0].value, found[0].value)
|
|
84
|
+
found = with
|
|
85
|
+
k = i - 1
|
|
86
|
+
end
|
|
87
|
+
# E4: a leading "in", or a meal before "at" (D50), is left out when the rest reads the same, or
|
|
88
|
+
# when the rest reaches further (D50): "in 2 days from today" is "2 days from today". D51: so are
|
|
89
|
+
# "about" and "within" when the rest reaches further
|
|
90
|
+
while (lead = LEADS[tokens[k * S]]&.[](tokens[k * S + 1])) && (tokens[k * S] == L::WORD || tokens[(k + 1) * S] == L::AT)
|
|
91
|
+
shorter = after_lead(text, tokens, k, found, lead == :same, look)
|
|
92
|
+
break unless shorter
|
|
93
|
+
found, k = shorter
|
|
94
|
+
end
|
|
95
|
+
# D51: a bare number ending a mention goes with what follows when it starts a mention that reaches
|
|
96
|
+
# further: "11/20, 12 of Nov"
|
|
97
|
+
last = k + found[1] / S - 1
|
|
98
|
+
if last > k && last + 1 < w && tokens[last * S] == L::NUM && tokens[last * S + 2] & L::DIGITS <= 2 &&
|
|
99
|
+
!GLUE[tokens[(last - 1) * S]] && (after = look.(last)) && after[1] > S &&
|
|
100
|
+
(shorter = longest(text, tokens[(k * S)...(last * S)], make_context, whole, spent))
|
|
101
|
+
found = shorter
|
|
102
|
+
end
|
|
103
|
+
mentions << found[0]
|
|
104
|
+
i = k + found[1] / S
|
|
105
|
+
elsif tokens[i * S] == L::NUM && tokens[i * S + 2] & L::DIGITS == 4 && mentions.any? &&
|
|
106
|
+
(year = joined_year(text, tokens, i, mentions[-1], make_context, whole, spent))
|
|
107
|
+
mentions << year
|
|
108
|
+
i += 1
|
|
109
|
+
else
|
|
110
|
+
i += 1
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
chars = Chars.new(text)
|
|
115
|
+
mentions.map! { |m| m.with(offset: chars[m.offset.begin]...chars[m.offset.end]) }
|
|
116
|
+
Extraction.new(text, mentions)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# T29: a run of what the lexer's word loop carries a word over (letters, an apostrophe, a dot before a
|
|
120
|
+
# letter, a UTF-8 character but a space or dash), far too long to be a word: a URL, base64, CJK without
|
|
121
|
+
# spaces. The loop packs a word into one Integer, a bignum multiply a byte by then, so 64 KB of one took
|
|
122
|
+
# 0.6 s. parse and the rest reject text over MAX_LENGTH first; extract has the run's middle blanked
|
|
123
|
+
# for the lexer, byte for byte, so every offset stays and its ends still lex as the unknown words they were.
|
|
124
|
+
NOT_SPACE = "\\x00-\\x7F\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000\\u2010-\\u2015\\u2212".freeze
|
|
125
|
+
LONG = /(?:[A-Za-z']|[^#{NOT_SPACE}\u00BD\u2019])(?:[A-Za-z']|\.(?=[A-Za-z])|[^#{NOT_SPACE}]){63,}/
|
|
126
|
+
KEEP = 24 # characters at each end, more than the longest word's 14
|
|
127
|
+
|
|
128
|
+
def self.lexable(text)
|
|
129
|
+
return text unless LONG.match?(text)
|
|
130
|
+
text.gsub(LONG) do |run|
|
|
131
|
+
head = run[0, run[KEEP - 1] == "." ? KEEP + 1 : KEEP] # a dot before a space would lex as a dot
|
|
132
|
+
tail = run[-KEEP..].sub(/\A[.½]+/, "") # so would a dot or "½" after one
|
|
133
|
+
head + (" " * (run.bytesize - head.bytesize - tail.bytesize)) + tail
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The longest rule from token i, as [mention, the index after it]: the run up to w, then shorter, while it
|
|
138
|
+
# keeps the rule word at mark and doesn't end in a word that only introduces what follows (D50)
|
|
139
|
+
def self.rule_at(text, tokens, i, w, mark, make_context, spent)
|
|
140
|
+
j = w
|
|
141
|
+
FEW.times do
|
|
142
|
+
j -= 1 while j > mark + 1 && tokens[(j - 1) * S] == L::WORD && TRAILING[tokens[(j - 1) * S + 1]]
|
|
143
|
+
break if j <= mark
|
|
144
|
+
next j -= 1 if glued?(tokens, j * S)
|
|
145
|
+
slice = tokens[(i * S)...(j * S)]
|
|
146
|
+
spent[0] += slice.size
|
|
147
|
+
found = rule_mention(text, slice, make_context)
|
|
148
|
+
return [found, j] if found
|
|
149
|
+
j -= 1
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# D50: a number written as a word doesn't start one: "one every 8 hours" is "every 8 hours"
|
|
155
|
+
# The grammar goes first: it says no the most cheaply, and hostile text has it asked at every word
|
|
156
|
+
def self.rule_mention(text, slice, make_context)
|
|
157
|
+
tree = Grammar.rule(slice)
|
|
158
|
+
return if Failure === tree
|
|
159
|
+
st = slice[3]
|
|
160
|
+
return if plain_english?(slice, sentence_start?(text, st)) || in_word?(text, st, slice[-1]) ||
|
|
161
|
+
adjective?(text, slice) || (slice[0] == L::NUM && slice[2] & L::WORDY != 0)
|
|
162
|
+
stop = meridiem_dot(text, slice) || slice[-1]
|
|
163
|
+
piece = text.byteslice(st, stop - st)
|
|
164
|
+
value = Acute.send(:rule, piece, make_context.(), slice, tree)
|
|
165
|
+
Extraction::Mention.new(:rule, piece, st...stop, value) if Rule === value
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
CONTINUES = %w[at with from until till for and or on in by to before after during through].freeze
|
|
169
|
+
|
|
170
|
+
# E6: a lone frequency word before a noun-like word is an adjective: "hourly rate", "the weekly report". Not
|
|
171
|
+
# before a word that goes on with a rule: "take it daily with food"
|
|
172
|
+
def self.adjective?(text, slice)
|
|
173
|
+
content = slice.each_slice(S).reject { |k, v| k == L::ART || (k == L::WORD && v == :the) }
|
|
174
|
+
return false unless content.size == 1 && content[0][0] == L::WORD && Grammar::FREQUENCY[content[0][1]]
|
|
175
|
+
return true if content.size < slice.size / S || determined?(text, slice[3]) || hyphenated?(text, slice[-1])
|
|
176
|
+
at = space_after(text, slice[-1])
|
|
177
|
+
at > slice[-1] && (c = text.getbyte(at)) && (c | 32).between?(97, 122) &&
|
|
178
|
+
!CONTINUES.include?(text.byteslice(at, 12).scrub("")[/\A[a-z]+/i]&.downcase)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
DETERMINERS = %w[the a an this our my your their his its].freeze
|
|
182
|
+
|
|
183
|
+
# E6: "read the daily", "our weekly with the team": after a determiner it is a noun phrase
|
|
184
|
+
# Walks back over the word byte by byte, no further than the longest determiner and one more: a regex on all
|
|
185
|
+
# the text before would make extract quadratic
|
|
186
|
+
def self.determined?(text, start)
|
|
187
|
+
stop = space_before(text, start)
|
|
188
|
+
word = stop
|
|
189
|
+
word -= 1 while word > 0 && stop - word < 6 && letter?(text.getbyte(word - 1))
|
|
190
|
+
stop < start && word < stop && !(word > 0 && letter?(text.getbyte(word - 1))) &&
|
|
191
|
+
DETERMINERS.include?(text.byteslice(word, stop - word).downcase)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.letter?(byte) = (byte | 32).between?(97, 122)
|
|
195
|
+
|
|
196
|
+
# E6: "to-do", "on-call": a word joined by a hyphen is a compound noun, not a word that continues a rule
|
|
197
|
+
def self.hyphenated?(text, stop)
|
|
198
|
+
text.getbyte(stop) == 45 && (c = text.getbyte(stop + 1)) && (c | 32).between?(97, 122)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Character offsets of byte offsets, counted from the last one asked for: a walk through the text costs
|
|
202
|
+
# its length once
|
|
203
|
+
class Chars
|
|
204
|
+
def initialize(text)
|
|
205
|
+
@text = text
|
|
206
|
+
@ascii = text.ascii_only?
|
|
207
|
+
@byte = 0
|
|
208
|
+
@char = 0
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def [](byte)
|
|
212
|
+
return byte if @ascii
|
|
213
|
+
@char += byte >= @byte ? @text.byteslice(@byte, byte - @byte).length : -@text.byteslice(byte, @byte - byte).length
|
|
214
|
+
@byte = byte
|
|
215
|
+
@char
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The longest mention at the start of the tokens, as [mention, its length in entries]. Only the prefixes
|
|
220
|
+
# the grammar accepts are tried, longest first: a phrase's, then a duration's.
|
|
221
|
+
def self.longest(text, all, make_context, whole, spent)
|
|
222
|
+
tokens = all
|
|
223
|
+
until tokens.empty?
|
|
224
|
+
tree, t = Grammar.longest(tokens, spent:)
|
|
225
|
+
units, d = Grammar.longest(tokens, duration: true, spent:)
|
|
226
|
+
return unless tree || units
|
|
227
|
+
j = t.to_i > d.to_i ? t : d
|
|
228
|
+
slice = j == tokens.size ? tokens : tokens[0, j]
|
|
229
|
+
found = !glued?(all, j) && !trailing?(tokens, j) && !before_month?(all, j) &&
|
|
230
|
+
mention(text, slice, j == t ? tree : nil, j == d ? units : nil, make_context, whole)
|
|
231
|
+
return [found, j] if found
|
|
232
|
+
tokens = tokens[0, j - S]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Token index ranges [a, b) not split by an unknown word or a sentence's punctuation
|
|
237
|
+
def self.runs(text, tokens, unknown)
|
|
238
|
+
gaps = unknown.each_slice(2).map(&:first)
|
|
239
|
+
out = []
|
|
240
|
+
a = 0
|
|
241
|
+
n = tokens.size / S
|
|
242
|
+
(1..n).each do |k|
|
|
243
|
+
next if k < n && !split?(text, tokens, k, gaps)
|
|
244
|
+
out << [a, k]
|
|
245
|
+
a = k
|
|
246
|
+
end
|
|
247
|
+
out
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.split?(text, tokens, k, gaps)
|
|
251
|
+
from = tokens[(k - 1) * S + 4]
|
|
252
|
+
to = tokens[k * S + 3]
|
|
253
|
+
gaps.shift while gaps.any? && gaps[0] < from
|
|
254
|
+
return true if gaps.any? && gaps[0] < to
|
|
255
|
+
dot = abbreviated?(text, tokens, k - 1) && !capital?(text.getbyte(to)) ? from : -1
|
|
256
|
+
(from...to).any? { |b| (c = text.getbyte(b)) != 44 && b != dot && PUNCT.include?(c) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
ABBREVIATED = { L::MONTH => true, L::WDAY => true, L::MER => true }.freeze
|
|
260
|
+
|
|
261
|
+
# E5: a dot right after a month, weekday or meridiem ends its abbreviation, not the sentence: "Oct. 2",
|
|
262
|
+
# "1p.m. to 4". One before a capital still ends the sentence.
|
|
263
|
+
def self.abbreviated?(text, tokens, k)
|
|
264
|
+
ABBREVIATED[tokens[k * S]] && text.getbyte(tokens[k * S + 4]) == 46
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def self.capital?(byte) = byte && byte >= 65 && byte <= 90
|
|
268
|
+
|
|
269
|
+
START_WORDS = { on: true, and: true, to: true, for: true, during: true }.freeze
|
|
270
|
+
LEADS = { L::WORD => { in: :same, around: :further, within: :further }.freeze,
|
|
271
|
+
L::PART => { lunch: :same, breakfast: :same, dinner: :same }.freeze }.freeze
|
|
272
|
+
|
|
273
|
+
# The mention inside found after its leading word at k, with where it starts: with same, the next one
|
|
274
|
+
# when it reads the same or reaches further; otherwise the first that reaches further
|
|
275
|
+
def self.after_lead(text, tokens, k, found, same, look)
|
|
276
|
+
((k + 1)...(k + found[1] / S)).each do |n|
|
|
277
|
+
next unless starts_mention?(text, tokens, n)
|
|
278
|
+
m = look.(n)
|
|
279
|
+
return [m, n] if m && (m[0].offset.end > found[0].offset.end ||
|
|
280
|
+
(same && m[0].offset.end == found[0].offset.end && same?(m[0].value, found[0].value)))
|
|
281
|
+
break if same
|
|
282
|
+
end
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# "on", "at", "for" and "during" introduce a mention without being part of it (E2 gives a duration its
|
|
287
|
+
# "for", E4 leaves out "on" and "at"). None starts inside a written number, as "12-1234" in "123-12-1234"
|
|
288
|
+
# or "1 in the morning" in "37.1 in the morning".
|
|
289
|
+
def self.starts_mention?(text, tokens, i)
|
|
290
|
+
k = tokens[i * S]
|
|
291
|
+
if i >= 2 && GLUE[tokens[(i - 1) * S]] && tokens[(i - 2) * S] == L::NUM &&
|
|
292
|
+
tokens[(i - 2) * S + 4] == tokens[(i - 1) * S + 3] && tokens[(i - 1) * S + 4] == tokens[i * S + 3]
|
|
293
|
+
return false
|
|
294
|
+
end
|
|
295
|
+
return false if k == L::AT || (k == L::WORD && START_WORDS[tokens[i * S + 1]])
|
|
296
|
+
return false if after?(text, tokens[i * S + 3], CURRENCY) # D50: "$12", "$DEC"
|
|
297
|
+
return false if k == L::NUM && after?(text, tokens[i * S + 3], LABEL) # D51: "building 4 this afternoon"
|
|
298
|
+
return false if k == L::PART && after?(text, tokens[i * S + 3], GREETING) # D50: "Good morning"
|
|
299
|
+
# D50: "start" and "end" in lower case are verbs unless "of" follows: "start 3 minutes from now"
|
|
300
|
+
return false if k == L::FUZZ && tokens[i * S + 2] & L::CAP == 0 &&
|
|
301
|
+
VERBS.match?(text.byteslice(tokens[i * S + 3], tokens[i * S + 4] - tokens[i * S + 3])) &&
|
|
302
|
+
!(tokens[(i + 1) * S] == L::WORD && tokens[(i + 1) * S + 1] == :of)
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
VERBS = /\A(?:start|end)\z/
|
|
307
|
+
CURRENCY = /[$£€]\s*\z/
|
|
308
|
+
GREETING = /\bgood\s+\z/i
|
|
309
|
+
LABEL = /\b(?:building|room|floor|level|gate|platform|terminal|suite|unit|table|track|bay|page|chapter)\s+\z/i
|
|
310
|
+
|
|
311
|
+
# Whether the text just before the byte matches
|
|
312
|
+
# The window can start inside a multibyte character, which a regex refuses to match: "会議は日本 tomorrow"
|
|
313
|
+
def self.after?(text, byte, pattern)
|
|
314
|
+
return false unless byte > 0
|
|
315
|
+
before = text.byteslice([byte - 12, 0].max, [byte, 12].min)
|
|
316
|
+
(before.valid_encoding? ? before : before.scrub("")).match?(pattern)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
TRAILING = { on: true, the: true, at: true, and: true, of: true, to: true, in: true, for: true, by: true }.freeze
|
|
320
|
+
|
|
321
|
+
# D50: a mention doesn't end in a word that only introduces what follows: "May 6th 2004, the customer"
|
|
322
|
+
def self.trailing?(tokens, j)
|
|
323
|
+
j > S && tokens[j - S] == L::WORD && TRAILING[tokens[j - S + 1]]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# D50: a month abbreviation directly before another month is a name: "Angie Mar November 2019"
|
|
327
|
+
def self.before_month?(all, j)
|
|
328
|
+
j == S && all[0] == L::MONTH && all[j] == L::MONTH && all[4] - all[3] <= 3
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# E4: the same begin and end for every reading, and the same alternatives
|
|
332
|
+
def self.same?(a, b)
|
|
333
|
+
return a == b unless a.kind == :time && b.kind == :time
|
|
334
|
+
[a, *a.alternatives].map { |r| [r.begin, r.end] } == [b, *b.alternatives].map { |r| [r.begin, r.end] }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Nor does one end inside a written number, as "2010-2015" in "2010-2015-86"
|
|
338
|
+
def self.glued?(tokens, j)
|
|
339
|
+
j + S < tokens.size && GLUE[tokens[j]] && tokens[j - S] == L::NUM && tokens[j + S] == L::NUM &&
|
|
340
|
+
tokens[j - 1] == tokens[j + 3] && tokens[j + 4] == tokens[j + S + 3]
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def self.mention(text, slice, tree, units, make_context, whole)
|
|
344
|
+
st = slice[3]
|
|
345
|
+
return if plain_english?(slice, sentence_start?(text, st)) || (slice.size == S && slice[0] == L::NUM) ||
|
|
346
|
+
spelled?(slice) || in_word?(text, st, slice[-1]) || one_to_one?(slice) || counted?(text, slice) ||
|
|
347
|
+
version?(slice)
|
|
348
|
+
stop = meridiem_dot(text, slice) || slice[-1]
|
|
349
|
+
# E5: "'40s": the apostrophe for the century left out is part of it
|
|
350
|
+
st -= 1 if slice[0] == L::DECADE && st > 0 && text.getbyte(st - 1) == 39
|
|
351
|
+
piece = text.byteslice(st, stop - st)
|
|
352
|
+
value = (tree && time(piece, tree, make_context, whole)) || (units && Duration.from_units(units))
|
|
353
|
+
return unless value
|
|
354
|
+
# E2: "for 2 hours" is the duration's mention, "for" included
|
|
355
|
+
if value.kind == :duration && (f = for_before(text, st))
|
|
356
|
+
st = f
|
|
357
|
+
piece = text.byteslice(st, stop - st)
|
|
358
|
+
end
|
|
359
|
+
Extraction::Mention.new(value.kind, piece, st...stop, value)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
JOINED = /\A\s*(?:and|or|(?:and |but )?not)\s+\z/
|
|
363
|
+
YEAR_END = /\d{4}\z/
|
|
364
|
+
|
|
365
|
+
# D51: a lone year joined by "and", "or" or "not" to a mention that ends in a year is a year too: "in 2007
|
|
366
|
+
# and 2009", "before 2000 but not 1998". With no mention before it, a lone year is still a number (E3).
|
|
367
|
+
def self.joined_year(text, tokens, i, last, make_context, whole, spent)
|
|
368
|
+
st = tokens[i * S + 3]
|
|
369
|
+
stop = tokens[i * S + 4]
|
|
370
|
+
return unless last.text.match?(YEAR_END) && JOINED.match?(text.byteslice(last.offset.end, st - last.offset.end)) &&
|
|
371
|
+
!glued?(tokens, (i + 1) * S) && !in_word?(text, st, stop)
|
|
372
|
+
slice = tokens[i * S, S]
|
|
373
|
+
tree, = Grammar.longest(slice, spent:)
|
|
374
|
+
piece = text.byteslice(st, stop - st)
|
|
375
|
+
value = tree && time(piece, tree, make_context, whole)
|
|
376
|
+
Extraction::Mention.new(value.kind, piece, st...stop, value) if value
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
LIST_GAP = /\A\s*(?:,\s*|(?:,\s*)?(?:and|or)\s+)\z/i
|
|
380
|
+
|
|
381
|
+
# D18: a weekday in a list of weekdays is a day, though it is also a word: "mon, wed and fri", "mon and
|
|
382
|
+
# wed" mention each day
|
|
383
|
+
def self.listed_days(text, tokens)
|
|
384
|
+
prev = nil
|
|
385
|
+
(0...tokens.size).step(S) do |j|
|
|
386
|
+
if tokens[j] == L::WDAY
|
|
387
|
+
if prev && LIST_GAP.match?(text.byteslice(tokens[prev + 4], tokens[j + 3] - tokens[prev + 4]))
|
|
388
|
+
tokens[prev + 2] &= ~L::ENGLISH
|
|
389
|
+
tokens[j + 2] &= ~L::ENGLISH
|
|
390
|
+
end
|
|
391
|
+
prev = j
|
|
392
|
+
elsif !(tokens[j] == L::WORD && ((w = tokens[j + 1]) == :and || w == :or))
|
|
393
|
+
prev = nil
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# E5: "4 a.m.", "3 p . m .": the last dot of a meridiem written with dots is part of it
|
|
399
|
+
def self.meridiem_dot(text, slice)
|
|
400
|
+
return unless slice[-S] == L::MER && (abbr = text.byteslice(slice[-2], slice[-1] - slice[-2])).include?(".")
|
|
401
|
+
dot = abbr.include?(" ") ? space_after(text, slice[-1]) : slice[-1]
|
|
402
|
+
dot + 1 if text.getbyte(dot) == 46
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def self.time(piece, tree, make_context, whole)
|
|
406
|
+
ctx = make_context.()
|
|
407
|
+
readings = Resolve.call(tree, ctx)
|
|
408
|
+
Result.new(whole || piece, readings, ctx.zone, ctx.assumptions(readings[0]), piece) unless readings.empty?
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# E3: "may", "sat", "second" in lower case and with nothing else to say they are times are words. A
|
|
412
|
+
# capital that starts a sentence says nothing, so "Lunch with Sam" is a word too. D51: a month after "in"
|
|
413
|
+
# is a month: "It may be in may".
|
|
414
|
+
def self.plain_english?(slice, sentence_start)
|
|
415
|
+
return false if slice[0] == L::WORD && slice[1] == :in && slice[S] == L::MONTH
|
|
416
|
+
content = slice.each_slice(S).reject { |k, *| k == L::WORD || k == L::REL || k == L::ART || k == L::AT }
|
|
417
|
+
content.any? && content.each_with_index.all? do |(_, _, flags, start), n|
|
|
418
|
+
flags & L::ENGLISH != 0 && (flags & L::CAP == 0 || (n == 0 && sentence_start && start == slice[3]))
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
JOINS = { to: true, and: true, or: true }.freeze
|
|
423
|
+
|
|
424
|
+
# D50: numbers written as words with no time word beside them are plain English: "two to four people",
|
|
425
|
+
# "One may ask", "The first free fish"
|
|
426
|
+
def self.spelled?(slice)
|
|
427
|
+
tokens = slice.each_slice(S).to_a
|
|
428
|
+
words, content = tokens.partition { |k, *| k == L::WORD || k == L::ART }
|
|
429
|
+
spelled = ->((k, _, flags)) { (k == L::NUM || k == L::ORD) && flags & L::WORDY != 0 }
|
|
430
|
+
if content.all?(&spelled)
|
|
431
|
+
content.size == 1 ? content[0][0] == L::ORD && words.all? { |_, v| v == :the } : words.any? && words.all? { |_, v| JOINS[v] }
|
|
432
|
+
else
|
|
433
|
+
words.empty? && content.size == 2 && spelled.(content[0]) && content[1][2] & (L::ENGLISH | L::CAP) == L::ENGLISH
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# D50: nothing inside a word or an id: "3am" in "~.~3am3ioYcY0"
|
|
438
|
+
def self.in_word?(text, st, stop)
|
|
439
|
+
(st > 0 && alnum?(text.getbyte(st - 1))) || alnum?(text.getbyte(stop))
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def self.alnum?(byte) = byte && ((byte >= 48 && byte <= 57) || ((byte | 32) >= 97 && (byte | 32) <= 122))
|
|
443
|
+
|
|
444
|
+
# D50: the same number at both ends is no range: "a 1-1 meeting"
|
|
445
|
+
def self.one_to_one?(slice)
|
|
446
|
+
slice.size == 3 * S && slice[0] == L::NUM && slice[S] == L::DASH && slice[2 * S] == L::NUM && slice[1] == slice[2 * S + 1]
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# D51: three dotted numbers with a one-digit day or month and a two-digit year are a version: "1.5.30"
|
|
450
|
+
def self.version?(slice)
|
|
451
|
+
slice.size >= 5 * S && slice[S] == L::DOT && slice[3 * S] == L::DOT && slice[4 * S] == L::NUM &&
|
|
452
|
+
slice[4 * S + 2] & L::DIGITS == 2 && slice[2] & L::DIGITS <= 2 &&
|
|
453
|
+
(slice[2] & L::DIGITS == 1 || slice[2 * S + 2] & L::DIGITS == 1) &&
|
|
454
|
+
slice[4] == slice[S + 3] && slice[S + 4] == slice[2 * S + 3] && slice[2 * S + 4] == slice[3 * S + 3] &&
|
|
455
|
+
slice[3 * S + 4] == slice[4 * S + 3]
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# D50: an ordinal before "time" or "times" counts occasions: "17th times", "the 5th time"
|
|
459
|
+
def self.counted?(text, slice)
|
|
460
|
+
slice.each_slice(S).count { |k, *| k != L::WORD && k != L::ART } == 1 && slice[-S] == L::ORD &&
|
|
461
|
+
text.byteslice(slice[-1], 8).scrub("").match?(/\A\s+times?\b/i) # the 8 bytes can end inside a character
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
SPACE = [32, 9, 10, 11, 12, 13].freeze
|
|
465
|
+
|
|
466
|
+
def self.space_before(text, byte)
|
|
467
|
+
byte -= 1 while byte > 0 && SPACE.include?(text.getbyte(byte - 1))
|
|
468
|
+
byte
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def self.space_after(text, byte)
|
|
472
|
+
byte += 1 while byte < text.bytesize && SPACE.include?(text.getbyte(byte))
|
|
473
|
+
byte
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def self.sentence_start?(text, byte)
|
|
477
|
+
byte = space_before(text, byte)
|
|
478
|
+
byte == 0 || [46, 33, 63].include?(text.getbyte(byte - 1))
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Where the "for" just before the byte starts, with a space or more between
|
|
482
|
+
def self.for_before(text, byte)
|
|
483
|
+
word = space_before(text, byte)
|
|
484
|
+
return if word == byte || word < 3 || !text.byteslice(word - 3, 3).b.casecmp?("for")
|
|
485
|
+
return 0 if word == 3
|
|
486
|
+
before = word - 4
|
|
487
|
+
before -= 1 while before > 0 && text.getbyte(before) & 0xC0 == 0x80
|
|
488
|
+
word - 3 unless text.byteslice(before, word - 3 - before).match?(/\p{Word}/)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
private_constant :Extract
|
|
493
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
# Clocks and years said in words: "twenty past four", "a quarter to six", "four twenty-three", "two thousand"
|
|
3
|
+
class Grammar
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
# D42: "five to 9" is 8:55 when the minutes are a word; "5 to 9" and "from five to nine" are ranges.
|
|
7
|
+
# D44: "10 after 2", "twenty before noon" are clocks however the minutes are written.
|
|
8
|
+
def minutes_to?(i)
|
|
9
|
+
w = @t[i + S + 1]
|
|
10
|
+
return hour?(i + 2 * S) && @t[i + 1].between?(1, 59) if w == :after || w == :before
|
|
11
|
+
n = @t[i + 1]
|
|
12
|
+
w == :to && n.between?(1, 30) && hour?(i + 2 * S) &&
|
|
13
|
+
!(i >= S && @t[i - S] == L::WORD && (@t[i - S + 1] == :from || @t[i - S + 1] == :between)) &&
|
|
14
|
+
# D44: "15 to noon" is 11:45, since as a range of hours it would end before it began
|
|
15
|
+
(@t[i + 2] & L::WORDY != 0 || (@t[i + 2 * S] == L::NAMED && @t[i + 2 * S + 1] == 12 && (n <= 5 || n >= 12)))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A number word at i and the words after it that say one clock or year with it, or nil
|
|
19
|
+
def said(v, i)
|
|
20
|
+
t = @t
|
|
21
|
+
w = t[i + S + 1]
|
|
22
|
+
if t[i + S] == L::NUM && t[i + S + 2] & L::WORDY != 0
|
|
23
|
+
# "four twenty-three"
|
|
24
|
+
if v.between?(1, 12) && w.between?(10, 59)
|
|
25
|
+
@i += 2 * S
|
|
26
|
+
return said_clock(v, w)
|
|
27
|
+
end
|
|
28
|
+
# D44: "nineteen ninety five", "twenty ten": a year written as two numbers; "fourteen thirty" may be a
|
|
29
|
+
# clock, so from 13 to 23 the second must be past any minute
|
|
30
|
+
if v.between?(13, 29) && w.between?(10, 99) && (v.between?(18, 20) || v > 23 || w > 59)
|
|
31
|
+
@i += 2 * S
|
|
32
|
+
Date.new(v * 100 + w, nil, nil, nil, false)
|
|
33
|
+
end
|
|
34
|
+
elsif w == :oh # D44: "twelve oh three", "nine zero five"
|
|
35
|
+
if v.between?(1, 12) && t[i + 2 * S] == L::NUM && t[i + 2 * S + 2] & L::WORDY != 0 && (m = t[i + 2 * S + 1]).between?(1, 9)
|
|
36
|
+
@i += 3 * S
|
|
37
|
+
said_clock(v, m)
|
|
38
|
+
end
|
|
39
|
+
elsif w == :thousand && v.between?(1, 2)
|
|
40
|
+
thousands(v, i)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def said_clock(h, m)
|
|
45
|
+
c = Clock.new(h, m, 0, nil, nil, false, false)
|
|
46
|
+
if @t[@i] == L::MER
|
|
47
|
+
c.meridiem = @t[@i + 1] == 0 ? :am : :pm
|
|
48
|
+
@i += S
|
|
49
|
+
end
|
|
50
|
+
c
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# D44: "two thousand", "two thousand and eighteen", "two thousand ten": a year in words
|
|
54
|
+
def thousands(v, i)
|
|
55
|
+
j = i + 2 * S
|
|
56
|
+
j += S if @t[j] == L::WORD && @t[j + 1] == :and && @t[j + S] == L::NUM
|
|
57
|
+
year = v * 1000
|
|
58
|
+
if @t[j] == L::NUM && @t[j + 2] & L::WORDY != 0 && @t[j + 1] < 100
|
|
59
|
+
year += @t[j + 1]
|
|
60
|
+
j += S
|
|
61
|
+
end
|
|
62
|
+
@i = j
|
|
63
|
+
Date.new(year, nil, nil, nil, false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def hour?(i) = (@t[i] == L::NUM && @t[i + 1].between?(1, 12)) || @t[i] == L::NAMED
|
|
67
|
+
|
|
68
|
+
def clock_word?(i)
|
|
69
|
+
k = @t[i]
|
|
70
|
+
v = @t[i + 1]
|
|
71
|
+
(k == L::REL && v == :past) || (k == L::WORD && (v == :to || v == :after || v == :before))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# "twenty past four", "half past twelve", "a quarter to six", D44: "twenty after 3pm", "10 before 2",
|
|
75
|
+
# "a quarter past noon"
|
|
76
|
+
def clock_words(minutes)
|
|
77
|
+
@i += S
|
|
78
|
+
@i += S if @t[@i - S] == L::ART
|
|
79
|
+
@i += S if @t[@i] == L::UNIT && (@t[@i + 1] == :quarter || @t[@i + 1] == :minute)
|
|
80
|
+
# D48: "half three" is 3:30, as said in Britain
|
|
81
|
+
half = minutes == 30 && @t[@i - S + 1] == :half && @t[@i] == L::NUM
|
|
82
|
+
return failure(:incomplete) unless half || clock_word?(@i)
|
|
83
|
+
to = !half && (@t[@i + 1] == :to || @t[@i + 1] == :before)
|
|
84
|
+
@i += S unless half
|
|
85
|
+
if @t[@i] == L::NAMED
|
|
86
|
+
noon = @t[@i + 1] == 12
|
|
87
|
+
@i += S
|
|
88
|
+
return Clock.new(11, 60 - minutes, 0, noon ? :am : :pm, nil, false, false) if to
|
|
89
|
+
return Clock.new(12, minutes, 0, noon ? :pm : :am, nil, false, false)
|
|
90
|
+
end
|
|
91
|
+
return failure(:incomplete) unless @t[@i] == L::NUM && @t[@i + 1].between?(1, 12)
|
|
92
|
+
hour = @t[@i + 1]
|
|
93
|
+
@i += S
|
|
94
|
+
min = 0
|
|
95
|
+
if @t[@i] == L::NUM && @t[@i + 2] & L::WORDY != 0 && @t[@i + 1].between?(10, 59) # D48: "three mins to five thirty"
|
|
96
|
+
min = @t[@i + 1]
|
|
97
|
+
@i += S
|
|
98
|
+
end
|
|
99
|
+
@i += S if @t[@i] == L::WORD && @t[@i + 1] == :oclock
|
|
100
|
+
meridiem = nil
|
|
101
|
+
if @t[@i] == L::MER
|
|
102
|
+
meridiem = @t[@i + 1] == 0 ? :am : :pm
|
|
103
|
+
@i += S
|
|
104
|
+
end
|
|
105
|
+
t = (hour % 12 * 60 + min + (to ? -minutes : minutes)) % 720
|
|
106
|
+
Clock.new(t < 60 ? 12 : t / 60, t % 60, 0, meridiem, nil, false, false)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|