diff --git a/_board/_BOARD.md b/_board/_BOARD.md index 5dbc81e..0bc1c38 100644 --- a/_board/_BOARD.md +++ b/_board/_BOARD.md @@ -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] diff --git a/src/services/time-parser.ts b/src/services/time-parser.ts new file mode 100644 index 0000000..d184673 --- /dev/null +++ b/src/services/time-parser.ts @@ -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": "", + "isRecurring": , + "description": "" +} + +Examples: +- Input: "in 5 minutes" (assuming current time is 14:30) + Output: {"cronExpr": "35 14 *", "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 and 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 { + 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 { + const parser = new TimeParserService(); + return parser.parseTimeExpression(input); +}