Improve Automation Flexibility and Automation Email Format (#1016)

- Format AI response to send in automation email
- Let Khoj infer chat query based on user automation query
- Decide if automation emails should be sent based on response
  - Fix the `to_notify_or_not` decider AI
  - Ask reason before decision to improve to_notify decider AI
- Show error message on web app when fail to create/update automation
This commit is contained in:
Debanjum
2024-12-27 01:36:38 -08:00
committed by GitHub
6 changed files with 275 additions and 97 deletions

View File

@@ -167,68 +167,68 @@ const timestamp = Date.now();
const suggestedAutomationsMetadata: AutomationsData[] = [
{
subject: "Weekly Newsletter",
query_to_run:
scheduling_request:
"/research Compile a message including: 1. A recap of news from last week 2. An at-home workout I can do before work 3. A quote to inspire me for the week ahead",
schedule: "9AM every Monday",
next: "Next run at 9AM on Monday",
crontime: "0 9 * * 1",
id: timestamp,
scheduling_request: "",
},
{
subject: "Daily Bedtime Story",
query_to_run:
"Compose a bedtime story that a five-year-old might enjoy. It should not exceed five paragraphs. Appeal to the imagination, but weave in learnings.",
schedule: "9PM every night",
next: "Next run at 9PM today",
crontime: "0 21 * * *",
id: timestamp + 1,
scheduling_request: "",
query_to_run: "",
},
{
subject: "Front Page of Hacker News",
query_to_run:
scheduling_request:
"/research Summarize the top 5 posts from https://news.ycombinator.com/best and share them with me, including links",
schedule: "9PM on every Wednesday",
next: "Next run at 9PM on Wednesday",
crontime: "0 21 * * 3",
id: timestamp + 2,
scheduling_request: "",
query_to_run: "",
},
{
subject: "Market Summary",
query_to_run:
scheduling_request:
"/research Get the market summary for today and share it with me. Focus on tech stocks and the S&P 500.",
schedule: "9AM on every weekday",
next: "Next run at 9AM on Monday",
crontime: "0 9 * * *",
id: timestamp + 3,
scheduling_request: "",
query_to_run: "",
},
{
subject: "Market Crash Notification",
query_to_run: "Notify me if the stock market fell by more than 5% today.",
scheduling_request: "Notify me if the stock market fell by more than 5% today.",
schedule: "5PM every evening",
next: "Next run at 5PM today",
crontime: "0 17 * * *",
id: timestamp + 5,
scheduling_request: "",
query_to_run: "",
},
{
subject: "Round-up of research papers about AI in healthcare",
query_to_run:
scheduling_request:
"/research Summarize the top 3 research papers about AI in healthcare that were published in the last week. Include links to the full papers.",
schedule: "9AM every Friday",
next: "Next run at 9AM on Friday",
crontime: "0 9 * * 5",
id: timestamp + 4,
scheduling_request: "",
query_to_run: "",
},
{
subject: "Daily Bedtime Story",
scheduling_request:
"Compose a bedtime story that a five-year-old might enjoy. It should not exceed five paragraphs. Appeal to the imagination, but weave in learnings.",
schedule: "9PM every night",
next: "Next run at 9PM today",
crontime: "0 21 * * *",
id: timestamp + 1,
query_to_run: "",
},
];
function createShareLink(automation: AutomationsData) {
const encodedSubject = encodeURIComponent(automation.subject);
const encodedQuery = encodeURIComponent(automation.query_to_run);
const encodedQuery = encodeURIComponent(automation.scheduling_request);
const encodedCrontime = encodeURIComponent(automation.crontime);
const shareLink = `${window.location.origin}/automations?subject=${encodedSubject}&query=${encodedQuery}&crontime=${encodedCrontime}`;
@@ -269,6 +269,7 @@ interface AutomationsCardProps {
isLoggedIn: boolean;
setShowLoginPrompt: (showLoginPrompt: boolean) => void;
authenticatedData: UserProfile | null;
setToastMessage: (toastMessage: string) => void;
}
function AutomationsCard(props: AutomationsCardProps) {
@@ -277,8 +278,6 @@ function AutomationsCard(props: AutomationsCardProps) {
null,
);
const [isDeleted, setIsDeleted] = useState(false);
const [toastMessage, setToastMessage] = useState("");
const { toast } = useToast();
const automation = props.automation;
@@ -306,18 +305,6 @@ function AutomationsCard(props: AutomationsCardProps) {
}
}, [updatedAutomationData, automation]);
useEffect(() => {
const toastTitle = `Automation: ${updatedAutomationData?.subject || automation.subject}`;
if (toastMessage) {
toast({
title: toastTitle,
description: toastMessage,
action: <ToastAction altText="Dismiss">Ok</ToastAction>,
});
setToastMessage("");
}
}, [toastMessage, updatedAutomationData, automation, toast]);
if (isDeleted) {
return null;
}
@@ -346,6 +333,7 @@ function AutomationsCard(props: AutomationsCardProps) {
isCreating={isEditing}
automation={updatedAutomationData || automation}
ipLocationData={props.locationData}
setToastMessage={props.setToastMessage}
/>
)}
<ShareLink
@@ -365,7 +353,10 @@ function AutomationsCard(props: AutomationsCardProps) {
variant={"outline"}
className="justify-start"
onClick={() => {
sendAPreview(automation.id.toString(), setToastMessage);
sendAPreview(
automation.id.toString(),
props.setToastMessage,
);
}}
>
<Play className="h-4 w-4 mr-2" />
@@ -391,7 +382,7 @@ function AutomationsCard(props: AutomationsCardProps) {
</CardTitle>
</CardHeader>
<CardContent className="text-secondary-foreground break-all">
{updatedAutomationData?.query_to_run || automation.query_to_run}
{updatedAutomationData?.scheduling_request || automation.scheduling_request}
</CardContent>
<CardFooter className="flex flex-col items-start md:flex-row md:justify-between md:items-center gap-2">
<div className="flex gap-2">
@@ -420,6 +411,7 @@ function AutomationsCard(props: AutomationsCardProps) {
isCreating={isEditing}
automation={automation}
ipLocationData={props.locationData}
setToastMessage={props.setToastMessage}
/>
)}
</CardFooter>
@@ -434,6 +426,7 @@ interface SharedAutomationCardProps {
setShowLoginPrompt: (showLoginPrompt: boolean) => void;
authenticatedData: UserProfile | null;
isMobileWidth: boolean;
setToastMessage: (toastMessage: string) => void;
}
function SharedAutomationCard(props: SharedAutomationCardProps) {
@@ -451,8 +444,8 @@ function SharedAutomationCard(props: SharedAutomationCardProps) {
const automation: AutomationsData = {
id: 0,
subject: decodeURIComponent(subject),
query_to_run: decodeURIComponent(query),
scheduling_request: "",
scheduling_request: decodeURIComponent(query),
query_to_run: "",
schedule: cronToHumanReadableString(decodeURIComponent(crontime)),
crontime: decodeURIComponent(crontime),
next: "",
@@ -470,6 +463,7 @@ function SharedAutomationCard(props: SharedAutomationCardProps) {
isCreating={isCreating}
automation={automation}
ipLocationData={props.locationData}
setToastMessage={props.setToastMessage}
/>
) : null;
}
@@ -480,7 +474,7 @@ const EditAutomationSchema = z.object({
dayOfWeek: z.optional(z.number()),
dayOfMonth: z.optional(z.string()),
timeRecurrence: z.string({ required_error: "Time Recurrence is required" }),
queryToRun: z.string({ required_error: "Query to Run is required" }),
schedulingRequest: z.string({ required_error: "Query to Run is required" }),
});
interface EditCardProps {
@@ -492,6 +486,7 @@ interface EditCardProps {
isLoggedIn: boolean;
setShowLoginPrompt: (showLoginPrompt: boolean) => void;
authenticatedData: UserProfile | null;
setToastMessage: (toastMessage: string) => void;
}
function EditCard(props: EditCardProps) {
@@ -507,7 +502,7 @@ function EditCard(props: EditCardProps) {
? getTimeRecurrenceFromCron(automation.crontime)
: "12:00 PM",
dayOfMonth: automation?.crontime ? getDayOfMonthFromCron(automation.crontime) : "1",
queryToRun: automation?.query_to_run,
schedulingRequest: automation?.scheduling_request,
},
});
@@ -520,7 +515,7 @@ function EditCard(props: EditCardProps) {
);
let updateQueryUrl = `/api/automation?`;
updateQueryUrl += `q=${encodeURIComponent(values.queryToRun)}`;
updateQueryUrl += `q=${encodeURIComponent(values.schedulingRequest)}`;
if (automation?.id && !props.createNew) {
updateQueryUrl += `&automation_id=${encodeURIComponent(automation.id)}`;
}
@@ -552,6 +547,15 @@ function EditCard(props: EditCardProps) {
crontime: data.crontime,
next: data.next,
});
})
.catch((error) => {
console.error("Error saving automation:", error);
// Reset saving state
props.setIsEditing(false);
// Show error message
props.setToastMessage(
"Sorry, something went wrong. Try again or contact team@khoj.dev.",
);
});
};
@@ -829,7 +833,7 @@ function AutomationModificationForm(props: AutomationModificationFormProps) {
)}
<FormField
control={props.form.control}
name="queryToRun"
name="schedulingRequest"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Instructions</FormLabel>
@@ -850,7 +854,7 @@ function AutomationModificationForm(props: AutomationModificationFormProps) {
</FormControl>
<FormMessage />
{errors.subject && (
<FormMessage>{errors.queryToRun?.message}</FormMessage>
<FormMessage>{errors.schedulingRequest?.message}</FormMessage>
)}
</FormItem>
)}
@@ -919,6 +923,7 @@ interface AutomationComponentWrapperProps {
isCreating: boolean;
ipLocationData: LocationData | null | undefined;
automation?: AutomationsData;
setToastMessage: (toastMessage: string) => void;
}
function AutomationComponentWrapper(props: AutomationComponentWrapperProps) {
@@ -946,6 +951,7 @@ function AutomationComponentWrapper(props: AutomationComponentWrapperProps) {
setShowLoginPrompt={props.setShowLoginPrompt}
setUpdatedAutomationData={props.setNewAutomationData}
locationData={props.ipLocationData}
setToastMessage={props.setToastMessage}
/>
</DrawerContent>
</Drawer>
@@ -973,6 +979,7 @@ function AutomationComponentWrapper(props: AutomationComponentWrapperProps) {
setShowLoginPrompt={props.setShowLoginPrompt}
setUpdatedAutomationData={props.setNewAutomationData}
locationData={props.ipLocationData}
setToastMessage={props.setToastMessage}
/>
</DialogContent>
</Dialog>
@@ -1000,6 +1007,8 @@ export default function Automations() {
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const isMobileWidth = useIsMobileWidth();
const { locationData, locationDataError, locationDataLoading } = useIPLocationData();
const [toastMessage, setToastMessage] = useState("");
const { toast } = useToast();
useEffect(() => {
if (newAutomationData) {
@@ -1026,6 +1035,19 @@ export default function Automations() {
}
}, [personalAutomations, allNewAutomations]);
useEffect(() => {
const toastTitle = `Automation`;
if (toastMessage) {
toast({
title: toastTitle,
description: toastMessage,
action: <ToastAction altText="Dismiss">Ok</ToastAction>,
variant: toastMessage.includes("Sorry") ? "destructive" : "default",
});
setToastMessage("");
}
}, [toastMessage]);
if (error)
return <InlineLoading message="Oops, something went wrong. Please refresh the page." />;
@@ -1100,6 +1122,7 @@ export default function Automations() {
authenticatedData={authenticatedData}
isCreating={isCreating}
ipLocationData={locationData}
setToastMessage={setToastMessage}
/>
) : (
<Button
@@ -1120,6 +1143,7 @@ export default function Automations() {
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
setNewAutomationData={setNewAutomationData}
setToastMessage={setToastMessage}
/>
</Suspense>
{isLoading && <InlineLoading message="booting up your automations" />}
@@ -1135,6 +1159,7 @@ export default function Automations() {
locationData={locationData}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
setToastMessage={setToastMessage}
/>
))}
{authenticatedData &&
@@ -1147,6 +1172,7 @@ export default function Automations() {
locationData={locationData}
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
setToastMessage={setToastMessage}
/>
))}
</div>
@@ -1163,6 +1189,7 @@ export default function Automations() {
isLoggedIn={authenticatedData ? true : false}
setShowLoginPrompt={setShowLoginPrompt}
suggestedCard={true}
setToastMessage={setToastMessage}
/>
))}
</div>

View File

@@ -1741,7 +1741,7 @@ class AutomationAdapters:
return {
"id": automation.id,
"subject": automation_metadata["subject"],
"query_to_run": re.sub(r"^/automated_task\s*", "", automation_metadata["query_to_run"]),
"query_to_run": automation_metadata["query_to_run"],
"scheduling_request": automation_metadata["scheduling_request"],
"schedule": schedule,
"crontime": crontime,
@@ -1783,6 +1783,19 @@ class AutomationAdapters:
return automation
@staticmethod
async def aget_automation(user: KhojUser, automation_id: str) -> Job:
# Perform validation checks
# Check if user is allowed to delete this automation id
if not automation_id.startswith(f"automation_{user.uuid}_"):
raise ValueError("Invalid automation id")
# Check if automation with this id exist
automation: Job = await sync_to_async(state.scheduler.get_job)(job_id=automation_id)
if not automation:
raise ValueError("Invalid automation id")
return automation
@staticmethod
def delete_automation(user: KhojUser, automation_id: str):
# Get valid, user-owned automation

View File

@@ -935,7 +935,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": "/automated_task Share a funny Calvin and Hobbes or Bill Watterson quote from my notes",
"query": "Share a funny Calvin and Hobbes or Bill Watterson quote from my notes",
"subject": "Your Calvin and Hobbes Quote for the Day"
}}
@@ -955,7 +955,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": "/automated_task What is the latest released version of the Khoj python package?",
"query": "/automated_task /research What is the latest released version of the Khoj python package?",
"subject": "Khoj Python Package Version 2.0.0 Release"
}}
@@ -1101,33 +1101,33 @@ to_notify_or_not = PromptTemplate.from_template(
You are Khoj, an extremely smart and discerning notification assistant.
- Decide whether the user should be notified of the AI's response using the Original User Query, Executed User Query and AI Response triplet.
- Notify the user only if the AI's response satisfies the user specified requirements.
- You should only respond with a "Yes" or "No". Do not say anything else.
- You should return a response with your reason and "Yes" or "No" decision in JSON format. Do not say anything else.
# Examples:
Original User Query: Hahah, nice! Show a new one every morning at 9am. My Current Location: Shanghai, China
Executed User Query: Could you share a funny Calvin and Hobbes quote from my notes?
AI Reponse: Here is one I found: "It's not denial. I'm just selective about the reality I accept."
Khoj: Yes
Khoj: {{ "reason": "The AI has shared a funny Calvin and Hobbes quote." , "decision": "Yes" }}
Original User Query: Every evening check if it's going to rain tomorrow. Notify me only if I'll need an umbrella. My Current Location: Nairobi, Kenya
Executed User Query: Is it going to rain tomorrow in Nairobi, Kenya
AI Response: Tomorrow's forecast is sunny with a high of 28°C and a low of 18°C
Khoj: No
Khoj: {{ "reason": "It is not expected to rain tomorrow.", "decision": "No" }}
Original User Query: Tell me when version 2.0.0 is released. My Current Location: Mexico City, Mexico
Executed User Query: Check if version 2.0.0 of the Khoj python package is released
AI Response: The latest released Khoj python package version is 1.5.0.
Khoj: No
Original User Query: Paint me a sunset every evening. My Current Location: Shanghai, China
Executed User Query: Paint me a sunset in Shanghai, China
Original User Query: Paint a sunset for me every evening. My Current Location: Shanghai, China
Executed User Query: Paint a sunset in Shanghai, China
AI Response: https://khoj-generated-images.khoj.dev/user110/image78124.webp
Khoj: Yes
Khoj: {{ "reason": "The AI has created an image.", "decision": "Yes" }}
Original User Query: Share a summary of the tasks I've completed at the end of the day. My Current Location: Oslo, Norway
Executed User Query: Share a summary of the tasks I've completed today.
AI Response: I'm sorry, I couldn't find any relevant notes to respond to your message.
Khoj: No
Original User Query: Notify me when Khoj version 2.0.0 is released
Executed User Query: What is the latest released version of the Khoj python package
AI Response: The latest released Khoj python package version is 1.5.0.
Khoj: {{ "reason": "Version 2.0.0 of Khoj has not been released yet." , "decision": "No" }}
Original User Query: Share a summary of the tasks I've completed at the end of the day.
Executed User Query: Generate a summary of the tasks I've completed today.
AI Response: You have completed the following tasks today: 1. Meeting with the team 2. Submit travel expense report
Khoj: {{ "reason": "The AI has provided a summary of completed tasks.", "decision": "Yes" }}
Original User Query: {original_query}
Executed User Query: {executed_query}
@@ -1137,6 +1137,26 @@ Khoj:
)
automation_format_prompt = PromptTemplate.from_template(
"""
You are Khoj, a smart and creative researcher and writer with a knack for creating engaging content.
- You *CAN REMEMBER ALL NOTES and PERSONAL INFORMATION FOREVER* that the user ever shares with you.
- You *CAN* generate look-up real-time information from the internet, send notifications and answer questions based on the user's notes.
Convert the AI response into a clear, structured markdown report with section headings to improve readability.
Your response will be sent in the body of an email to the user.
Do not add an email subject. Never add disclaimers in your final response.
You are provided the following details for context.
{username}
Original User Query: {original_query}
Executed Chat Request: {executed_query}
AI Response: {response}
Khoj:
""".strip()
)
# System messages to user
# --
help_message = PromptTemplate.from_template(

View File

@@ -38,15 +38,15 @@ from khoj.processor.conversation.offline.chat_model import extract_questions_off
from khoj.processor.conversation.offline.whisper import transcribe_audio_offline
from khoj.processor.conversation.openai.gpt import extract_questions
from khoj.processor.conversation.openai.whisper import transcribe_audio
from khoj.processor.conversation.utils import defilter_query
from khoj.processor.conversation.utils import clean_json, defilter_query
from khoj.routers.helpers import (
ApiUserRateLimiter,
ChatEvent,
CommonQueryParams,
ConversationCommandRateLimiter,
acreate_title_from_query,
get_user_config,
schedule_automation,
schedule_query,
update_telemetry_state,
)
from khoj.search_filter.date_filter import DateFilter
@@ -566,7 +566,7 @@ def delete_automation(request: Request, automation_id: str) -> Response:
@api.post("/automation", response_class=Response)
@requires(["authenticated"])
async def post_automation(
def post_automation(
request: Request,
q: str,
crontime: str,
@@ -584,11 +584,15 @@ async def post_automation(
if not cron_descriptor.get_description(crontime):
return Response(content="Invalid crontime", status_code=400)
# Infer subject, query to run
_, query_to_run, generated_subject = schedule_query(q, conversation_history={}, user=user)
subject = subject or generated_subject
# Normalize query parameters
# Add /automated_task prefix to query if not present
q = q.strip()
if not q.startswith("/automated_task"):
query_to_run = f"/automated_task {q}"
query_to_run = query_to_run.strip()
if not query_to_run.startswith("/automated_task"):
query_to_run = f"/automated_task {query_to_run}"
# Normalize crontime for AP Scheduler CronTrigger
crontime = crontime.strip()
@@ -603,24 +607,19 @@ async def post_automation(
minute_value = crontime.split(" ")[0]
if not minute_value.isdigit():
return Response(
content="Recurrence of every X minutes is unsupported. Please create a less frequent schedule.",
content="Minute level recurrence is unsupported. Please create a less frequent schedule.",
status_code=400,
)
if not subject:
subject = await acreate_title_from_query(q, user)
title = f"Automation: {subject}"
# Create new Conversation Session associated with this new task
conversation = await ConversationAdapters.acreate_conversation_session(user, request.user.client_app, title=title)
calling_url = request.url.replace(query=f"{request.url.query}")
title = f"Automation: {subject}"
conversation = ConversationAdapters.create_conversation_session(user, request.user.client_app, title=title)
# Schedule automation with query_to_run, timezone, subject directly provided by user
try:
# Use the query to run as the scheduling request if the scheduling request is unset
automation = await schedule_automation(
calling_url = request.url.replace(query=f"{request.url.query}")
automation = schedule_automation(
query_to_run, subject, crontime, timezone, q, user, calling_url, str(conversation.id)
)
except Exception as e:
@@ -691,11 +690,15 @@ def edit_job(
logger.error(f"Error editing automation {automation_id} for {user.email}: {e}", exc_info=True)
return Response(content="Invalid automation", status_code=403)
# Infer subject, query to run
_, query_to_run, _ = schedule_query(q, conversation_history={}, user=user)
subject = subject
# Normalize query parameters
# Add /automated_task prefix to query if not present
q = q.strip()
if not q.startswith("/automated_task"):
query_to_run = f"/automated_task {q}"
query_to_run = query_to_run.strip()
if not query_to_run.startswith("/automated_task"):
query_to_run = f"/automated_task {query_to_run}"
# Normalize crontime for AP Scheduler CronTrigger
crontime = crontime.strip()
if len(crontime.split(" ")) > 5:
@@ -713,7 +716,7 @@ def edit_job(
)
# Construct updated automation metadata
automation_metadata = json.loads(automation.name)
automation_metadata: dict[str, str] = json.loads(clean_json(automation.name))
automation_metadata["scheduling_request"] = q
automation_metadata["query_to_run"] = query_to_run
automation_metadata["subject"] = subject.strip()

View File

@@ -551,9 +551,37 @@ async def generate_online_subqueries(
return {q}
async def schedule_query(
def schedule_query(
q: str, conversation_history: dict, user: KhojUser, query_images: List[str] = None, tracer: dict = {}
) -> Tuple[str, ...]:
) -> Tuple[str, str, str]:
"""
Schedule the date, time to run the query. Assume the server timezone is UTC.
"""
chat_history = construct_chat_history(conversation_history)
crontime_prompt = prompts.crontime_prompt.format(
query=q,
chat_history=chat_history,
)
raw_response = send_message_to_model_wrapper_sync(
crontime_prompt, query_images=query_images, response_type="json_object", user=user, tracer=tracer
)
# Validate that the response is a non-empty, JSON-serializable list
try:
raw_response = raw_response.strip()
response: Dict[str, str] = json.loads(clean_json(raw_response))
if not response or not isinstance(response, Dict) or len(response) != 3:
raise AssertionError(f"Invalid response for scheduling query : {response}")
return response.get("crontime"), response.get("query"), response.get("subject")
except Exception:
raise AssertionError(f"Invalid response for scheduling query: {raw_response}")
async def aschedule_query(
q: str, conversation_history: dict, user: KhojUser, query_images: List[str] = None, tracer: dict = {}
) -> Tuple[str, str, str]:
"""
Schedule the date, time to run the query. Assume the server timezone is UTC.
"""
@@ -571,7 +599,7 @@ async def schedule_query(
# Validate that the response is a non-empty, JSON-serializable list
try:
raw_response = raw_response.strip()
response: Dict[str, str] = json.loads(raw_response)
response: Dict[str, str] = json.loads(clean_json(raw_response))
if not response or not isinstance(response, Dict) or len(response) != 3:
raise AssertionError(f"Invalid response for scheduling query : {response}")
return response.get("crontime"), response.get("query"), response.get("subject")
@@ -1065,6 +1093,7 @@ def send_message_to_model_wrapper_sync(
system_message: str = "",
response_type: str = "text",
user: KhojUser = None,
query_images: List[str] = None,
query_files: str = "",
tracer: dict = {},
):
@@ -1090,6 +1119,7 @@ def send_message_to_model_wrapper_sync(
max_prompt_size=max_tokens,
vision_enabled=vision_available,
model_type=chat_model.model_type,
query_images=query_images,
query_files=query_files,
)
@@ -1112,6 +1142,7 @@ def send_message_to_model_wrapper_sync(
max_prompt_size=max_tokens,
vision_enabled=vision_available,
model_type=chat_model.model_type,
query_images=query_images,
query_files=query_files,
)
@@ -1134,6 +1165,7 @@ def send_message_to_model_wrapper_sync(
max_prompt_size=max_tokens,
vision_enabled=vision_available,
model_type=chat_model.model_type,
query_images=query_images,
query_files=query_files,
)
@@ -1154,6 +1186,7 @@ def send_message_to_model_wrapper_sync(
max_prompt_size=max_tokens,
vision_enabled=vision_available,
model_type=chat_model.model_type,
query_images=query_images,
query_files=query_files,
)
@@ -1634,6 +1667,25 @@ class CommonQueryParamsClass:
CommonQueryParams = Annotated[CommonQueryParamsClass, Depends()]
def format_automation_response(scheduling_request: str, executed_query: str, ai_response: str, user: KhojUser) -> bool:
"""
Format the AI response to send in automation email to user.
"""
name = get_user_name(user)
if name:
username = prompts.user_name.format(name=name)
automation_format_prompt = prompts.automation_format_prompt.format(
original_query=scheduling_request,
executed_query=executed_query,
response=ai_response,
username=username,
)
with timer("Chat actor: Format automation response", logger):
return send_message_to_model_wrapper_sync(automation_format_prompt, user=user)
def should_notify(original_query: str, executed_query: str, ai_response: str, user: KhojUser) -> bool:
"""
Decide whether to notify the user of the AI response.
@@ -1651,12 +1703,19 @@ def should_notify(original_query: str, executed_query: str, ai_response: str, us
with timer("Chat actor: Decide to notify user of automation response", logger):
try:
# TODO Replace with async call so we don't have to maintain a sync version
response = send_message_to_model_wrapper_sync(to_notify_or_not, user)
should_notify_result = "no" not in response.lower()
logger.info(f'Decided to {"not " if not should_notify_result else ""}notify user of automation response.')
raw_response = send_message_to_model_wrapper_sync(to_notify_or_not, user=user, response_type="json_object")
response = json.loads(raw_response)
should_notify_result = response["decision"] == "Yes"
reason = response.get("reason", "unknown")
logger.info(
f'Decided to {"not " if not should_notify_result else ""}notify user of automation response because of reason: {reason}.'
)
return should_notify_result
except:
logger.warning(f"Fallback to notify user of automation response as failed to infer should notify or not.")
except Exception as e:
logger.warning(
f"Fallback to notify user of automation response as failed to infer should notify or not. {e}",
exc_info=True,
)
return True
@@ -1751,10 +1810,12 @@ def scheduled_chat(
if should_notify(
original_query=scheduling_request, executed_query=cleaned_query, ai_response=ai_response, user=user
):
formatted_response = format_automation_response(scheduling_request, cleaned_query, ai_response, user)
if is_resend_enabled():
send_task_email(user.get_short_name(), user.email, cleaned_query, ai_response, subject, is_image)
send_task_email(user.get_short_name(), user.email, cleaned_query, formatted_response, subject, is_image)
else:
return raw_response
return formatted_response
async def create_automation(
@@ -1766,12 +1827,66 @@ async def create_automation(
conversation_id: str = None,
tracer: dict = {},
):
crontime, query_to_run, subject = await schedule_query(q, meta_log, user, tracer=tracer)
job = await schedule_automation(query_to_run, subject, crontime, timezone, q, user, calling_url, conversation_id)
crontime, query_to_run, subject = await aschedule_query(q, meta_log, user, tracer=tracer)
job = await aschedule_automation(query_to_run, subject, crontime, timezone, q, user, calling_url, conversation_id)
return job, crontime, query_to_run, subject
async def schedule_automation(
def schedule_automation(
query_to_run: str,
subject: str,
crontime: str,
timezone: str,
scheduling_request: str,
user: KhojUser,
calling_url: URL,
conversation_id: str,
):
# Disable minute level automation recurrence
minute_value = crontime.split(" ")[0]
if not minute_value.isdigit():
# Run automation at some random minute (to distribute request load) instead of running every X minutes
crontime = " ".join([str(math.floor(random() * 60))] + crontime.split(" ")[1:])
user_timezone = pytz.timezone(timezone)
trigger = CronTrigger.from_crontab(crontime, user_timezone)
trigger.jitter = 60
# Generate id and metadata used by task scheduler and process locks for the task runs
job_metadata = json.dumps(
{
"query_to_run": query_to_run,
"scheduling_request": scheduling_request,
"subject": subject,
"crontime": crontime,
"conversation_id": str(conversation_id),
}
)
query_id = hashlib.md5(f"{query_to_run}_{crontime}".encode("utf-8")).hexdigest()
job_id = f"automation_{user.uuid}_{query_id}"
job = state.scheduler.add_job(
run_with_process_lock,
trigger=trigger,
args=(
scheduled_chat,
f"{ProcessLock.Operation.SCHEDULED_JOB}_{user.uuid}_{query_id}",
),
kwargs={
"query_to_run": query_to_run,
"scheduling_request": scheduling_request,
"subject": subject,
"user": user,
"calling_url": calling_url,
"job_id": job_id,
"conversation_id": conversation_id,
},
id=job_id,
name=job_metadata,
max_instances=2, # Allow second instance to kill any previous instance with stale lock
)
return job
async def aschedule_automation(
query_to_run: str,
subject: str,
crontime: str,

View File

@@ -636,11 +636,11 @@ async def test_infer_webpage_urls_actor_extracts_correct_links(chat_client, defa
),
],
)
async def test_infer_task_scheduling_request(
def test_infer_task_scheduling_request(
chat_client, user_query, expected_crontime, expected_qs, unexpected_qs, default_user2
):
# Act
crontime, inferred_query, _ = await schedule_query(user_query, {}, default_user2)
crontime, inferred_query, _ = schedule_query(user_query, {}, default_user2)
inferred_query = inferred_query.lower()
# Assert