P11-01: Create Time Parser Service

- Implement TimeParserService that converts natural language time expressions to cron expressions
- Uses OllamaAdapter and ModelRouter to leverage LLM capabilities
- Validates cron expressions using node-cron's validate() function
- Handles both one-time and recurring reminders
- Includes comprehensive error handling and JSON parsing
- Exports both class-based and convenience function interfaces

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
larchanka
2026-02-17 13:11:39 +01:00
committed by Mikhail Larchanka
parent 50c7525982
commit 76a5072f8d
2 changed files with 271 additions and 0 deletions

View File

@@ -2,8 +2,125 @@
## To Do
### P11-02 Add Time Parser Tests
- tags: [todo, reminder-system, phase-1, testing]
- defaultExpanded: false
```md
Create unit tests for the time parser service.
Source: P11-02_TIME_PARSER_TESTS.md
```
### P11-03 Update Cron Manager Event Payload
- tags: [todo, reminder-system, phase-1]
- defaultExpanded: false
```md
Modify CronManager.runJob() to emit structured reminder data in event.cron.completed.
Source: P11-03_CRON_EVENT_PAYLOAD.md
```
### P11-04 Add Cron Manager Integration Tests
- tags: [todo, reminder-system, phase-1, testing]
- defaultExpanded: false
```md
Create integration tests for cron manager reminder functionality.
Source: P11-04_CRON_INTEGRATION_TESTS.md
```
### P11-05 Handle Cron Events in Orchestrator
- tags: [todo, reminder-system, phase-2]
- defaultExpanded: false
```md
Add handler for event.cron.completed to route reminders to Telegram.
Source: P11-05_ORCHESTRATOR_CRON_HANDLER.md
```
### P11-06 Update Planner Prompt with Reminder Capability
- tags: [todo, reminder-system, phase-3]
- defaultExpanded: false
```md
Add cron-manager service and schedule_reminder capability to planner prompt.
Source: P11-06_PLANNER_REMINDER_CAPABILITY.md
```
### P11-07 Add Planner Example for Reminders
- tags: [todo, reminder-system, phase-3]
- defaultExpanded: false
```md
Add few-shot example showing how to plan a reminder request.
Source: P11-07_PLANNER_REMINDER_EXAMPLE.md
```
### P11-08 Add Schedule Reminder Handler to Executor
- tags: [todo, reminder-system, phase-4]
- defaultExpanded: false
```md
Add handler for schedule_reminder node type in the executor.
Source: P11-08_EXECUTOR_REMINDER_HANDLER.md
```
### P11-09 Add List Reminders Command
- tags: [todo, reminder-system, phase-5]
- defaultExpanded: false
```md
Add /reminders command to list active reminders for the user.
Source: P11-09_LIST_REMINDERS_COMMAND.md
```
### P11-10 Add Cancel Reminder Command
- tags: [todo, reminder-system, phase-5]
- defaultExpanded: false
```md
Add /cancel_reminder command to remove a specific reminder.
Source: P11-10_CANCEL_REMINDER_COMMAND.md
```
### P11-11 Update Help Command
- tags: [todo, reminder-system, phase-5]
- defaultExpanded: false
```md
Update /help command to document reminder functionality.
Source: P11-11_UPDATE_HELP_COMMAND.md
```
### P11-12 Manual End-to-End Testing
- tags: [todo, reminder-system, phase-6, testing]
- defaultExpanded: false
```md
Perform manual testing of the complete reminder flow.
Source: P11-12_E2E_TESTING.md
```
### P11-13 Update README
- tags: [todo, reminder-system, phase-6, documentation]
- defaultExpanded: false
```md
Document the reminder feature in the README.
Source: P11-13_UPDATE_README.md
```
## In Progress
### P11-01 Create Time Parser Service
- tags: [in-progress, reminder-system, phase-1]
- defaultExpanded: false
```md
Create a service that converts natural language time expressions into cron expressions using the LLM.
Source: P11-01_TIME_PARSER_SERVICE.md
```
## Done
### P10-06 Model Selection Verification
- tags: [done]

154
src/services/time-parser.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* Time Parser Service: converts natural language time expressions into cron expressions using the LLM.
* P11-01: _board/TASKS/P11-01_TIME_PARSER_SERVICE.md
*/
import cron from "node-cron";
import { OllamaAdapter } from "./ollama-adapter.js";
import { ModelRouter } from "./model-router.js";
const SYSTEM_PROMPT = `You are a time expression parser. Your task is to convert natural language time expressions into cron expressions.
Rules:
1. For recurring reminders (e.g., "every day at 9am", "every Monday", "every week"), output a standard cron expression.
2. For one-time reminders (e.g., "in 5 minutes", "tomorrow at 3pm", "next Monday at 9am"), calculate the exact date and time, then output a cron expression for that specific time.
3. Cron format: "minute hour day month dayOfWeek"
- minute: 0-59
- hour: 0-23 (24-hour format)
- day: 1-31
- month: 1-12
- dayOfWeek: 0-7 (0 or 7 = Sunday, 1 = Monday, ..., 6 = Saturday)
4. Use * for "every" values (e.g., "every day" = "* * * * *")
5. For one-time reminders, use specific values for all fields (e.g., "15 14 17 2 *" = Feb 17 at 2:15 PM)
Output your response as a JSON object with this exact format:
{
"cronExpr": "<cron expression>",
"isRecurring": <true or false>,
"description": "<human-readable description of when this will trigger>"
}
Examples:
- Input: "in 5 minutes" (assuming current time is 14:30)
Output: {"cronExpr": "35 14 <day> <month> *", "isRecurring": false, "description": "In 5 minutes (at 14:35)"}
- Input: "every day at 9am"
Output: {"cronExpr": "0 9 * * *", "isRecurring": true, "description": "Every day at 9:00 AM"}
- Input: "every Monday at 2pm"
Output: {"cronExpr": "0 14 * * 1", "isRecurring": true, "description": "Every Monday at 2:00 PM"}
- Input: "tomorrow at 3pm" (assuming today is Feb 17)
Output: {"cronExpr": "0 15 18 2 *", "isRecurring": false, "description": "Tomorrow at 3:00 PM (Feb 18)"}
Note: For one-time reminders, calculate the exact date and time based on the current date/time provided in the user message. Replace <day> and <month> placeholders with actual numeric values.
Important: Always output valid JSON. The cron expression must be valid according to node-cron format.`;
export interface ParseTimeExpressionResult {
cronExpr: string;
isRecurring: boolean;
description: string;
}
export class TimeParserService {
private readonly ollama: OllamaAdapter;
private readonly modelRouter: ModelRouter;
constructor(options?: { ollama?: OllamaAdapter; modelRouter?: ModelRouter }) {
this.ollama = options?.ollama ?? new OllamaAdapter();
this.modelRouter = options?.modelRouter ?? new ModelRouter();
}
/**
* Parse a natural language time expression into a cron expression.
* @param input Natural language time expression (e.g., "in 5 minutes", "every day at 9am")
* @returns Promise resolving to cron expression, recurrence flag, and description
* @throws Error if the input cannot be parsed or the generated cron expression is invalid
*/
async parseTimeExpression(input: string): Promise<ParseTimeExpressionResult> {
if (!input || typeof input !== "string" || input.trim().length === 0) {
throw new Error("Invalid input: time expression must be a non-empty string");
}
const prompt = `Parse this time expression into a cron expression: "${input.trim()}"\n\nCurrent date and time: ${new Date().toISOString()}`;
try {
// Use "small" model for this task as it's relatively straightforward
const model = this.modelRouter.getModel("small");
const result = await this.ollama.chat(
[
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: prompt },
],
model,
);
const responseText = (result.message.content || "").trim();
if (!responseText) {
throw new Error("LLM returned empty response");
}
// Try to extract JSON from the response (may be wrapped in markdown code blocks)
let jsonText = responseText;
const jsonMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
if (jsonMatch && jsonMatch[1]) {
jsonText = jsonMatch[1];
} else {
// Try to find JSON object in the response
const braceMatch = responseText.match(/\{[\s\S]*\}/);
if (braceMatch && braceMatch[0]) {
jsonText = braceMatch[0];
}
}
let parsed: ParseTimeExpressionResult;
try {
parsed = JSON.parse(jsonText) as ParseTimeExpressionResult;
} catch (parseError) {
throw new Error(
`Failed to parse LLM response as JSON. Response: ${responseText.substring(0, 200)}`,
);
}
// Validate required fields
if (typeof parsed.cronExpr !== "string") {
throw new Error(`Invalid response: missing or invalid cronExpr field`);
}
if (typeof parsed.isRecurring !== "boolean") {
throw new Error(`Invalid response: missing or invalid isRecurring field`);
}
if (typeof parsed.description !== "string") {
throw new Error(`Invalid response: missing or invalid description field`);
}
// Validate cron expression using node-cron
const isValid = cron.validate(parsed.cronExpr);
if (!isValid) {
throw new Error(
`Generated cron expression is invalid: "${parsed.cronExpr}". LLM response: ${responseText.substring(0, 200)}`,
);
}
return parsed;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("Invalid") || message.includes("Failed to parse")) {
throw new Error(`Failed to parse time expression "${input}": ${message}`);
}
// Re-throw network/timeout errors as-is
throw err;
}
}
}
/**
* Convenience function to parse a time expression.
* Creates a new TimeParserService instance and calls parseTimeExpression.
*/
export async function parseTimeExpression(
input: string,
): Promise<ParseTimeExpressionResult> {
const parser = new TimeParserService();
return parser.parseTimeExpression(input);
}