Rename scheduled task to automations across code and UX

- Fix query, subject parameters passed to email template
- Show 12 hour scheduled time in automation created chat message
This commit is contained in:
Debanjum Singh Solanky 2024-04-29 20:41:07 +05:30
parent 230d160602
commit 2f9241b5a3
9 changed files with 128 additions and 134 deletions

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>Khoj AI - Task</title>
<title>Khoj AI - Automation</title>
</head>
<body>
<body style="font-family: 'Verdana', sans-serif; font-weight: 400; font-style: normal; padding: 0; text-align: left; width: 600px; margin: 20px auto;">
@ -13,7 +13,7 @@
<div>
<h1 style="color: #333; font-size: large; font-weight: bold; margin: 0; line-height: 1.5; background-color: #fee285; padding: 8px; box-shadow: 6px 6px rgba(0, 0, 0, 1.5);">Your Open, Personal AI</h1>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">Hey {{name}}! </p>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">I've shared your scheduled task results below:</p>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">I've shared your automation results below:</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; grid-gap: 12px; margin-top: 20px;">
<div style="border: 1px solid black; border-radius: 8px; padding: 8px; box-shadow: 6px 6px rgba(0, 0, 0, 1.0); margin-top: 20px;">
@ -23,8 +23,8 @@
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">{{result}}</p>
</div>
</div>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">The scheduled query I ran on your behalf: {query}</p>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">You can view, delete and manage your scheduled tasks via <a href="https://app.khoj.dev/configure#tasks">the settings page</a></p>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">The automation query I ran on your behalf: {{query}}</p>
<p style="color: #333; font-size: medium; margin-top: 20px; padding: 0; line-height: 1.5;">You can view, delete your automations via <a href="https://app.khoj.dev/configure#tasks">the settings page</a></p>
</div>
</div>
<p style="color: #333; font-size: large; margin-top: 20px; padding: 0; line-height: 1.5;">- Khoj</p>

View file

@ -272,17 +272,17 @@
{% endif %}
</div>
{% endif %}
<div id="tasks" class="section">
<h2 class="section-title">Scheduled Tasks</h2>
<div id="scheduled-tasks" class="api-settings">
<div id="automations" class="section">
<h2 class="section-title">Automations</h2>
<div id="automations" class="api-settings">
<div class="card-title-row">
<img class="card-icon" src="/static/assets/icons/key.svg" alt="Scheduled Tasks">
<h3 class="card-title">Tasks</h3>
<img class="card-icon" src="/static/assets/icons/key.svg" alt="Automations">
<h3 class="card-title">Automations</h3>
</div>
<div class="card-description-row">
<p id="tasks-settings-card-description" class="card-description">Manage your scheduled tasks</p>
<p id="tasks-settings-card-description" class="card-description">Manage your automations</p>
</div>
<table id="scheduled-tasks-table">
<table id="automations-table">
<thead>
<tr>
<th scope="col">Name</th>
@ -292,10 +292,10 @@
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="scheduled-tasks-list"></tbody>
<tbody id="automations-list"></tbody>
</table>
<div class="card-action-row">
<button class="card-button happy" id="create-scheduled-task" onclick="createScheduledTask()">
<button class="card-button happy" id="create-automation" onclick="createAutomation()">
Create Task
</button>
</div>
@ -661,46 +661,42 @@
// List user's API keys on page load
listApiKeys();
function deleteTask(taskId) {
const scheduledTaskList = document.getElementById("scheduled-tasks-list");
fetch(`/api/task?task_id=${taskId}`, {
function deleteAutomation(automationId) {
const AutomationList = document.getElementById("automations-list");
fetch(`/api/automation?automation_id=${automationId}`, {
method: 'DELETE',
})
.then(response => {
if (response.status == 200) {
const scheduledTaskItem = document.getElementById(`scheduled-task-item-${taskId}`);
scheduledTaskList.removeChild(scheduledTaskItem);
const AutomationItem = document.getElementById(`automation-item-${automationId}`);
AutomationList.removeChild(AutomationItem);
}
});
}
function generateTaskRow(taskObj) {
let taskId = taskObj.id;
let taskSchedulingRequest = taskObj.scheduling_request;
let taskQuery = taskObj.query_to_run;
let taskSubject = taskObj.subject;
let taskNextRun = `Next run at ${taskObj.next}`;
let taskSchedule = taskObj.schedule;
function generateAutomationRow(automationObj) {
let automationId = automationObj.id;
let automationNextRun = `Next run at ${automationObj.next}`;
return `
<tr id="scheduled-task-item-${taskId}">
<td><b>${taskSubject}</b></td>
<td><b>${taskSchedulingRequest}</b></td>
<td><b>${taskQuery}</b></td>
<td id="scheduled-task-${taskId}" title="${taskNextRun}">${taskSchedule}</td>
<tr id="automation-item-${automationId}">
<td><b>${automationObj.subject}</b></td>
<td><b>${automationObj.scheduling_request}</b></td>
<td><b>${automationObj.query_to_run}</b></td>
<td id="automation-${automationId}" title="${automationNextRun}">${automationObj.schedule}</td>
<td>
<img onclick="deleteTask('${taskId}')" class="configured-icon api-key-action enabled" src="/static/assets/icons/trash-solid.svg" alt="Delete Task" title="Delete Task">
<img onclick="deleteAutomation('${automationId}')" class="configured-icon api-key-action enabled" src="/static/assets/icons/trash-solid.svg" alt="Delete Automation" title="Delete Automation">
</td>
</tr>
`;
}
function listScheduledTasks() {
const scheduledTasksList = document.getElementById("scheduled-tasks-list");
fetch('/api/tasks')
function listAutomations() {
const AutomationsList = document.getElementById("automations-list");
fetch('/api/automations')
.then(response => response.json())
.then(tasks => {
if (!tasks?.length > 0) return;
scheduledTasksList.innerHTML = tasks.map(generateTaskRow).join("");
.then(automations => {
if (!automations?.length > 0) return;
AutomationsList.innerHTML = automations.map(generateAutomationRow).join("");
});
}
@ -713,8 +709,8 @@
});
}
// List user's scheduled tasks on page load
listScheduledTasks();
// List user's automations on page load
listAutomations();
function removeFile(path) {
fetch('/api/config/data/file?filename=' + path, {

View file

@ -507,7 +507,7 @@ Khoj:
""".strip()
)
# Schedule task
# Automations
# --
crontime_prompt = PromptTemplate.from_template(
"""
@ -525,7 +525,7 @@ AI: Here is one I found: "It's not denial. I'm just selective about the reality
User: Hahah, nice! Show a new one every morning.
Khoj: {{
"crontime": "0 9 * * *",
"query": "/task Share a funny Calvin and Hobbes or Bill Watterson quote from my notes",
"query": "/automated_task Share a funny Calvin and Hobbes or Bill Watterson quote from my notes",
"subject": "Your Calvin and Hobbes Quote for the Day"
}}
@ -534,7 +534,7 @@ Khoj: {{
User: Every monday evening at 6 share the top posts on hacker news from last week. Format it as a newsletter
Khoj: {{
"crontime": "0 18 * * 1",
"query": "/task Top posts last week on Hacker News",
"query": "/automated_task Top posts last week on Hacker News",
"subject": "Your Weekly Top Hacker News Posts Newsletter"
}}
@ -545,7 +545,7 @@ AI: The latest released Khoj python package version is 1.5.0.
User: Notify me when version 2.0.0 is released
Khoj: {{
"crontime": "0 10 * * *",
"query": "/task What is the latest released version of the Khoj python package?",
"query": "/automated_task What is the latest released version of the Khoj python package?",
"subject": "Khoj Python Package Version 2.0.0 Release"
}}
@ -554,7 +554,7 @@ Khoj: {{
User: Tell me the latest local tech news on the first sunday of every month
Khoj: {{
"crontime": "0 8 1-7 * 0",
"query": "/task Find the latest local tech, AI and engineering news. Format it as a newsletter.",
"query": "/automated_task Find the latest local tech, AI and engineering news. Format it as a newsletter.",
"subject": "Your Monthly Dose of Local Tech News"
}}
@ -563,7 +563,7 @@ Khoj: {{
User: Inform me when the national election results are declared. Run task at 4pm every thursday.
Khoj: {{
"crontime": "0 16 * * 4",
"query": "/task Check if the Indian national election results are officially declared",
"query": "/automated_task Check if the Indian national election results are officially declared",
"subject": "Indian National Election Results Declared"
}}

View file

@ -102,7 +102,7 @@ def save_to_conversation_log(
intent_type: str = "remember",
client_application: ClientApplication = None,
conversation_id: int = None,
job_id: str = None,
automation_id: str = None,
):
user_message_time = user_message_time or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_conversation = message_to_log(
@ -113,7 +113,7 @@ def save_to_conversation_log(
"context": compiled_references,
"intent": {"inferred-queries": inferred_queries, "type": intent_type},
"onlineContext": online_results,
"jobId": job_id,
"automationId": automation_id,
},
conversation_log=meta_log.get("chat", []),
)

View file

@ -391,59 +391,59 @@ def user_info(request: Request) -> Response:
return Response(content=json.dumps(user_info), media_type="application/json", status_code=200)
@api.get("/tasks", response_class=Response)
@api.get("/automations", response_class=Response)
@requires(["authenticated"])
def get_jobs(request: Request) -> Response:
def get_automations(request: Request) -> Response:
user: KhojUser = request.user.object
tasks: list[Job] = state.scheduler.get_jobs()
automations: list[Job] = state.scheduler.get_jobs()
# Collate all tasks assigned by user that are still active
tasks_info = []
for task in tasks:
if task.id.startswith(f"job_{user.uuid}_"):
task_metadata = json.loads(task.name)
schedule = (
f'{cron_descriptor.get_description(task_metadata["crontime"])} {task.next_run_time.strftime("%Z")}'
)
tasks_info.append(
# Collate all automations created by user that are still active
automations_info = []
for automation in automations:
if automation.id.startswith(f"automation_{user.uuid}_"):
automation_metadata = json.loads(automation.name)
crontime = automation_metadata["crontime"]
timezone = automation.next_run_time.strftime("%Z")
schedule = f"{cron_descriptor.get_description(crontime)} {timezone}"
automations_info.append(
{
"id": task.id,
"subject": task_metadata["subject"],
"query_to_run": re.sub(r"^/task\s*", "", task_metadata["query_to_run"]),
"scheduling_request": task_metadata["scheduling_request"],
"id": automation.id,
"subject": automation_metadata["subject"],
"query_to_run": re.sub(r"^/automated_task\s*", "", automation_metadata["query_to_run"]),
"scheduling_request": automation_metadata["scheduling_request"],
"schedule": schedule,
"next": task.next_run_time.strftime("%Y-%m-%d %I:%M %p %Z"),
"next": automation.next_run_time.strftime("%Y-%m-%d %I:%M %p %Z"),
}
)
# Return tasks information as a JSON response
return Response(content=json.dumps(tasks_info), media_type="application/json", status_code=200)
return Response(content=json.dumps(automations_info), media_type="application/json", status_code=200)
@api.delete("/task", response_class=Response)
@api.delete("/automation", response_class=Response)
@requires(["authenticated"])
def delete_job(request: Request, task_id: str) -> Response:
def delete_automation(request: Request, automation_id: str) -> Response:
user: KhojUser = request.user.object
# Perform validation checks
# Check if user is allowed to delete this task id
if not task_id.startswith(f"job_{user.uuid}_"):
# Check if user is allowed to delete this automation id
if not automation_id.startswith(f"automation_{user.uuid}_"):
return Response(content="Unauthorized job deletion request", status_code=403)
# Check if task with this task id exist
task: Job = state.scheduler.get_job(job_id=task_id)
if not task:
# Check if automation with this id exist
automation: Job = state.scheduler.get_job(job_id=automation_id)
if not automation:
return Response(content="Invalid job", status_code=403)
# Collate info about user task to be deleted
task_metadata = json.loads(task.name)
task_info = {
"id": task.id,
"name": task_metadata["inferred_query"],
"next": task.next_run_time.strftime("%Y-%m-%d %H:%MS"),
automation_metadata = json.loads(automation.name)
automation_info = {
"id": automation.id,
"name": automation_metadata["query_to_run"],
"next": automation.next_run_time.strftime("%Y-%m-%d %I:%M %p %Z"),
}
# Delete job
task.remove()
automation.remove()
# Return delete task information as a JSON response
return Response(content=json.dumps(task_info), media_type="application/json", status_code=200)
# Return deleted automation information as a JSON response
return Response(content=json.dumps(automation_info), media_type="application/json", status_code=200)

View file

@ -37,7 +37,7 @@ from khoj.routers.helpers import (
agenerate_chat_response,
aget_relevant_information_sources,
aget_relevant_output_modes,
create_scheduled_task,
create_automation,
get_conversation_command,
is_ready_to_chat,
text_to_image,
@ -217,6 +217,7 @@ async def chat_options(
) -> Response:
cmd_options = {}
for cmd in ConversationCommand:
if cmd in command_descriptions:
cmd_options[cmd.value] = command_descriptions[cmd]
update_telemetry_state(
@ -373,14 +374,14 @@ async def websocket_endpoint(
continue
meta_log = conversation.conversation_log
is_task = conversation_commands == [ConversationCommand.Task]
is_automated_task = conversation_commands == [ConversationCommand.AutomatedTask]
if conversation_commands == [ConversationCommand.Default] or is_task:
if conversation_commands == [ConversationCommand.Default] or is_automated_task:
conversation_commands = await aget_relevant_information_sources(q, meta_log)
conversation_commands_str = ", ".join([cmd.value for cmd in conversation_commands])
await send_status_update(f"**🗃️ Chose Data Sources to Search:** {conversation_commands_str}")
mode = await aget_relevant_output_modes(q, meta_log, is_task)
mode = await aget_relevant_output_modes(q, meta_log, is_automated_task)
await send_status_update(f"**🧑🏾‍💻 Decided Response Mode:** {mode.value}")
if mode not in conversation_commands:
conversation_commands.append(mode)
@ -389,29 +390,31 @@ async def websocket_endpoint(
await conversation_command_rate_limiter.update_and_check_if_valid(websocket, cmd)
q = q.replace(f"/{cmd.value}", "").strip()
if ConversationCommand.Reminder in conversation_commands:
if ConversationCommand.Automation in conversation_commands:
try:
job, crontime, inferred_query, subject = await create_scheduled_task(
automation, crontime, query_to_run, subject = await create_automation(
q, location, timezone, user, websocket.url, meta_log
)
except Exception as e:
logger.error(f"Error scheduling task {q} for {user.email}: {e}")
await send_complete_llm_response(f"Unable to schedule task. Ensure the task doesn't already exist.")
await send_complete_llm_response(
f"Unable to create automation. Ensure the automation doesn't already exist."
)
continue
# Display next run time in user timezone instead of UTC
schedule = f'{cron_descriptor.get_description(crontime)} {job.next_run_time.strftime("%Z")}'
next_run_time = job.next_run_time.strftime("%Y-%m-%d %H:%M %Z")
# Remove /task prefix from inferred_query
unprefixed_inferred_query = re.sub(r"^\/task\s*", "", inferred_query)
# Create the scheduled task response
schedule = f'{cron_descriptor.get_description(crontime)} {automation.next_run_time.strftime("%Z")}'
next_run_time = automation.next_run_time.strftime("%Y-%m-%d %I:%M %p %Z")
# Remove /automated_task prefix from inferred_query
unprefixed_query_to_run = re.sub(r"^\/automated_task\s*", "", query_to_run)
# Create the automation response
llm_response = f"""
### 🕒 Scheduled Task
### 🕒 Automation
- Subject: **{subject}**
- Query: "{unprefixed_inferred_query}"
- Query to Run: "{unprefixed_query_to_run}"
- Schedule: `{schedule}`
- Next Run At: {next_run_time}
Manage your tasks [here](/config#tasks).
Manage your tasks [here](/config#automations).
""".strip()
await sync_to_async(save_to_conversation_log)(
@ -420,11 +423,11 @@ Manage your tasks [here](/config#tasks).
user,
meta_log,
user_message_time,
intent_type="reminder",
intent_type="automation",
client_application=websocket.user.client_app,
conversation_id=conversation_id,
inferred_queries=[inferred_query],
job_id=job.id,
inferred_queries=[query_to_run],
automation_id=automation.id,
)
common = CommonQueryParamsClass(
client=websocket.user.client_app,
@ -621,7 +624,7 @@ async def chat(
else:
meta_log = conversation.conversation_log
is_task = conversation_commands == [ConversationCommand.Task]
is_task = conversation_commands == [ConversationCommand.AutomatedTask]
if conversation_commands == [ConversationCommand.Default] or is_task:
conversation_commands = await aget_relevant_information_sources(q, meta_log)
@ -640,32 +643,32 @@ async def chat(
user_name = await aget_user_name(user)
if ConversationCommand.Reminder in conversation_commands:
if ConversationCommand.Automation in conversation_commands:
try:
job, crontime, inferred_query, subject = await create_scheduled_task(
automation, crontime, query_to_run, subject = await create_automation(
q, location, timezone, user, request.url, meta_log
)
except Exception as e:
logger.error(f"Error scheduling task {q} for {user.email}: {e}")
logger.error(f"Error creating automation {q} for {user.email}: {e}")
return Response(
content=f"Unable to schedule task. Ensure the task doesn't already exist.",
content=f"Unable to create automation. Ensure the automation doesn't already exist.",
media_type="text/plain",
status_code=500,
)
# Display next run time in user timezone instead of UTC
schedule = f'{cron_descriptor.get_description(crontime)} {job.next_run_time.strftime("%Z")}'
next_run_time = job.next_run_time.strftime("%Y-%m-%d %H:%M %Z")
# Remove /task prefix from inferred_query
unprefixed_inferred_query = re.sub(r"^\/task\s*", "", inferred_query)
# Create the scheduled task response
schedule = f'{cron_descriptor.get_description(crontime)} {automation.next_run_time.strftime("%Z")}'
next_run_time = automation.next_run_time.strftime("%Y-%m-%d %I:%M %p %Z")
# Remove /automated_task prefix from inferred_query
unprefixed_query_to_run = re.sub(r"^\/automated_task\s*", "", query_to_run)
# Create the Automation response
llm_response = f"""
### 🕒 Scheduled Task
### 🕒 Automation
- Subject: **{subject}**
- Query: "{unprefixed_inferred_query}"
- Query to Run: "{unprefixed_query_to_run}"
- Schedule: `{schedule}`
- Next Run At: {next_run_time}
Manage your tasks [here](/config#tasks).
Manage your automations [here](/config#automations).
""".strip()
await sync_to_async(save_to_conversation_log)(
@ -674,11 +677,11 @@ Manage your tasks [here](/config#tasks).
user,
meta_log,
user_message_time,
intent_type="reminder",
intent_type="automation",
client_application=request.user.client_app,
conversation_id=conversation_id,
inferred_queries=[inferred_query],
job_id=job.id,
inferred_queries=[query_to_run],
automation_id=automation.id,
)
if stream:

View file

@ -58,7 +58,7 @@ def send_task_email(name, email, query, result, subject):
template = env.get_template("task.html")
html_result = markdown_it.MarkdownIt().render(result)
html_content = template.render(name=name, query=query, result=html_result)
html_content = template.render(name=name, subject=subject, query=query, result=html_result)
r = resend.Emails.send(
{

View file

@ -170,8 +170,8 @@ def get_conversation_command(query: str, any_references: bool = False) -> Conver
return ConversationCommand.Online
elif query.startswith("/image"):
return ConversationCommand.Image
elif query.startswith("/task"):
return ConversationCommand.Task
elif query.startswith("/automated_task"):
return ConversationCommand.AutomatedTask
# If no relevant notes found for the given query
elif not any_references:
return ConversationCommand.General
@ -239,7 +239,7 @@ async def aget_relevant_output_modes(query: str, conversation_history: dict, is_
for mode, description in mode_descriptions_for_llm.items():
# Do not allow tasks to schedule another task
if is_task and mode == ConversationCommand.Reminder:
if is_task and mode == ConversationCommand.Automation:
continue
mode_options[mode.value] = description
mode_options_str += f'- "{mode.value}": "{description}"\n'
@ -857,18 +857,14 @@ def should_notify(original_query: str, executed_query: str, ai_response: str) ->
response=ai_response,
)
with timer("Chat actor: Decide to notify user of AI response", logger):
with timer("Chat actor: Decide to notify user of automation response", logger):
try:
response = send_message_to_model_wrapper_sync(to_notify_or_not)
should_notify_result = "no" not in response.lower()
logger.info(
f'Decided to {"not " if not should_notify_result else ""}notify user of scheduled task response.'
)
logger.info(f'Decided to {"not " if not should_notify_result else ""}notify user of automation response.')
return should_notify_result
except:
logger.warning(
f"Fallback to notify user of scheduled task response as failed to infer should notify or not."
)
logger.warning(f"Fallback to notify user of automation response as failed to infer should notify or not.")
return True
@ -904,7 +900,7 @@ def scheduled_chat(query_to_run: str, scheduling_request: str, subject: str, use
return None
# Extract the AI response from the chat API response
cleaned_query = re.sub(r"^/task\s*", "", query_to_run).strip()
cleaned_query = re.sub(r"^/automated_task\s*", "", query_to_run).strip()
if raw_response.headers.get("Content-Type") == "application/json":
response_map = raw_response.json()
ai_response = response_map.get("response") or response_map.get("image")
@ -919,7 +915,7 @@ def scheduled_chat(query_to_run: str, scheduling_request: str, subject: str, use
return raw_response
async def create_scheduled_task(
async def create_automation(
q: str, location: LocationData, timezone: str, user: KhojUser, calling_url: URL, meta_log: dict = {}
):
user_timezone = pytz.timezone(timezone)
@ -930,7 +926,7 @@ async def create_scheduled_task(
{"query_to_run": query_to_run, "scheduling_request": q, "subject": subject, "crontime": crontime_string}
)
query_id = hashlib.md5(f"{query_to_run}".encode("utf-8")).hexdigest()
job_id = f"job_{user.uuid}_{crontime_string}_{query_id}"
job_id = f"automation_{user.uuid}_{crontime_string}_{query_id}"
job = state.scheduler.add_job(
run_with_process_lock,
trigger=trigger,

View file

@ -304,8 +304,8 @@ class ConversationCommand(str, Enum):
Online = "online"
Webpage = "webpage"
Image = "image"
Reminder = "reminder"
Task = "task"
Automation = "automation"
AutomatedTask = "automated_task"
command_descriptions = {
@ -315,8 +315,7 @@ command_descriptions = {
ConversationCommand.Online: "Search for information on the internet.",
ConversationCommand.Webpage: "Get information from webpage links provided by you.",
ConversationCommand.Image: "Generate images by describing your imagination in words.",
ConversationCommand.Reminder: "Schedule your query to run at a specified time or interval.",
ConversationCommand.Task: "Scheduled task running at previously specified schedule.",
ConversationCommand.Automation: "Automatically run your query at a specified time or interval.",
ConversationCommand.Help: "Display a help message with all available commands and other metadata.",
}
@ -330,7 +329,7 @@ tool_descriptions_for_llm = {
mode_descriptions_for_llm = {
ConversationCommand.Image: "Use this if the user is requesting an image or visual response to their query.",
ConversationCommand.Reminder: "Use this if the user is requesting a response at a scheduled date or time.",
ConversationCommand.Automation: "Use this if the user is requesting a response at a scheduled date or time.",
ConversationCommand.Default: "Use this if the other response modes don't seem to fit the query.",
}