nl2time 0.1.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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/clock/index.d.ts +40 -0
- package/dist/clock/index.js +99 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +75 -0
- package/dist/corpus/runner.d.ts +81 -0
- package/dist/corpus/runner.js +220 -0
- package/dist/data/dayPeriods.d.ts +18 -0
- package/dist/data/dayPeriods.js +24 -0
- package/dist/data/weekData.d.ts +11 -0
- package/dist/data/weekData.js +25 -0
- package/dist/describe/index.d.ts +25 -0
- package/dist/describe/index.js +289 -0
- package/dist/engine/resolve.d.ts +31 -0
- package/dist/engine/resolve.js +684 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/ir/types.d.ts +165 -0
- package/dist/ir/types.js +31 -0
- package/dist/ir/validate.d.ts +11 -0
- package/dist/ir/validate.js +205 -0
- package/dist/llm/index.d.ts +26 -0
- package/dist/llm/index.js +89 -0
- package/dist/parse/en.d.ts +18 -0
- package/dist/parse/en.js +2708 -0
- package/dist/parse/index.d.ts +20 -0
- package/dist/parse/index.js +230 -0
- package/dist/parse/tokenizer.d.ts +33 -0
- package/dist/parse/tokenizer.js +118 -0
- package/package.json +56 -0
- package/schema/timeexpr.schema.json +239 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognition: text → TimeExpr matches. Pure with respect to the reference
|
|
3
|
+
* instant — parsing uses ctx.locale/dateOrder only; ctx.now matters at
|
|
4
|
+
* resolve() time. (Recognition/resolution separation, docs/design.md §4.)
|
|
5
|
+
*/
|
|
6
|
+
import type { TimeContext } from '../context.js';
|
|
7
|
+
import type { TimeExpr } from '../ir/types.js';
|
|
8
|
+
export interface ParseMatch {
|
|
9
|
+
/** The matched source text span. */
|
|
10
|
+
text: string;
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
expr: TimeExpr;
|
|
14
|
+
confidence: number;
|
|
15
|
+
source: 'rules' | 'llm';
|
|
16
|
+
}
|
|
17
|
+
export interface ParseResult {
|
|
18
|
+
matches: ParseMatch[];
|
|
19
|
+
}
|
|
20
|
+
export declare function parse(text: string, ctx: TimeContext): ParseResult;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { EN_RULES } from './en.js';
|
|
2
|
+
import { tokenize } from './tokenizer.js';
|
|
3
|
+
const CONNECTORS = ['at', 'on', 'in', 'of', 'the', 'for', 'from', 'around', 'about', '-'];
|
|
4
|
+
export function parse(text, ctx) {
|
|
5
|
+
const tokens = tokenize(text);
|
|
6
|
+
const raw = [];
|
|
7
|
+
let i = 0;
|
|
8
|
+
while (i < tokens.length) {
|
|
9
|
+
let best;
|
|
10
|
+
for (const rule of EN_RULES) {
|
|
11
|
+
const m = rule(tokens, i, ctx);
|
|
12
|
+
if (!m)
|
|
13
|
+
continue;
|
|
14
|
+
if (!best || m.consumed > best.consumed || (m.consumed === best.consumed && m.confidence > best.confidence)) {
|
|
15
|
+
best = { ...m, tokenStart: i, tokenEnd: i + m.consumed };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (best) {
|
|
19
|
+
raw.push(best);
|
|
20
|
+
i = best.tokenEnd;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
i += 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Merge to fixpoint: [date, duration, time] needs two passes.
|
|
27
|
+
let merged = mergeAdjacent(raw, tokens);
|
|
28
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
29
|
+
const again = mergeAdjacent(merged, tokens);
|
|
30
|
+
if (again.length === merged.length)
|
|
31
|
+
break;
|
|
32
|
+
merged = again;
|
|
33
|
+
}
|
|
34
|
+
const matches = merged
|
|
35
|
+
.filter((m) => m.confidence >= 0.5)
|
|
36
|
+
.map((m) => {
|
|
37
|
+
const startTok = tokens[m.tokenStart];
|
|
38
|
+
const endTok = tokens[m.tokenEnd - 1];
|
|
39
|
+
return {
|
|
40
|
+
text: text.slice(startTok.start, endTok.end),
|
|
41
|
+
start: startTok.start,
|
|
42
|
+
end: endTok.end,
|
|
43
|
+
expr: m.expr,
|
|
44
|
+
confidence: m.confidence,
|
|
45
|
+
source: 'rules',
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
return { matches };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Refiner: merge a date-ish match with an adjacent time-ish match (optionally
|
|
52
|
+
* separated by a connector token) into intersect(date, time) — "yesterday at
|
|
53
|
+
* 9pm", "next Tuesday morning", "May 29 at noon".
|
|
54
|
+
*/
|
|
55
|
+
function mergeAdjacent(matches, tokens) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let k = 0;
|
|
58
|
+
while (k < matches.length) {
|
|
59
|
+
const cur = matches[k];
|
|
60
|
+
const next = matches[k + 1];
|
|
61
|
+
// Anchored span: time + duration ("from 2pm for 2 hours") or duration +
|
|
62
|
+
// time ("for 2.5 hours from 9") → span(anchor, amount).
|
|
63
|
+
const spanPair = next &&
|
|
64
|
+
((isTimeish(cur.role) && next.role === 'duration' && connectedBy(cur, next, tokens, ['for'])) ||
|
|
65
|
+
(cur.role === 'duration' && isTimeish(next.role) && connectedBy(cur, next, tokens, ['from', 'starting', 'at'])) ||
|
|
66
|
+
// "for a week starting tomorrow"
|
|
67
|
+
(cur.role === 'duration' && next.role === 'date' && connectedBy(cur, next, tokens, ['starting', 'from'])));
|
|
68
|
+
if (next && spanPair) {
|
|
69
|
+
const [anchorPart, durPart] = cur.role === 'duration' ? [next, cur] : [cur, next];
|
|
70
|
+
const amount = amountOf(durPart.expr);
|
|
71
|
+
if (amount) {
|
|
72
|
+
out.push({
|
|
73
|
+
expr: { op: 'span', anchor: anchorPart.expr, amount },
|
|
74
|
+
consumed: next.tokenEnd - cur.tokenStart,
|
|
75
|
+
confidence: Math.max(cur.confidence, next.confidence),
|
|
76
|
+
// A span anchored at a time-of-day is still time-of-day-ish: it can
|
|
77
|
+
// merge with an adjacent date ("14th Feb from 9:30am for 7 hours").
|
|
78
|
+
role: anchorPart.role,
|
|
79
|
+
tokenStart: cur.tokenStart,
|
|
80
|
+
tokenEnd: next.tokenEnd,
|
|
81
|
+
});
|
|
82
|
+
k += 2;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Week + weekday: "previous week - Monday" → that weekday within the week.
|
|
87
|
+
if (next &&
|
|
88
|
+
isWeekExpr(cur.expr) &&
|
|
89
|
+
next.expr.op === 'seek' &&
|
|
90
|
+
next.expr.target.kind === 'weekday' &&
|
|
91
|
+
isAdjacent(cur, next, tokens)) {
|
|
92
|
+
out.push({
|
|
93
|
+
expr: { op: 'seek', base: cur.expr, dir: 'next', target: next.expr.target, n: 1 },
|
|
94
|
+
consumed: next.tokenEnd - cur.tokenStart,
|
|
95
|
+
confidence: Math.max(cur.confidence, next.confidence),
|
|
96
|
+
role: 'date',
|
|
97
|
+
tokenStart: cur.tokenStart,
|
|
98
|
+
tokenEnd: next.tokenEnd,
|
|
99
|
+
});
|
|
100
|
+
k += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (next && canMerge(cur, next) && isAdjacent(cur, next, tokens)) {
|
|
104
|
+
const [datePart, timePart] = cur.role === 'time' ? [next, cur] : [cur, next];
|
|
105
|
+
let dateExpr = datePart.expr;
|
|
106
|
+
let timeExpr = inferMeridiem(dateExpr, timePart.expr);
|
|
107
|
+
// A day-period on the date side pushes its meridiem into an explicit
|
|
108
|
+
// time range and then steps aside: "Monday morning 6-8" is Monday
|
|
109
|
+
// 06:00–08:00, not clipped to the morning boundaries.
|
|
110
|
+
if (timeExpr.op === 'between') {
|
|
111
|
+
const period = findDayPeriod(dateExpr);
|
|
112
|
+
if (period) {
|
|
113
|
+
timeExpr = {
|
|
114
|
+
...timeExpr,
|
|
115
|
+
start: forceMeridiem(timeExpr.start, period),
|
|
116
|
+
end: forceMeridiem(timeExpr.end, period),
|
|
117
|
+
};
|
|
118
|
+
dateExpr = stripDayPeriod(dateExpr) ?? dateExpr;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
out.push({
|
|
122
|
+
expr: { op: 'intersect', parts: [dateExpr, timeExpr] },
|
|
123
|
+
consumed: next.tokenEnd - cur.tokenStart,
|
|
124
|
+
confidence: Math.max(cur.confidence, next.confidence),
|
|
125
|
+
role: 'datetime',
|
|
126
|
+
tokenStart: cur.tokenStart,
|
|
127
|
+
tokenEnd: next.tokenEnd,
|
|
128
|
+
});
|
|
129
|
+
k += 2;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
out.push(cur);
|
|
133
|
+
k += 1;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
function canMerge(a, b) {
|
|
138
|
+
const dateish = (r) => r === 'date' || r === 'datetime';
|
|
139
|
+
return (dateish(a.role) && b.role === 'time') || (a.role === 'time' && dateish(b.role));
|
|
140
|
+
}
|
|
141
|
+
function isTimeish(role) {
|
|
142
|
+
return role === 'time' || role === 'datetime';
|
|
143
|
+
}
|
|
144
|
+
function isWeekExpr(expr) {
|
|
145
|
+
return expr.op === 'snap' && expr.unit === 'week';
|
|
146
|
+
}
|
|
147
|
+
function connectedBy(a, b, tokens, words) {
|
|
148
|
+
const between = tokens.slice(a.tokenEnd, b.tokenStart);
|
|
149
|
+
if (between.length > 2)
|
|
150
|
+
return false;
|
|
151
|
+
return between.every((t) => t.type === 'word' && (words.includes(t.value) || t.value === 'the'));
|
|
152
|
+
}
|
|
153
|
+
/** Extract a calendar amount from a duration-role expression. */
|
|
154
|
+
function amountOf(expr) {
|
|
155
|
+
if (expr.op === 'amount')
|
|
156
|
+
return { ...expr.amount };
|
|
157
|
+
if (expr.op === 'duration') {
|
|
158
|
+
const m = expr.iso.match(/^PT(\d+)S$/);
|
|
159
|
+
if (m)
|
|
160
|
+
return { seconds: Number(m[1]) };
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* "tonight at 10": a day part that carries a day period narrows an ambiguous
|
|
166
|
+
* clock reading (night/evening/afternoon → pm, morning → am).
|
|
167
|
+
*/
|
|
168
|
+
function inferMeridiem(dateExpr, timeExpr) {
|
|
169
|
+
if (timeExpr.op !== 'literal' || timeExpr.time?.meridiem !== 'unknown')
|
|
170
|
+
return timeExpr;
|
|
171
|
+
const period = findDayPeriod(dateExpr);
|
|
172
|
+
if (!period)
|
|
173
|
+
return timeExpr;
|
|
174
|
+
const hour = timeExpr.time.hour ?? 12;
|
|
175
|
+
// "tonight around 3" is 3am (small hours belong to the night); "tonight at
|
|
176
|
+
// 10" is 10pm.
|
|
177
|
+
const meridiem = period === 'morning' ? 'am' : (period === 'night' || period === 'evening') && hour <= 5 ? 'am' : 'pm';
|
|
178
|
+
return {
|
|
179
|
+
...timeExpr,
|
|
180
|
+
time: { ...timeExpr.time, meridiem },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function forceMeridiem(expr, period) {
|
|
184
|
+
if (expr.op !== 'literal' || !expr.time || (expr.time.meridiem && expr.time.meridiem !== 'unknown')) {
|
|
185
|
+
return expr;
|
|
186
|
+
}
|
|
187
|
+
const hour = expr.time.hour ?? 12;
|
|
188
|
+
const meridiem = period === 'morning' ? 'am' : (period === 'night' || period === 'evening') && hour <= 5 ? 'am' : 'pm';
|
|
189
|
+
return { ...expr, time: { ...expr.time, meridiem } };
|
|
190
|
+
}
|
|
191
|
+
/** Remove day-period literals from an intersect; undefined if nothing remains. */
|
|
192
|
+
function stripDayPeriod(expr) {
|
|
193
|
+
if (expr.op === 'literal' && expr.dayPeriod && !expr.date && !expr.time)
|
|
194
|
+
return undefined;
|
|
195
|
+
if (expr.op === 'intersect') {
|
|
196
|
+
const parts = expr.parts.map(stripDayPeriod).filter((p) => p !== undefined);
|
|
197
|
+
if (parts.length === 0)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (parts.length === 1)
|
|
200
|
+
return parts[0];
|
|
201
|
+
return { ...expr, parts };
|
|
202
|
+
}
|
|
203
|
+
return expr;
|
|
204
|
+
}
|
|
205
|
+
function findDayPeriod(expr) {
|
|
206
|
+
if (expr.op === 'literal' && expr.dayPeriod)
|
|
207
|
+
return expr.dayPeriod;
|
|
208
|
+
if (expr.op === 'intersect') {
|
|
209
|
+
for (const p of expr.parts) {
|
|
210
|
+
const found = findDayPeriod(p);
|
|
211
|
+
if (found)
|
|
212
|
+
return found;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (expr.op === 'seek' && expr.target.kind === 'dayPeriod')
|
|
216
|
+
return expr.target.period;
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const REDUNDANT_BETWEEN = new Set([
|
|
220
|
+
'mon', 'tue', 'tues', 'wed', 'weds', 'thu', 'thur', 'thurs', 'fri', 'sat', 'sun',
|
|
221
|
+
'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
|
|
222
|
+
]);
|
|
223
|
+
function isAdjacent(a, b, tokens) {
|
|
224
|
+
const between = tokens.slice(a.tokenEnd, b.tokenStart);
|
|
225
|
+
if (between.length > 2)
|
|
226
|
+
return false;
|
|
227
|
+
// A parenthesized weekday between date and time ("May/22 (Tue) 11:30") is
|
|
228
|
+
// redundant with the date and doesn't block the merge.
|
|
229
|
+
return between.every((t) => t.type === 'word' && (CONNECTORS.includes(t.value) || REDUNDANT_BETWEEN.has(t.value)));
|
|
230
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizer for the rule-based parsers. Regexes here are deliberately simple
|
|
3
|
+
* (character classes and anchors only) so they port to any regex dialect;
|
|
4
|
+
* everything above this layer is table-driven token matching (docs/porting.md).
|
|
5
|
+
*/
|
|
6
|
+
export type Token = {
|
|
7
|
+
type: 'word';
|
|
8
|
+
value: string;
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'number';
|
|
13
|
+
value: number;
|
|
14
|
+
ordinal: boolean;
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'clock';
|
|
19
|
+
hour: number;
|
|
20
|
+
minute: number;
|
|
21
|
+
explicitMinute: boolean;
|
|
22
|
+
second?: number;
|
|
23
|
+
meridiem?: 'am' | 'pm';
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'numdate';
|
|
28
|
+
parts: number[];
|
|
29
|
+
sep: string;
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
};
|
|
33
|
+
export declare function tokenize(text: string): Token[];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizer for the rule-based parsers. Regexes here are deliberately simple
|
|
3
|
+
* (character classes and anchors only) so they port to any regex dialect;
|
|
4
|
+
* everything above this layer is table-driven token matching (docs/porting.md).
|
|
5
|
+
*/
|
|
6
|
+
const CLOCK_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?(am|pm|a|p)?$/;
|
|
7
|
+
const HOUR_MERIDIEM_RE = /^(\d{1,2})(am|pm|a|p)$/;
|
|
8
|
+
const ORDINAL_RE = /^(\d{1,2})(st|nd|rd|th)$/;
|
|
9
|
+
const NUMDATE_RE = /^(\d{1,4})([/\-.])(\d{1,2})(?:\2(\d{1,4}))?$/;
|
|
10
|
+
const NUMBER_RE = /^\d{1,4}$/;
|
|
11
|
+
const RAW_TOKEN_RE = /[a-z0-9:/\-.'~\u00bc\u00bd\u00be]+/gi;
|
|
12
|
+
export function tokenize(text) {
|
|
13
|
+
const tokens = [];
|
|
14
|
+
for (const m of text.matchAll(RAW_TOKEN_RE)) {
|
|
15
|
+
const raw = m[0]
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/^-(?=\d)/, '') // "(Tue)-11:30" → clock token after paren split
|
|
18
|
+
.replace(/[.]+$/, '')
|
|
19
|
+
.replace(/[''`]s$/, '') // "fortnight's" → fortnight
|
|
20
|
+
.replace(/['']+$/, '') // "workers'" → workers
|
|
21
|
+
.replace(/^a\.m$/, 'am')
|
|
22
|
+
.replace(/^p\.m$/, 'pm')
|
|
23
|
+
.replace(/^(\d{1,2}(?::\d{2})?)\.(am|pm|a|p)$/, '$1$2'); // "9.am" → 9am
|
|
24
|
+
if (raw === '')
|
|
25
|
+
continue;
|
|
26
|
+
const start = m.index;
|
|
27
|
+
const end = start + m[0].length;
|
|
28
|
+
// Split dash-joined mixed tokens ("18-dec", "nov-feb", "friday-jun-15")
|
|
29
|
+
// into parts with explicit "-" connectors so the range machinery sees
|
|
30
|
+
// them. Pure-numeric joins (11-4, 2014-2018) and "mid-…" stay intact.
|
|
31
|
+
if (raw.includes('-') && !raw.startsWith('mid')) {
|
|
32
|
+
const segments = raw.split('-');
|
|
33
|
+
// Word-ish segments include month/year compounds ("dec/2018"); pure
|
|
34
|
+
// digit-with-suffix segments like "4.30pm" must NOT count, so joined
|
|
35
|
+
// time ranges stay intact.
|
|
36
|
+
const alpha = segments.filter((s) => /^[a-z']{2,}(\/\d{1,4})?$/.test(s)).length;
|
|
37
|
+
const numeric = segments.filter((s) => /^[\d.:]+$/.test(s)).length;
|
|
38
|
+
const splittable = segments.length >= 2 &&
|
|
39
|
+
segments.every((s) => s.length > 0) &&
|
|
40
|
+
alpha + numeric === segments.length &&
|
|
41
|
+
alpha > 0;
|
|
42
|
+
if (splittable) {
|
|
43
|
+
for (const [si, seg] of segments.entries()) {
|
|
44
|
+
if (si > 0)
|
|
45
|
+
tokens.push({ type: 'word', value: '-', start, end });
|
|
46
|
+
pushToken(tokens, seg, start, end);
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
pushToken(tokens, raw, start, end);
|
|
52
|
+
}
|
|
53
|
+
return tokens;
|
|
54
|
+
}
|
|
55
|
+
function pushToken(tokens, raw, start, end) {
|
|
56
|
+
let match = raw.match(CLOCK_RE);
|
|
57
|
+
if (match) {
|
|
58
|
+
const token = {
|
|
59
|
+
type: 'clock',
|
|
60
|
+
hour: Number(match[1]),
|
|
61
|
+
minute: Number(match[2]),
|
|
62
|
+
explicitMinute: true,
|
|
63
|
+
start,
|
|
64
|
+
end,
|
|
65
|
+
};
|
|
66
|
+
if (match[3] !== undefined)
|
|
67
|
+
token.second = Number(match[3]);
|
|
68
|
+
if (match[4])
|
|
69
|
+
token.meridiem = match[4].startsWith('a') ? 'am' : 'pm';
|
|
70
|
+
tokens.push(token);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
match = raw.match(HOUR_MERIDIEM_RE);
|
|
74
|
+
if (match) {
|
|
75
|
+
tokens.push({
|
|
76
|
+
type: 'clock',
|
|
77
|
+
hour: Number(match[1]),
|
|
78
|
+
minute: 0,
|
|
79
|
+
explicitMinute: false,
|
|
80
|
+
meridiem: match[2].startsWith('a') ? 'am' : 'pm',
|
|
81
|
+
start,
|
|
82
|
+
end,
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
match = raw.match(ORDINAL_RE);
|
|
87
|
+
if (match) {
|
|
88
|
+
tokens.push({ type: 'number', value: Number(match[1]), ordinal: true, start, end });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
match = raw.match(NUMDATE_RE);
|
|
92
|
+
if (match && match[4] !== undefined) {
|
|
93
|
+
tokens.push({
|
|
94
|
+
type: 'numdate',
|
|
95
|
+
parts: [Number(match[1]), Number(match[3]), Number(match[4])],
|
|
96
|
+
sep: match[2],
|
|
97
|
+
start,
|
|
98
|
+
end,
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (match && match[2] === '/') {
|
|
103
|
+
// Two-part numeric date like 5/29 (only with slash — 5-29 is too odd).
|
|
104
|
+
tokens.push({
|
|
105
|
+
type: 'numdate',
|
|
106
|
+
parts: [Number(match[1]), Number(match[3])],
|
|
107
|
+
sep: '/',
|
|
108
|
+
start,
|
|
109
|
+
end,
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (NUMBER_RE.test(raw)) {
|
|
114
|
+
tokens.push({ type: 'number', value: Number(raw), ordinal: false, start, end });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
tokens.push({ type: 'word', value: raw, start, end });
|
|
118
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nl2time",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bidirectional natural language ⇄ date/time: parse expressions like 'last week' into concrete intervals, and describe instants as '9pm last night' — deterministic core, optional LLM assist.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js",
|
|
11
|
+
"./llm": "./dist/llm/index.js",
|
|
12
|
+
"./corpus": "./dist/corpus/runner.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"schema"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/AndyFooBlah/nl2time.git"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"prepare": "npm run build",
|
|
25
|
+
"prepublishOnly": "npm run typecheck && npm test",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"import:recognizers": "node scripts/import-recognizers.mjs",
|
|
30
|
+
"baselines": "npm run build && node scripts/update-baselines.mjs",
|
|
31
|
+
"eval": "npm run build && node scripts/eval.mjs"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"date",
|
|
35
|
+
"time",
|
|
36
|
+
"natural-language",
|
|
37
|
+
"parser",
|
|
38
|
+
"humanize",
|
|
39
|
+
"relative-time",
|
|
40
|
+
"temporal",
|
|
41
|
+
"llm",
|
|
42
|
+
"timezone",
|
|
43
|
+
"locale"
|
|
44
|
+
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"temporal-polyfill": "^0.3.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^26.1.1",
|
|
50
|
+
"typescript": "^5.6.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20"
|
|
55
|
+
}
|
|
56
|
+
}
|