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
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { TimeContext, ConfigError, type TimeContextOptions, type DateOrder, type Bias, type NextWeekdayPolicy, type PartialPeriodPolicy, } from './context.js';
|
|
2
|
+
export type { TimeExpr, Grain, Unit, Weekday, DayPeriod, CalendarAmount, Mod, Target, PartialDate, PartialTime, } from './ir/types.js';
|
|
3
|
+
export { IR_VERSION } from './ir/types.js';
|
|
4
|
+
export { validateExpr, IRValidationError } from './ir/validate.js';
|
|
5
|
+
export { resolve, resolveOne, NotResolvableError, AmbiguityError, type TimeValue, type Resolution, } from './engine/resolve.js';
|
|
6
|
+
export { parse, type ParseResult, type ParseMatch } from './parse/index.js';
|
|
7
|
+
export { describe, type Description, type DescribeOptions, type Framing, } from './describe/index.js';
|
|
8
|
+
export { Temporal } from './clock/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { TimeContext, ConfigError, } from './context.js';
|
|
2
|
+
export { IR_VERSION } from './ir/types.js';
|
|
3
|
+
export { validateExpr, IRValidationError } from './ir/validate.js';
|
|
4
|
+
export { resolve, resolveOne, NotResolvableError, AmbiguityError, } from './engine/resolve.js';
|
|
5
|
+
export { parse } from './parse/index.js';
|
|
6
|
+
export { describe, } from './describe/index.js';
|
|
7
|
+
export { Temporal } from './clock/index.js';
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nl2time intermediate representation (IR).
|
|
3
|
+
*
|
|
4
|
+
* A TimeExpr is a small, JSON-serializable operator tree over timeline
|
|
5
|
+
* intervals. Both directions of the library meet here: parsing produces a
|
|
6
|
+
* TimeExpr, and describe() selects a TimeExpr before rendering it. Resolution
|
|
7
|
+
* of a TimeExpr against a TimeContext is deterministic.
|
|
8
|
+
*
|
|
9
|
+
* The IR is language-neutral: the normative spec is schema/timeexpr.schema.json
|
|
10
|
+
* plus docs/ir-spec.md, and ports (e.g. Python) implement the same semantics.
|
|
11
|
+
*/
|
|
12
|
+
export type Grain = 'instant' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
|
|
13
|
+
/** Units usable in offsets/snaps — every grain except the zero-width 'instant'. */
|
|
14
|
+
export type Unit = Exclude<Grain, 'instant'>;
|
|
15
|
+
export type Weekday = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
|
16
|
+
/** CLDR-style flexible day periods (boundaries come from locale data). */
|
|
17
|
+
export type DayPeriod = 'morning' | 'afternoon' | 'evening' | 'night';
|
|
18
|
+
/**
|
|
19
|
+
* A calendar amount with unit-preserving fields ("2 months" is not a number of
|
|
20
|
+
* seconds). Fields may be negative ("the past 3 days" is days: -3).
|
|
21
|
+
*/
|
|
22
|
+
export interface CalendarAmount {
|
|
23
|
+
years?: number;
|
|
24
|
+
months?: number;
|
|
25
|
+
weeks?: number;
|
|
26
|
+
days?: number;
|
|
27
|
+
hours?: number;
|
|
28
|
+
minutes?: number;
|
|
29
|
+
seconds?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Approximation / edge channel, after TIMEX3's `mod`: "early July", "around 5". */
|
|
32
|
+
export type Mod = 'approx' | 'start' | 'mid' | 'end';
|
|
33
|
+
/** What a `seek` navigates to. */
|
|
34
|
+
export type Target = {
|
|
35
|
+
kind: 'weekday';
|
|
36
|
+
weekday: Weekday;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'month';
|
|
39
|
+
month: number;
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'dayPeriod';
|
|
42
|
+
period: DayPeriod;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'unit';
|
|
45
|
+
unit: Unit;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Partial civil components. Only components the source text asserted are
|
|
49
|
+
* present (chrono-node's known-vs-implied insight); missing components are
|
|
50
|
+
* completed at resolution time under context policy, never defaulted here.
|
|
51
|
+
*/
|
|
52
|
+
export interface PartialDate {
|
|
53
|
+
year?: number;
|
|
54
|
+
month?: number;
|
|
55
|
+
day?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface PartialTime {
|
|
58
|
+
hour?: number;
|
|
59
|
+
minute?: number;
|
|
60
|
+
second?: number;
|
|
61
|
+
/** 'unknown' marks an ambiguous clock reading ("at 4") → AM/PM candidates. */
|
|
62
|
+
meridiem?: 'am' | 'pm' | 'unknown';
|
|
63
|
+
}
|
|
64
|
+
interface ModCarrier {
|
|
65
|
+
mod?: Mod;
|
|
66
|
+
}
|
|
67
|
+
export type TimeExpr = TimeExprBody & ModCarrier;
|
|
68
|
+
export type TimeExprBody =
|
|
69
|
+
/** The reference instant from the TimeContext. */
|
|
70
|
+
{
|
|
71
|
+
op: 'now';
|
|
72
|
+
}
|
|
73
|
+
/** Civil components asserted by the text; optionally a day period. */
|
|
74
|
+
| {
|
|
75
|
+
op: 'literal';
|
|
76
|
+
date?: PartialDate;
|
|
77
|
+
time?: PartialTime;
|
|
78
|
+
dayPeriod?: DayPeriod;
|
|
79
|
+
}
|
|
80
|
+
/** Calendar offset: offset(now, -1, 'week') is "one week before now". */
|
|
81
|
+
| {
|
|
82
|
+
op: 'offset';
|
|
83
|
+
base: TimeExpr;
|
|
84
|
+
amount: number;
|
|
85
|
+
unit: Unit;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Snap to the containing unit interval: snap(x, 'week') is the whole week
|
|
89
|
+
* containing x (honoring the context week start). edge collapses to a point.
|
|
90
|
+
*/
|
|
91
|
+
| {
|
|
92
|
+
op: 'snap';
|
|
93
|
+
base: TimeExpr;
|
|
94
|
+
unit: Unit;
|
|
95
|
+
edge?: 'start' | 'end';
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Anchored span: span(now, {days: -3}) is the 3 days ending at now.
|
|
99
|
+
* Positive amounts extend forward from the anchor start.
|
|
100
|
+
*/
|
|
101
|
+
| {
|
|
102
|
+
op: 'span';
|
|
103
|
+
anchor: TimeExpr;
|
|
104
|
+
amount: CalendarAmount;
|
|
105
|
+
business?: boolean;
|
|
106
|
+
} | {
|
|
107
|
+
op: 'between';
|
|
108
|
+
start: TimeExpr;
|
|
109
|
+
end: TimeExpr;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Directed calendar navigation: seek(now, 'next', weekday tue) is "next
|
|
113
|
+
* Tuesday" (semantics of 'next' controlled by context nextWeekday policy);
|
|
114
|
+
* seek(marchExpr, 'next', weekday mon, 2) is "the 2nd Monday of March".
|
|
115
|
+
*/
|
|
116
|
+
| {
|
|
117
|
+
op: 'seek';
|
|
118
|
+
base: TimeExpr;
|
|
119
|
+
dir: 'next' | 'prev' | 'nearest';
|
|
120
|
+
target: Target;
|
|
121
|
+
n?: number;
|
|
122
|
+
}
|
|
123
|
+
/** Constraint intersection: "Tuesday" ∩ "morning" (SCATE-style). */
|
|
124
|
+
| {
|
|
125
|
+
op: 'intersect';
|
|
126
|
+
parts: TimeExpr[];
|
|
127
|
+
}
|
|
128
|
+
/** An exact elapsed duration ("for 90 minutes"). ISO-8601 duration string. */
|
|
129
|
+
| {
|
|
130
|
+
op: 'duration';
|
|
131
|
+
iso: string;
|
|
132
|
+
}
|
|
133
|
+
/** A calendar amount as a value in its own right ("2 months"). */
|
|
134
|
+
| {
|
|
135
|
+
op: 'amount';
|
|
136
|
+
amount: CalendarAmount;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A named holiday, resolved by the engine's holiday table (fixed-date,
|
|
140
|
+
* nth-weekday, or computed — Easter). Without a year: candidate occurrences
|
|
141
|
+
* around the reference, ordered by bias/dir.
|
|
142
|
+
*/
|
|
143
|
+
| {
|
|
144
|
+
op: 'holiday';
|
|
145
|
+
name: HolidayName;
|
|
146
|
+
year?: number;
|
|
147
|
+
dir?: 'prev' | 'next';
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Recurrence — representable and serializable in v1, resolvable in v2.
|
|
151
|
+
* "every Tuesday" = { every: 'week', filter: seek weekday tue }.
|
|
152
|
+
*/
|
|
153
|
+
| {
|
|
154
|
+
op: 'recur';
|
|
155
|
+
every: Unit;
|
|
156
|
+
filter?: TimeExpr;
|
|
157
|
+
};
|
|
158
|
+
export type HolidayName = 'black-friday' | 'earth-day' | 'st-patricks' | 'workers-day' | 'new-year' | 'new-year-eve' | 'valentines' | 'easter' | 'halloween' | 'thanksgiving' | 'christmas' | 'christmas-eve' | 'independence-day' | 'labor-day' | 'memorial-day' | 'mothers-day' | 'fathers-day';
|
|
159
|
+
export declare const HOLIDAY_NAMES: readonly HolidayName[];
|
|
160
|
+
/** Current IR schema version, embedded when serializing. */
|
|
161
|
+
export declare const IR_VERSION = 1;
|
|
162
|
+
export declare const UNITS: readonly Unit[];
|
|
163
|
+
export declare const WEEKDAYS: readonly Weekday[];
|
|
164
|
+
export declare const DAY_PERIODS: readonly DayPeriod[];
|
|
165
|
+
export {};
|
package/dist/ir/types.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nl2time intermediate representation (IR).
|
|
3
|
+
*
|
|
4
|
+
* A TimeExpr is a small, JSON-serializable operator tree over timeline
|
|
5
|
+
* intervals. Both directions of the library meet here: parsing produces a
|
|
6
|
+
* TimeExpr, and describe() selects a TimeExpr before rendering it. Resolution
|
|
7
|
+
* of a TimeExpr against a TimeContext is deterministic.
|
|
8
|
+
*
|
|
9
|
+
* The IR is language-neutral: the normative spec is schema/timeexpr.schema.json
|
|
10
|
+
* plus docs/ir-spec.md, and ports (e.g. Python) implement the same semantics.
|
|
11
|
+
*/
|
|
12
|
+
export const HOLIDAY_NAMES = [
|
|
13
|
+
'black-friday', 'earth-day', 'st-patricks', 'workers-day',
|
|
14
|
+
'new-year', 'new-year-eve', 'valentines', 'easter', 'halloween',
|
|
15
|
+
'thanksgiving', 'christmas', 'christmas-eve', 'independence-day',
|
|
16
|
+
'labor-day', 'memorial-day', 'mothers-day', 'fathers-day',
|
|
17
|
+
];
|
|
18
|
+
/** Current IR schema version, embedded when serializing. */
|
|
19
|
+
export const IR_VERSION = 1;
|
|
20
|
+
export const UNITS = [
|
|
21
|
+
'second',
|
|
22
|
+
'minute',
|
|
23
|
+
'hour',
|
|
24
|
+
'day',
|
|
25
|
+
'week',
|
|
26
|
+
'month',
|
|
27
|
+
'quarter',
|
|
28
|
+
'year',
|
|
29
|
+
];
|
|
30
|
+
export const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
31
|
+
export const DAY_PERIODS = ['morning', 'afternoon', 'evening', 'night'];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type TimeExpr } from './types.js';
|
|
2
|
+
export declare class IRValidationError extends Error {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
constructor(message: string, path: string);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Structural validation of untrusted input (LLM output, stored IR, network)
|
|
8
|
+
* into a typed TimeExpr. Mirrors schema/timeexpr.schema.json; the JSON Schema
|
|
9
|
+
* is the language-neutral spec, this is the fast in-process check.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateExpr(input: unknown, path?: string): TimeExpr;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { DAY_PERIODS, HOLIDAY_NAMES, UNITS, WEEKDAYS, } from './types.js';
|
|
2
|
+
export class IRValidationError extends Error {
|
|
3
|
+
path;
|
|
4
|
+
constructor(message, path) {
|
|
5
|
+
super(`${path}: ${message}`);
|
|
6
|
+
this.path = path;
|
|
7
|
+
this.name = 'IRValidationError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const MODS = ['approx', 'start', 'mid', 'end'];
|
|
11
|
+
const DIRS = ['next', 'prev', 'nearest'];
|
|
12
|
+
/**
|
|
13
|
+
* Structural validation of untrusted input (LLM output, stored IR, network)
|
|
14
|
+
* into a typed TimeExpr. Mirrors schema/timeexpr.schema.json; the JSON Schema
|
|
15
|
+
* is the language-neutral spec, this is the fast in-process check.
|
|
16
|
+
*/
|
|
17
|
+
export function validateExpr(input, path = '$') {
|
|
18
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
19
|
+
throw new IRValidationError('expected an object', path);
|
|
20
|
+
}
|
|
21
|
+
const obj = input;
|
|
22
|
+
if ('mod' in obj && obj.mod !== undefined && !MODS.includes(obj.mod)) {
|
|
23
|
+
throw new IRValidationError(`invalid mod ${JSON.stringify(obj.mod)}`, path);
|
|
24
|
+
}
|
|
25
|
+
const op = obj.op;
|
|
26
|
+
switch (op) {
|
|
27
|
+
case 'now':
|
|
28
|
+
return obj;
|
|
29
|
+
case 'literal': {
|
|
30
|
+
if (obj.date !== undefined)
|
|
31
|
+
validatePartialDate(obj.date, `${path}.date`);
|
|
32
|
+
if (obj.time !== undefined)
|
|
33
|
+
validatePartialTime(obj.time, `${path}.time`);
|
|
34
|
+
if (obj.dayPeriod !== undefined && !DAY_PERIODS.includes(obj.dayPeriod)) {
|
|
35
|
+
throw new IRValidationError(`invalid dayPeriod`, `${path}.dayPeriod`);
|
|
36
|
+
}
|
|
37
|
+
if (obj.date === undefined && obj.time === undefined && obj.dayPeriod === undefined) {
|
|
38
|
+
throw new IRValidationError('literal requires date, time, or dayPeriod', path);
|
|
39
|
+
}
|
|
40
|
+
return obj;
|
|
41
|
+
}
|
|
42
|
+
case 'offset': {
|
|
43
|
+
validateExpr(obj.base, `${path}.base`);
|
|
44
|
+
requireInt(obj.amount, `${path}.amount`);
|
|
45
|
+
requireUnit(obj.unit, `${path}.unit`);
|
|
46
|
+
return obj;
|
|
47
|
+
}
|
|
48
|
+
case 'snap': {
|
|
49
|
+
validateExpr(obj.base, `${path}.base`);
|
|
50
|
+
requireUnit(obj.unit, `${path}.unit`);
|
|
51
|
+
if (obj.edge !== undefined && obj.edge !== 'start' && obj.edge !== 'end') {
|
|
52
|
+
throw new IRValidationError('edge must be "start" or "end"', `${path}.edge`);
|
|
53
|
+
}
|
|
54
|
+
return obj;
|
|
55
|
+
}
|
|
56
|
+
case 'span': {
|
|
57
|
+
validateExpr(obj.anchor, `${path}.anchor`);
|
|
58
|
+
validateAmount(obj.amount, `${path}.amount`);
|
|
59
|
+
if (obj.business !== undefined && typeof obj.business !== 'boolean') {
|
|
60
|
+
throw new IRValidationError('business must be boolean', `${path}.business`);
|
|
61
|
+
}
|
|
62
|
+
return obj;
|
|
63
|
+
}
|
|
64
|
+
case 'between': {
|
|
65
|
+
validateExpr(obj.start, `${path}.start`);
|
|
66
|
+
validateExpr(obj.end, `${path}.end`);
|
|
67
|
+
return obj;
|
|
68
|
+
}
|
|
69
|
+
case 'seek': {
|
|
70
|
+
validateExpr(obj.base, `${path}.base`);
|
|
71
|
+
if (!DIRS.includes(obj.dir)) {
|
|
72
|
+
throw new IRValidationError('dir must be next|prev|nearest', `${path}.dir`);
|
|
73
|
+
}
|
|
74
|
+
validateTarget(obj.target, `${path}.target`);
|
|
75
|
+
if (obj.n !== undefined)
|
|
76
|
+
requireInt(obj.n, `${path}.n`);
|
|
77
|
+
return obj;
|
|
78
|
+
}
|
|
79
|
+
case 'intersect': {
|
|
80
|
+
if (!Array.isArray(obj.parts) || obj.parts.length < 2) {
|
|
81
|
+
throw new IRValidationError('intersect requires >= 2 parts', `${path}.parts`);
|
|
82
|
+
}
|
|
83
|
+
obj.parts.forEach((p, i) => validateExpr(p, `${path}.parts[${i}]`));
|
|
84
|
+
return obj;
|
|
85
|
+
}
|
|
86
|
+
case 'duration': {
|
|
87
|
+
if (typeof obj.iso !== 'string' || !/^-?P/.test(obj.iso)) {
|
|
88
|
+
throw new IRValidationError('duration requires an ISO-8601 string', `${path}.iso`);
|
|
89
|
+
}
|
|
90
|
+
return obj;
|
|
91
|
+
}
|
|
92
|
+
case 'amount': {
|
|
93
|
+
validateAmount(obj.amount, `${path}.amount`);
|
|
94
|
+
return obj;
|
|
95
|
+
}
|
|
96
|
+
case 'holiday': {
|
|
97
|
+
if (!HOLIDAY_NAMES.includes(obj.name)) {
|
|
98
|
+
throw new IRValidationError(`unknown holiday ${JSON.stringify(obj.name)}`, `${path}.name`);
|
|
99
|
+
}
|
|
100
|
+
if (obj.year !== undefined)
|
|
101
|
+
requireInt(obj.year, `${path}.year`);
|
|
102
|
+
if (obj.dir !== undefined && obj.dir !== 'prev' && obj.dir !== 'next') {
|
|
103
|
+
throw new IRValidationError('dir must be prev|next', `${path}.dir`);
|
|
104
|
+
}
|
|
105
|
+
return obj;
|
|
106
|
+
}
|
|
107
|
+
case 'recur': {
|
|
108
|
+
requireUnit(obj.every, `${path}.every`);
|
|
109
|
+
if (obj.filter !== undefined)
|
|
110
|
+
validateExpr(obj.filter, `${path}.filter`);
|
|
111
|
+
return obj;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
throw new IRValidationError(`unknown op ${JSON.stringify(op)}`, path);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function requireInt(v, path) {
|
|
118
|
+
if (typeof v !== 'number' || !Number.isInteger(v)) {
|
|
119
|
+
throw new IRValidationError('expected an integer', path);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function requireUnit(v, path) {
|
|
123
|
+
if (!UNITS.includes(v)) {
|
|
124
|
+
throw new IRValidationError(`invalid unit ${JSON.stringify(v)}`, path);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function validateTarget(v, path) {
|
|
128
|
+
if (typeof v !== 'object' || v === null) {
|
|
129
|
+
throw new IRValidationError('expected a target object', path);
|
|
130
|
+
}
|
|
131
|
+
const t = v;
|
|
132
|
+
switch (t.kind) {
|
|
133
|
+
case 'weekday':
|
|
134
|
+
if (!WEEKDAYS.includes(t.weekday)) {
|
|
135
|
+
throw new IRValidationError('invalid weekday', `${path}.weekday`);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
case 'month':
|
|
139
|
+
requireInt(t.month, `${path}.month`);
|
|
140
|
+
if (t.month < 1 || t.month > 12) {
|
|
141
|
+
throw new IRValidationError('month out of range', `${path}.month`);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
case 'dayPeriod':
|
|
145
|
+
if (!DAY_PERIODS.includes(t.period)) {
|
|
146
|
+
throw new IRValidationError('invalid dayPeriod', `${path}.period`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
case 'unit':
|
|
150
|
+
requireUnit(t.unit, `${path}.unit`);
|
|
151
|
+
return;
|
|
152
|
+
default:
|
|
153
|
+
throw new IRValidationError('target kind must be weekday|month|dayPeriod|unit', path);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const AMOUNT_FIELDS = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'];
|
|
157
|
+
function validateAmount(v, path) {
|
|
158
|
+
if (typeof v !== 'object' || v === null) {
|
|
159
|
+
throw new IRValidationError('expected a calendar amount object', path);
|
|
160
|
+
}
|
|
161
|
+
const keys = Object.keys(v);
|
|
162
|
+
if (keys.length === 0)
|
|
163
|
+
throw new IRValidationError('amount must have at least one field', path);
|
|
164
|
+
for (const k of keys) {
|
|
165
|
+
if (!AMOUNT_FIELDS.includes(k))
|
|
166
|
+
throw new IRValidationError(`unknown field "${k}"`, path);
|
|
167
|
+
requireInt(v[k], `${path}.${k}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function validatePartialDate(v, path) {
|
|
171
|
+
if (typeof v !== 'object' || v === null)
|
|
172
|
+
throw new IRValidationError('expected object', path);
|
|
173
|
+
const d = v;
|
|
174
|
+
for (const k of Object.keys(d)) {
|
|
175
|
+
if (!['year', 'month', 'day'].includes(k)) {
|
|
176
|
+
throw new IRValidationError(`unknown field "${k}"`, path);
|
|
177
|
+
}
|
|
178
|
+
requireInt(d[k], `${path}.${k}`);
|
|
179
|
+
}
|
|
180
|
+
if (typeof d.month === 'number' && (d.month < 1 || d.month > 12)) {
|
|
181
|
+
throw new IRValidationError('month out of range', `${path}.month`);
|
|
182
|
+
}
|
|
183
|
+
if (typeof d.day === 'number' && (d.day < 1 || d.day > 31)) {
|
|
184
|
+
throw new IRValidationError('day out of range', `${path}.day`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function validatePartialTime(v, path) {
|
|
188
|
+
if (typeof v !== 'object' || v === null)
|
|
189
|
+
throw new IRValidationError('expected object', path);
|
|
190
|
+
const t = v;
|
|
191
|
+
for (const k of Object.keys(t)) {
|
|
192
|
+
if (!['hour', 'minute', 'second', 'meridiem'].includes(k)) {
|
|
193
|
+
throw new IRValidationError(`unknown field "${k}"`, path);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (t.hour !== undefined)
|
|
197
|
+
requireInt(t.hour, `${path}.hour`);
|
|
198
|
+
if (t.minute !== undefined)
|
|
199
|
+
requireInt(t.minute, `${path}.minute`);
|
|
200
|
+
if (t.second !== undefined)
|
|
201
|
+
requireInt(t.second, `${path}.second`);
|
|
202
|
+
if (t.meridiem !== undefined && !['am', 'pm', 'unknown'].includes(t.meridiem)) {
|
|
203
|
+
throw new IRValidationError('meridiem must be am|pm|unknown', `${path}.meridiem`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TimeContext } from '../context.js';
|
|
2
|
+
import { type ParseResult } from '../parse/index.js';
|
|
3
|
+
/** The TimeExpr JSON Schema — pass to constrained decoding / tool definitions. */
|
|
4
|
+
export declare function irJsonSchema(): object;
|
|
5
|
+
export interface CtxSummary {
|
|
6
|
+
referenceInstant: string;
|
|
7
|
+
timeZone: string;
|
|
8
|
+
locale: string;
|
|
9
|
+
weekStart: string;
|
|
10
|
+
localDateTime: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function ctxSummary(ctx: TimeContext): CtxSummary;
|
|
13
|
+
/**
|
|
14
|
+
* A prompt for translating one natural-language expression into a TimeExpr.
|
|
15
|
+
* Inject alongside irJsonSchema() via your provider's structured-output
|
|
16
|
+
* mechanism.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildPrompt(text: string, ctx: TimeContext): string;
|
|
19
|
+
export type LLMFallback = (text: string, summary: CtxSummary) => Promise<unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* parse() with an LLM fallback: rules first; if no confident rule match, the
|
|
22
|
+
* fallback is asked for a TimeExpr, which is schema-validated before use.
|
|
23
|
+
* Invalid LLM output is dropped (the result just has no matches) — the engine
|
|
24
|
+
* never guesses.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseWithFallback(text: string, ctx: TimeContext, fallback: LLMFallback): Promise<ParseResult>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM adapter helpers. nl2time never makes network calls: the application
|
|
3
|
+
* supplies a fallback function (its own LLM client); this module supplies the
|
|
4
|
+
* IR JSON schema for constrained decoding, the prompt (which always carries
|
|
5
|
+
* the clock), and strict validation of whatever comes back. The LLM can only
|
|
6
|
+
* ever emit a TimeExpr — never a concrete date — so all arithmetic stays in
|
|
7
|
+
* the deterministic engine.
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { validateExpr } from '../ir/validate.js';
|
|
11
|
+
import { parse } from '../parse/index.js';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
/** The TimeExpr JSON Schema — pass to constrained decoding / tool definitions. */
|
|
14
|
+
export function irJsonSchema() {
|
|
15
|
+
return require('../../schema/timeexpr.schema.json');
|
|
16
|
+
}
|
|
17
|
+
export function ctxSummary(ctx) {
|
|
18
|
+
const z = ctx.zonedNow;
|
|
19
|
+
return {
|
|
20
|
+
referenceInstant: ctx.now.toString(),
|
|
21
|
+
timeZone: ctx.timeZone,
|
|
22
|
+
locale: ctx.locale,
|
|
23
|
+
weekStart: ctx.weekStart,
|
|
24
|
+
localDateTime: z.toPlainDateTime().toString(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A prompt for translating one natural-language expression into a TimeExpr.
|
|
29
|
+
* Inject alongside irJsonSchema() via your provider's structured-output
|
|
30
|
+
* mechanism.
|
|
31
|
+
*/
|
|
32
|
+
export function buildPrompt(text, ctx) {
|
|
33
|
+
const s = ctxSummary(ctx);
|
|
34
|
+
return [
|
|
35
|
+
'Translate the natural-language time expression into a TimeExpr JSON object',
|
|
36
|
+
'conforming to the provided schema. Emit ONLY the symbolic expression —',
|
|
37
|
+
'never compute concrete dates yourself; the deterministic engine resolves',
|
|
38
|
+
'the expression against the reference context.',
|
|
39
|
+
'',
|
|
40
|
+
`Reference context (for disambiguation only):`,
|
|
41
|
+
`- reference instant (UTC): ${s.referenceInstant}`,
|
|
42
|
+
`- user local date/time: ${s.localDateTime} (${s.timeZone})`,
|
|
43
|
+
`- locale: ${s.locale}, week starts on: ${s.weekStart}`,
|
|
44
|
+
'',
|
|
45
|
+
'Guidance:',
|
|
46
|
+
'- "last week" → {"op":"snap","base":{"op":"offset","base":{"op":"now"},"amount":-1,"unit":"week"},"unit":"week"}',
|
|
47
|
+
'- "the last 3 days" → {"op":"span","anchor":{"op":"now"},"amount":{"days":-3}}',
|
|
48
|
+
'- "next Tuesday at 4" → {"op":"intersect","parts":[{"op":"seek","base":{"op":"now"},"dir":"next","target":{"kind":"weekday","weekday":"tue"}},{"op":"literal","time":{"hour":4,"meridiem":"unknown"}}]}',
|
|
49
|
+
'- Ambiguous readings stay symbolic: keep meridiem "unknown", omit unstated date components.',
|
|
50
|
+
'',
|
|
51
|
+
`Expression: ${JSON.stringify(text)}`,
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* parse() with an LLM fallback: rules first; if no confident rule match, the
|
|
56
|
+
* fallback is asked for a TimeExpr, which is schema-validated before use.
|
|
57
|
+
* Invalid LLM output is dropped (the result just has no matches) — the engine
|
|
58
|
+
* never guesses.
|
|
59
|
+
*/
|
|
60
|
+
export async function parseWithFallback(text, ctx, fallback) {
|
|
61
|
+
const ruleResult = parse(text, ctx);
|
|
62
|
+
if (ruleResult.matches.length > 0)
|
|
63
|
+
return ruleResult;
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = await fallback(text, ctxSummary(ctx));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return ruleResult;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const expr = validateExpr(raw);
|
|
73
|
+
return {
|
|
74
|
+
matches: [
|
|
75
|
+
{
|
|
76
|
+
text,
|
|
77
|
+
start: 0,
|
|
78
|
+
end: text.length,
|
|
79
|
+
expr,
|
|
80
|
+
confidence: 0.6,
|
|
81
|
+
source: 'llm',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return ruleResult;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* English rule set. Each rule inspects the token stream at a position and, on
|
|
3
|
+
* match, emits a TimeExpr plus the number of tokens consumed. Rules are pure
|
|
4
|
+
* pattern → IR translators; all resolution semantics live in the engine.
|
|
5
|
+
*/
|
|
6
|
+
import type { TimeContext } from '../context.js';
|
|
7
|
+
import type { TimeExpr } from '../ir/types.js';
|
|
8
|
+
import type { Token } from './tokenizer.js';
|
|
9
|
+
export interface RuleMatch {
|
|
10
|
+
expr: TimeExpr;
|
|
11
|
+
consumed: number;
|
|
12
|
+
confidence: number;
|
|
13
|
+
/** Marks results that combine with a date ("at 4pm") or a time ("yesterday"). */
|
|
14
|
+
role: 'date' | 'time' | 'datetime' | 'duration';
|
|
15
|
+
}
|
|
16
|
+
export type Rule = (tokens: Token[], i: number, ctx: TimeContext) => RuleMatch | undefined;
|
|
17
|
+
/** Rules in priority order (first match at a position wins ties by length then order). */
|
|
18
|
+
export declare const EN_RULES: readonly Rule[];
|