File size: 16,800 Bytes
0c73cb5
ed0ad4e
 
 
 
 
 
 
 
 
 
b2d3529
0c73cb5
8100c53
 
 
 
 
 
 
b2d3529
 
 
 
bb2b81a
b2d3529
bb2b81a
ed0ad4e
 
 
 
 
 
0c73cb5
ed0ad4e
 
0c73cb5
ed0ad4e
 
0c73cb5
ed0ad4e
 
 
 
0c73cb5
ed0ad4e
 
 
 
 
0c73cb5
ed0ad4e
0c73cb5
ed0ad4e
 
672546a
 
 
 
 
62bd8af
672546a
 
 
 
 
 
 
 
0c73cb5
ed0ad4e
 
672546a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c73cb5
ed0ad4e
 
672546a
 
 
 
62bd8af
672546a
 
 
 
 
 
b661b3a
ed0ad4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0c73cb5
ed0ad4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
import gradio as gr
from openai import OpenAI
import requests
import os
import asana
from asana.rest import ApiException
from pprint import pprint
import openai
import json
import datetime
import dateparser
import multiprocessing.pool

# Monkey patch ApiClient.__del__
def noop_del(self):
    print("Monkey patched ApiClient.__del__ called. Doing nothing.")
    pass

asana.api_client.ApiClient.__del__ = noop_del

# Monkey patch multiprocessing.pool.Pool.__del__
def noop_pool_del(self):
    print("Monkey patched multiprocessing.pool.Pool.__del__ called. Doing nothing.")
    pass

multiprocessing.pool.Pool.__del__ = noop_pool_del

settings={
    "PREFER_DATES_FROM": "future",
    "RELATIVE_BASE": datetime.datetime.now()
}
# Get the OpenAI API key from the environment variable
open_ai_key = os.environ.get("OPENAI_API_KEY")

# Initialize the OpenAI client with the API key
client = OpenAI(api_key=open_ai_key)

# Get Asana API key from the environment variable
access_token = os.environ.get("ASANA_API_KEY")

# Set up the Asana API client with the retrieved access token
configuration = asana.Configuration()
configuration.access_token = access_token
api_client = asana.ApiClient(configuration)

ASANA_BASE_URL = "https://app.asana.com/api/1.0"
ASANA_HEADERS = {
    "Authorization": f"Bearer {access_token}",
    "Content-Type": "application/json"
}

DEFAULT_PROJECT_GID = "1209104858113361"

def create_asana_task(name, due_date=None):
    """Create a task in Asana."""
    url = f"{ASANA_BASE_URL}/tasks"
    data = {
        "data": {
            "name": name,
            "projects": [DEFAULT_PROJECT_GID]
        }
    }
    if due_date:
        data["data"]["due_on"] = due_date  # Asana uses 'due_on' in YYYY-MM-DD format
    resp = requests.post(url, json=data, headers=ASANA_HEADERS)
    if resp.status_code == 201:
        return resp.json()["data"]  # returns the newly created task object
    else:
        return {"error": resp.text}

def list_asana_tasks(only_open=True):
    """List tasks in the default project, optionally filtering for only open tasks."""
    url = f"{ASANA_BASE_URL}/projects/{DEFAULT_PROJECT_GID}/tasks"
    params = {
        "opt_fields": "name,completed"  # Include the "completed" field to verify task status
    }
    if only_open:
        params["completed_since"] = "now"  # Fetch only incomplete or recently updated tasks

    resp = requests.get(url, headers=ASANA_HEADERS, params=params)
    if resp.status_code == 200:
        tasks = resp.json()["data"]
        if only_open:
            # Filter out completed tasks if only_open is True
            tasks = [task for task in tasks if not task.get("completed", False)]
        return tasks
    else:
        return {"error": resp.text}

def complete_asana_task(task_gid):
    """Mark a task as complete."""
    url = f"{ASANA_BASE_URL}/tasks/{task_gid}"
    data = {
        "data": {
            "completed": True
        }
    }
    resp = requests.put(url, json=data, headers=ASANA_HEADERS)
    if resp.status_code == 200:
        return resp.json()["data"]
    else:
        return {"error": resp.text}         

def call_llm(user_message, conversation_history=None):
    today_date = datetime.date.today().strftime("%Y-%m-%d")
    messages = [{"role": "system", "content": system_prompt}]
    messages.append({"role": "user", "content": user_message})

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        temperature=0.2,
        max_tokens=200
    )
    # Access 'content' using dot notation instead of indexing
    llm_content = response.choices[0].message.content
    print(f"Debug: Raw LLM Content: {llm_content}")
    return llm_content

# Global variables
last_task_list = []

def execute_turn(user_message, test_feed_iter=None):
    global last_task_list  # Declare global variable at the top of the function
    llm_output = call_llm(user_message)
    parsed = parse_llm_response(llm_output)
    action = parsed.get("action")

    if action == "CREATE_TASK":
        task_name = parsed.get("name", "").strip()
        due_date = parsed.get("due")  # Could be a natural language string like "tomorrow"

        # --- 1) OVERRIDE "NEW TASK" NAME IF POSSIBLE ---
        if (not task_name or task_name.lower() == "new task"):
            if test_feed_iter is not None:
                # If we have more lines in TEST_FEED, use them
                try:
                    override_name = next(test_feed_iter)  # get the next line from the feed
                    task_name = override_name.strip()
                    print(f"Debug: Overriding 'New Task' with '{task_name}' from TEST_FEED")
                except StopIteration:
                    # If there's nothing left in the feed, fallback to user prompt
                    if not task_name:
                        print("Bot: Task name cannot be empty. Please try again.")
                        return
            else:
                # If we don't have a test_feed_iter, just prompt the user
                print("Bot: What would you like the name of the task to be?")
                task_name = input(USER_PROMPT).strip()
                if not task_name:
                    print("Bot: Task name cannot be empty. Please try again.")
                    return

        # --- 2) OVERRIDE DUE DATE IF POSSIBLE ---
        if not due_date:
            if test_feed_iter is not None:
                # Try to parse the next line as a date
                try:
                    maybe_date = next(test_feed_iter)
                    parsed_date = dateparser.parse(maybe_date)
                    if parsed_date:
                        due_date = parsed_date.strftime('%Y-%m-%d')
                        print(f"Debug: Overriding due date with '{due_date}' from TEST_FEED")
                    else:
                        # Not recognized as a date; do nothing
                        print(f"Debug: '{maybe_date}' did not parse as a date; skipping due.")
                except StopIteration:
                    pass

            # If we still have no due_date after that, fallback to user prompt
            if not due_date:
                user_due_date = input(USER_PROMPT).strip()
                if user_due_date:
                    parsed_date = dateparser.parse(
                        user_due_date,
                        settings={
                            "PREFER_DATES_FROM": "future",
                            # Optionally: "RELATIVE_BASE": datetime.datetime.now()
                        }
                    )
                    if parsed_date:
                        due_date = parsed_date.strftime('%Y-%m-%d')
                    else:
                        print("Bot: I couldn’t understand the due date. Skipping it.")
                        due_date = None

        # --- 3) ATTEMPT TO CREATE THE TASK ---
        print(f"Debug: Attempting to create task with name '{task_name}' and due date '{due_date}'")
        result = create_asana_task(task_name, due_date)
        # Provide a confirmation message if successful
        if "error" in result:
            print("Bot: Sorry, I had trouble creating the task:", result["error"])
        else:
            message = f"Bot: I've created your task '{result['name']}' (ID: {result['gid']})."
            if due_date:
                message += f" It's due on {due_date}."
            print(message)

    elif action == "LIST_TASKS":
        # (Unmodified code for listing tasks)
        filter_type = parsed.get("filter", "open")  # Default to "open" if no filter is provided
        only_open = filter_type == "open"
        tasks = list_asana_tasks(only_open=only_open)
        if "error" in tasks:
            print("Bot: Sorry, I had trouble listing tasks:", tasks["error"])
        elif not tasks:
            if only_open:
                print("Bot: You have no open tasks!")
            else:
                print("Bot: You have no tasks!")
        else:
            task_type = "open" if only_open else "all"
            print(f"Here are your {task_type} tasks:")
            last_task_list.clear()  # Clear previous tasks
            for t in tasks:
                task_info = {'name': t['name'], 'gid': t['gid']}
                last_task_list.append(task_info)  # Store task info
                print(f"- {t['name']} (ID: {t['gid']})")

    elif action == "COMPLETE_TASK":
        # (Unmodified code for completing tasks)
        task_gid = parsed.get("task_gid")
        task_name = parsed.get("name")  # Capture task name for fuzzy matching

        if task_gid:
            result = complete_asana_task(task_gid)
            if "error" in result:
                print("Bot: Sorry, I couldn’t complete the task:", result["error"])
            else:
                print(f"Task '{result['name']}' marked as complete.")
        elif task_name:
            tasks = list_asana_tasks()
            if "error" in tasks:
                print("Bot: Sorry, I had trouble fetching tasks to find a match.")
                return

            matches = [t for t in tasks if task_name.lower() in t['name'].lower()]

            if len(matches) == 1:
                task_to_close = matches[0]
                result = complete_asana_task(task_to_close["gid"])
                if "error" in result:
                    print(f"Bot: Sorry, I couldn’t complete the task: {result['error']}")
                else:
                    print(f"Task '{task_to_close['name']}' marked as complete.")
            elif len(matches) > 1:
                print("Bot: I found multiple tasks matching that name. "
                      "Please provide the ID of the task you'd like to close:")
                for task in matches:
                    print(f"- {task['name']} (ID: {task['gid']})")
            else:
                print(f"Bot: I couldn’t find any tasks matching '{task_name}'.")
        else:
            # Attempt to extract ordinal-based task position.
            ordinal_map = {
                'first': 1,
                'second': 2,
                'third': 3,
                'fourth': 4,
                'fifth': 5,
                'sixth': 6,
                'seventh': 7,
                'eighth': 8,
                'ninth': 9,
                'tenth': 10
            }
            words = user_message.lower().split()
            ordinal_position = None
            for word in words:
                if word in ordinal_map:
                    ordinal_position = ordinal_map[word] - 1  # zero-based index
                    break
                elif word.isdigit():
                    ordinal_position = int(word) - 1
                    break

            if ordinal_position is not None and last_task_list:
                if 0 <= ordinal_position < len(last_task_list):
                    task_to_close = last_task_list[ordinal_position]
                    result = complete_asana_task(task_to_close["gid"])
                    if "error" in result:
                        print(f"Bot: Sorry, I couldn’t complete the task: {result['error']}")
                    else:
                        print(f"Task '{task_to_close['name']}' marked as complete.")
                else:
                    print("Bot: The task number you specified is out of range.")
            else:
                print("Bot: Please provide a valid task name, ID, or position to close.")
    else:
        # No recognized action, or just normal text
        print(llm_output)

def extract_task_id_from_message(message):
    """
    Extract task ID (task_gid) from the user message.
    Example input: "Can we close task 1234567890?"
    Example output: "1234567890"
    """
    import re
    # Use a regular expression to find a numeric sequence in the message
    match = re.search(r'\b\d{10,}\b', message)  # Look for 10+ digit numbers
    if match:
        return match.group(0)  # Return the first match
    return None  # If no match found, return None

def parse_llm_response(llm_output):
    try:
        print(f"Debug: Raw LLM Content: {llm_output}")  # Debug raw output

        # Strip the backticks and "json" tag
        if llm_output.startswith("```json") and llm_output.endswith("```"):
            llm_output = llm_output.strip("```").strip("json").strip()

        print(f"Debug: Cleaned LLM Output: {llm_output}")  # Debug cleaned output

        # Parse the cleaned JSON
        parsed_response = json.loads(llm_output)
        print(f"Debug: Parsed Response: {parsed_response}")  # Debug parsed JSON
        return parsed_response
    except json.JSONDecodeError as e:
        print(f"Error: Failed to parse LLM response: {e}")
        return {"action": "NONE"}  # Fallback
    except Exception as e:
        print(f"Error: Unexpected issue in parse_llm_response: {e}")
        return {"action": "NONE"}  # Fallback

def run_manual_chat():
    print("Hello! I'm your Asana Copilot!")
    print("I can help you create new tasks, list your tasks, and mark tasks as completed.")
    print("Let me know how I can help.")
    print("Want to end our chat? Just type 'quit' to exit.\n")

    USER_PROMPT = "[USER]\n>>> "
    TURN_BREAK = "-------------------\n"

    while True:
        user_input = input(USER_PROMPT).strip()
        if user_input.lower() == "quit":
            print("\nExiting the chat. Goodbye!")
            break

        print(TURN_BREAK + "[COPILOT]")
        execute_turn(user_input)
        print(TURN_BREAK)

system_prompt = """
You are a friendly AI Copilot that helps users interface with Asana -- namely creating new tasks, listing tasks, and marking tasks as complete.
You will interpret the user's request and respond with structured JSON.
Today's date is {today_date}.

Rules:
1. If the user asks to create a task, respond with:
   { "action": "CREATE_TASK", "name": "<TASK NAME>", "due": "<YYYY-MM-DD>" }
   If they gave a date in any format. For words like 'tomorrow', interpret it as {today_date} + 1 day, etc.
   If no date is given or you cannot parse it, omit the 'due' field.
2. If the user asks to list tasks, respond with:
   {"action": "LIST_TASKS", "filter": "open"}  # For "list my open tasks" or similar
   {"action": "LIST_TASKS", "filter": "all"}  # For "list all my tasks" or similar
   If the user specifies "open tasks" or similar, return only incomplete tasks. If the user specifies "all tasks," return all tasks (completed and incomplete).
   If the intent is unclear, default to showing only open tasks.
3. If the user asks to complete a task, respond with:
   { "action": "COMPLETE_TASK", "task_gid": "<ID>" }
   OR
   { "action": "COMPLETE_TASK", "name": "<TASK NAME>" }
   OR
   { "action": "COMPLETE_TASK", "position": <NUMBER> }
   Use 'position' if the user refers to a task by its position in the list (e.g., "third one").
4. If no action is needed, respond with:
   { "action": "NONE" }

Examples:
- User: "Close task 1209105096577103"
  Response: { "action": "COMPLETE_TASK", "task_gid": "1209105096577103" }

- User: "Can you close rub jason's feet?"
  Response: { "action": "COMPLETE_TASK", "name": "rub jason's feet" }

- User: "List all my tasks"
  Response: { "action": "LIST_TASKS" }

- User: "Create a task called 'Finish report' due tomorrow"
  Response: { "action": "CREATE_TASK", "name": "Finish report", "due": "2025-01-08" }

- User: "Close the third one"
  Response: { "action": "COMPLETE_TASK", "position": 3 }

- User: "Complete task number 5"
  Response: { "action": "COMPLETE_TASK", "position": 5 }

- {"action": "LIST_TASKS", "filter": "open"}  # For "list my open tasks"
- {"action": "LIST_TASKS", "filter": "all"}  # For "list all my tasks"
- {"action": "CREATE_TASK", "name": "Task Name", "due": "2025-01-15"}
- {"action": "COMPLETE_TASK", "task_gid": "1209105096577103"}

Again, always respond in JSON format. Example:
{
  "action": "CREATE_TASK",
  "name": "Submit Assignment",
  "due": "2023-12-31"
}

If no action is required, respond with:
{
  "action": "NONE"
}
"""

def process_input(user_input):
    """
    This function takes the user's input, processes it using the Asana Copilot logic,
    and returns the formatted output for display.
    """
    global last_task_list  # Make sure to handle global variables

    # Simulate the conversation turn
    response = ""  # Initialize an empty string to accumulate the response

    # Process the user input using the execute_turn function
    # Capture the output from execute_turn (e.g., print statements) and append it to 'response'
    import io
    from contextlib import redirect_stdout

    with io.StringIO() as buf, redirect_stdout(buf):
        execute_turn(user_input)
        output = buf.getvalue()

    response += output  # Append the captured output to the response

    return response