Prof-Reza commited on
Commit
8812eb7
·
verified ·
1 Parent(s): b5b62e6

Upload 5 files

Browse files

Implement persistent chat sessions and Word document output: add session sidebar and chat management via DB, generate docx on finalize; update search caching and UI.

Files changed (4) hide show
  1. app.py +234 -27
  2. db.py +53 -1
  3. docx_utils.py +90 -0
  4. requirements.txt +2 -0
app.py CHANGED
@@ -7,7 +7,24 @@ from generators import generate_course_zip
7
  from searcher import web_search, fetch_and_extract, get_youtube_transcript
8
 
9
  # Bring in DB helpers to persist resources if needed later
10
- from db import get_resource, upsert_resource, list_resources, new_chat, append_message, load_chat, soft_delete_message
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  # System prompt guiding the assistant's behaviour during brainstorming
13
  SYSTEM_PROMPT = (
@@ -17,8 +34,27 @@ SYSTEM_PROMPT = (
17
  "conversation and gathered resources."
18
  )
19
 
20
- def chat(user_message, chat_history, chat_pairs, sources, plan, resource_cache):
21
- """Handle a user chat message and return updated chat state."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # Ensure lists/dicts are initialised
23
  if chat_history is None:
24
  chat_history = []
@@ -26,6 +62,13 @@ def chat(user_message, chat_history, chat_pairs, sources, plan, resource_cache):
26
  chat_pairs = []
27
  if resource_cache is None:
28
  resource_cache = {}
 
 
 
 
 
 
 
29
  # Append the user's message to the conversation history (list of dictionaries for Chatbot)
30
  chat_history.append({"role": "user", "content": user_message})
31
  # Build messages including system prompt for API call
@@ -289,6 +332,12 @@ def chat(user_message, chat_history, chat_pairs, sources, plan, resource_cache):
289
  )
290
  # Append assistant reply to conversation history
291
  chat_history.append({"role": "assistant", "content": assistant_reply})
 
 
 
 
 
 
292
  # Append pair to display history for any other uses (kept for compatibility)
293
  chat_pairs.append((user_message, assistant_reply))
294
  # For Chatbot with default type (list of (user, assistant) tuples), return chat_pairs as the first output
@@ -328,7 +377,7 @@ def run_search(query, chat_history, chat_pairs, sources, plan, num_results=5, do
328
  return summary, chat_history, chat_pairs, sources, plan
329
 
330
  def finalize_outline(chat_history, chat_pairs, sources, plan):
331
- """Generate a course outline based on the conversation and collected sources."""
332
  if chat_history is None:
333
  chat_history = []
334
  if sources is None:
@@ -344,6 +393,54 @@ def finalize_outline(chat_history, chat_pairs, sources, plan):
344
  plan = plan_text
345
  return plan_text, chat_history, chat_pairs, sources, plan
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def generate_package(plan, sources):
348
  """Generate the final course package zip file."""
349
  if plan is None or plan == "":
@@ -369,41 +466,151 @@ def generate_package(plan, sources):
369
  with gr.Blocks() as demo:
370
  gr.Markdown(
371
  """# Course Creator Agent
372
- Chat with the assistant to brainstorm your course idea. You can ask the assistant to search the internet directly in the chat. When you're ready, click **Finalize Outline** to generate a course plan. Then generate the final course package (ZIP)."""
373
  )
374
- # Chat interface components
375
- # Use the default Chatbot type (list of (user, assistant) tuples). This avoids CSS issues with the
376
- # messages type and makes the conversation visible without custom styling. Specify a height to
377
- # constrain the display area.
378
- chatbot = gr.Chatbot(label="Conversation", height=400)
379
- msg_input = gr.Textbox(label="Your message", placeholder="Type your message and press Enter", lines=1)
380
- # Buttons and outputs (search is triggered via chat; no separate search controls)
381
- finalize_btn = gr.Button("Finalize Outline")
382
- plan_output = gr.Textbox(label="Course outline", interactive=False)
383
- generate_btn = gr.Button("Generate Course Package")
384
- file_output = gr.File(label="course.zip")
385
- # State variables to keep track of conversation (messages), display pairs, sources and plan
386
  state_chat_history = gr.State([])
387
  state_chat_pairs = gr.State([])
388
  state_sources = gr.State([])
389
  state_plan = gr.State("")
390
- # Cache for previously fetched search results (keyed by lowercased query). Helps prevent
391
- # duplicate web searches and ensures all returned resources are real and consistent.
392
  state_resource_cache = gr.State({})
393
- # Handle chat submission: update chatbot display and states
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  msg_input.submit(
395
  chat,
396
- inputs=[msg_input, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache],
397
- # First output goes to the Chatbot; return the updated chat pairs for proper display
398
  outputs=[chatbot, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache],
399
  )
400
- # Finalize outline button: generate course plan and store it
401
  finalize_btn.click(
402
- finalize_outline,
403
- inputs=[state_chat_history, state_chat_pairs, state_sources, state_plan],
404
- outputs=[plan_output, state_chat_history, state_chat_pairs, state_sources, state_plan],
405
  )
406
- # Generate course package button
407
  generate_btn.click(
408
  generate_package,
409
  inputs=[state_plan, state_sources],
 
7
  from searcher import web_search, fetch_and_extract, get_youtube_transcript
8
 
9
  # Bring in DB helpers to persist resources if needed later
10
+ # Import DB helpers. We include list_chats, rename_chat and delete_chat to support
11
+ # persistent chat sessions and management actions. The resource helpers allow
12
+ # fetching cached resources if needed.
13
+ from db import (
14
+ get_resource,
15
+ upsert_resource,
16
+ list_resources,
17
+ new_chat,
18
+ append_message,
19
+ load_chat,
20
+ soft_delete_message,
21
+ list_chats,
22
+ rename_chat,
23
+ delete_chat,
24
+ )
25
+
26
+ # Import the docx utility to generate Word documents for course outlines.
27
+ from docx_utils import outline_to_docx
28
 
29
  # System prompt guiding the assistant's behaviour during brainstorming
30
  SYSTEM_PROMPT = (
 
34
  "conversation and gathered resources."
35
  )
36
 
37
+ def chat(user_message, chat_history, chat_pairs, sources, plan, resource_cache, chat_key):
38
+ """
39
+ Handle a user chat message and return updated chat state.
40
+
41
+ This version persists messages to the database by inserting user and assistant
42
+ messages into the `messages` table keyed by `chat_key`. It also returns
43
+ updated state variables for Gradio to reflect the conversation.
44
+
45
+ Args:
46
+ user_message: The latest user input from the textbox.
47
+ chat_history: List of message dicts representing the full conversation.
48
+ chat_pairs: List of (user, assistant) tuples for display in the Chatbot.
49
+ sources: List of collected resource dicts with 'title' and 'url'.
50
+ plan: The current course plan text (unused here).
51
+ resource_cache: Dictionary caching search results by query.
52
+ chat_key: The unique key identifying the current chat session.
53
+
54
+ Returns:
55
+ Tuple of updated (chat_pairs, chat_history, chat_pairs, sources, plan,
56
+ resource_cache).
57
+ """
58
  # Ensure lists/dicts are initialised
59
  if chat_history is None:
60
  chat_history = []
 
62
  chat_pairs = []
63
  if resource_cache is None:
64
  resource_cache = {}
65
+ # Persist the user message to the database if a chat key is provided
66
+ if chat_key:
67
+ try:
68
+ append_message(chat_key, "user", user_message)
69
+ except Exception:
70
+ # Ignore DB errors; continue without persistence
71
+ pass
72
  # Append the user's message to the conversation history (list of dictionaries for Chatbot)
73
  chat_history.append({"role": "user", "content": user_message})
74
  # Build messages including system prompt for API call
 
332
  )
333
  # Append assistant reply to conversation history
334
  chat_history.append({"role": "assistant", "content": assistant_reply})
335
+ # Persist assistant message to the database
336
+ if chat_key:
337
+ try:
338
+ append_message(chat_key, "assistant", assistant_reply)
339
+ except Exception:
340
+ pass
341
  # Append pair to display history for any other uses (kept for compatibility)
342
  chat_pairs.append((user_message, assistant_reply))
343
  # For Chatbot with default type (list of (user, assistant) tuples), return chat_pairs as the first output
 
377
  return summary, chat_history, chat_pairs, sources, plan
378
 
379
  def finalize_outline(chat_history, chat_pairs, sources, plan):
380
+ """Generate a course outline based on the conversation and collected sources (text only)."""
381
  if chat_history is None:
382
  chat_history = []
383
  if sources is None:
 
393
  plan = plan_text
394
  return plan_text, chat_history, chat_pairs, sources, plan
395
 
396
+
397
+ def finalize_and_doc(chat_history, chat_pairs, sources, plan, chat_key):
398
+ """
399
+ Generate a course outline and a Word document from the conversation and sources.
400
+
401
+ This function calls the planner to create a textual plan, then writes the plan and
402
+ references to a .docx file using the docx utility. It returns the plan text,
403
+ updated state variables, and the path to the generated document.
404
+
405
+ Args:
406
+ chat_history: Conversation messages list.
407
+ chat_pairs: Display pairs list.
408
+ sources: List of collected resources (dictionaries with title and url).
409
+ plan: The existing plan text (ignored here).
410
+ chat_key: The key identifying the current chat (unused here but kept for consistency).
411
+
412
+ Returns:
413
+ Tuple of (plan_text, chat_history, chat_pairs, sources, plan_text, doc_path).
414
+ """
415
+ if chat_history is None:
416
+ chat_history = []
417
+ if sources is None:
418
+ sources = []
419
+ # Generate the course plan text
420
+ try:
421
+ plan_text = plan_course(chat_history, sources)
422
+ except Exception as e:
423
+ plan_text = (
424
+ "An error occurred while generating the course outline. Please ensure your API keys are configured.\n"
425
+ f"(Error: {e})"
426
+ )
427
+ # Create a Word document from the plan text and sources
428
+ try:
429
+ doc_path = outline_to_docx("Course Outline", plan_text, references=sources)
430
+ except Exception as e:
431
+ # If DOCX generation fails, create a temporary text file with an error
432
+ err_msg = (
433
+ "An error occurred while creating the Word document.\n"
434
+ f"(Error: {e})"
435
+ )
436
+ tmp_path = "/tmp/outline_error.txt"
437
+ with open(tmp_path, "w") as f:
438
+ f.write(err_msg)
439
+ doc_path = tmp_path
440
+ # Update plan state
441
+ plan = plan_text
442
+ return plan_text, chat_history, chat_pairs, sources, plan, doc_path
443
+
444
  def generate_package(plan, sources):
445
  """Generate the final course package zip file."""
446
  if plan is None or plan == "":
 
466
  with gr.Blocks() as demo:
467
  gr.Markdown(
468
  """# Course Creator Agent
469
+ Chat with the assistant to brainstorm your course idea. Use the panel on the left to manage multiple chat sessions (create, rename, delete). You can ask the assistant to search the internet directly in the chat. When you're ready, click **Finalize Outline** to generate a course plan and a Word document. Then generate the final course package (ZIP)."""
470
  )
471
+ # Global states
472
+ state_chat_key = gr.State(new_chat())
 
 
 
 
 
 
 
 
 
 
473
  state_chat_history = gr.State([])
474
  state_chat_pairs = gr.State([])
475
  state_sources = gr.State([])
476
  state_plan = gr.State("")
 
 
477
  state_resource_cache = gr.State({})
478
+
479
+ # Define layout with a sidebar for sessions and a main panel for chat
480
+ with gr.Row():
481
+ with gr.Column(scale=1, min_width=220):
482
+ session_picker = gr.Radio(label="Your Chats", choices=[], interactive=True)
483
+ new_chat_btn = gr.Button("New Chat")
484
+ rename_input = gr.Textbox(label="Rename chat", placeholder="New title", lines=1)
485
+ rename_btn = gr.Button("Rename")
486
+ delete_btn = gr.Button("Delete Chat")
487
+ with gr.Column(scale=4):
488
+ chatbot = gr.Chatbot(label="Conversation", height=400)
489
+ msg_input = gr.Textbox(
490
+ label="Your message",
491
+ placeholder="Type your message and press Enter",
492
+ lines=1,
493
+ )
494
+ finalize_btn = gr.Button("Finalize Outline")
495
+ plan_output = gr.Textbox(label="Course outline", interactive=False)
496
+ doc_output = gr.File(label="Course outline (Word)")
497
+ generate_btn = gr.Button("Generate Course Package")
498
+ file_output = gr.File(label="course.zip")
499
+
500
+ # Helper to refresh the sidebar session list. Returns an update for session_picker.
501
+ def refresh_sessions():
502
+ chats = list_chats()
503
+ choices = [c["key"] for c in chats]
504
+ # Display titles or truncated key if no title
505
+ labels = [c["title"] if c["title"] else c["key"][:8] for c in chats]
506
+ # gr.Radio uses choices for values and may display them as-is; set label implicitly via choices
507
+ return gr.update(choices=choices, value=state_chat_key.value)
508
+
509
+ # Load the selected chat into memory and return display pairs/history
510
+ def load_session(selected_key):
511
+ if not selected_key:
512
+ return [], [], [], [], "", {}
513
+ msgs = load_chat(selected_key)
514
+ history = []
515
+ pairs = []
516
+ buffer = []
517
+ for msg in msgs:
518
+ role = msg["role"]
519
+ content = msg["content"]
520
+ history.append({"role": role, "content": content})
521
+ if role == "user":
522
+ buffer = [content, ""]
523
+ else:
524
+ if buffer:
525
+ buffer[1] = content
526
+ pairs.append(tuple(buffer))
527
+ buffer = []
528
+ return pairs, history, pairs, [], "", {}
529
+
530
+ # Create a new chat session and return the new key
531
+ def handle_new_chat():
532
+ key = new_chat()
533
+ return key
534
+
535
+ # Rename the current chat session
536
+ def handle_rename(chat_key, new_title):
537
+ if chat_key and new_title:
538
+ rename_chat(chat_key, new_title)
539
+ return ""
540
+
541
+ # Delete the current chat session and return a new key to switch to
542
+ def handle_delete(chat_key):
543
+ if chat_key:
544
+ delete_chat(chat_key)
545
+ chats = list_chats()
546
+ if chats:
547
+ return chats[0]["key"]
548
+ else:
549
+ return new_chat()
550
+
551
+ # Initialize session list on load
552
+ demo.load(
553
+ lambda: refresh_sessions(),
554
+ None,
555
+ [session_picker],
556
+ )
557
+ # When a session is selected, load it
558
+ session_picker.change(
559
+ load_session,
560
+ inputs=session_picker,
561
+ outputs=[chatbot, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache],
562
+ )
563
+ # New chat button
564
+ new_chat_btn.click(
565
+ handle_new_chat,
566
+ inputs=None,
567
+ outputs=state_chat_key,
568
+ ).then(
569
+ lambda: refresh_sessions(),
570
+ None,
571
+ [session_picker],
572
+ ).then(
573
+ load_session,
574
+ inputs=state_chat_key,
575
+ outputs=[chatbot, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache],
576
+ )
577
+ # Rename button
578
+ rename_btn.click(
579
+ handle_rename,
580
+ inputs=[state_chat_key, rename_input],
581
+ outputs=rename_input,
582
+ ).then(
583
+ lambda: refresh_sessions(),
584
+ None,
585
+ [session_picker],
586
+ )
587
+ # Delete button
588
+ delete_btn.click(
589
+ handle_delete,
590
+ inputs=state_chat_key,
591
+ outputs=state_chat_key,
592
+ ).then(
593
+ lambda: refresh_sessions(),
594
+ None,
595
+ [session_picker],
596
+ ).then(
597
+ load_session,
598
+ inputs=state_chat_key,
599
+ outputs=[chatbot, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache],
600
+ )
601
+ # Chat submission: include chat_key for persistence
602
  msg_input.submit(
603
  chat,
604
+ inputs=[msg_input, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache, state_chat_key],
 
605
  outputs=[chatbot, state_chat_history, state_chat_pairs, state_sources, state_plan, state_resource_cache],
606
  )
607
+ # Finalise outline and produce Word doc
608
  finalize_btn.click(
609
+ finalize_and_doc,
610
+ inputs=[state_chat_history, state_chat_pairs, state_sources, state_plan, state_chat_key],
611
+ outputs=[plan_output, state_chat_history, state_chat_pairs, state_sources, state_plan, doc_output],
612
  )
613
+ # Generate course package (zip)
614
  generate_btn.click(
615
  generate_package,
616
  inputs=[state_plan, state_sources],
db.py CHANGED
@@ -155,4 +155,56 @@ def load_chat(chat_key: str) -> list[dict]:
155
  def soft_delete_message(message_id: int) -> None:
156
  """Mark a message as deleted without removing it."""
157
  with get_conn() as conn:
158
- conn.execute("UPDATE messages SET status='deleted' WHERE id=?", (message_id,))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  def soft_delete_message(message_id: int) -> None:
156
  """Mark a message as deleted without removing it."""
157
  with get_conn() as conn:
158
+ conn.execute("UPDATE messages SET status='deleted' WHERE id=?", (message_id,))
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Additional chat management helpers
162
+ #
163
+ # The following functions support listing, renaming, and deleting entire chat
164
+ # sessions. They complement the existing new_chat(), append_message(), and
165
+ # load_chat() functions above. Each chat is keyed by a unique chat_key.
166
+
167
+ def list_chats(limit: int = 100) -> list[dict]:
168
+ """Return a list of chat summaries ordered by most recent.
169
+
170
+ Args:
171
+ limit: Maximum number of chats to return.
172
+
173
+ Returns:
174
+ A list of dictionaries with keys 'key', 'title' and 'created_at'.
175
+ """
176
+ with get_conn() as conn:
177
+ rows = conn.execute(
178
+ "SELECT chat_key, COALESCE(title, 'Untitled'), created_at FROM chats "
179
+ "ORDER BY created_at DESC LIMIT ?",
180
+ (limit,),
181
+ ).fetchall()
182
+ return [
183
+ {"key": chat_key, "title": title, "created_at": created_at}
184
+ for chat_key, title, created_at in rows
185
+ ]
186
+
187
+
188
+ def rename_chat(chat_key: str, new_title: str) -> None:
189
+ """Rename a chat session given its key.
190
+
191
+ Args:
192
+ chat_key: The unique key of the chat to rename.
193
+ new_title: The new title to set.
194
+ """
195
+ with get_conn() as conn:
196
+ conn.execute(
197
+ "UPDATE chats SET title=? WHERE chat_key=?",
198
+ (new_title, chat_key),
199
+ )
200
+
201
+
202
+ def delete_chat(chat_key: str) -> None:
203
+ """Delete a chat session and all its messages.
204
+
205
+ Args:
206
+ chat_key: The unique key of the chat to delete.
207
+ """
208
+ with get_conn() as conn:
209
+ conn.execute("DELETE FROM messages WHERE chat_key=?", (chat_key,))
210
+ conn.execute("DELETE FROM chats WHERE chat_key=?", (chat_key,))
docx_utils.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions for generating Word documents (DOCX) from course outlines.
3
+
4
+ This module provides a helper to convert a course outline into a .docx file.
5
+ It uses the python-docx library to construct a simple document with a title,
6
+ outline sections, optional bullet points, and a references section. The
7
+ generated file is saved to a temporary location and the path is returned.
8
+
9
+ Dependencies:
10
+ python-docx >= 1.1.0
11
+
12
+ """
13
+
14
+ from datetime import datetime
15
+ from typing import List, Dict, Optional
16
+
17
+ from docx import Document
18
+ from docx.shared import Pt
19
+
20
+
21
+ def outline_to_docx(
22
+ title: str,
23
+ outline_text: str,
24
+ references: Optional[List[Dict[str, str]]] = None,
25
+ path: str = "/tmp/course_outline.docx",
26
+ ) -> str:
27
+ """Create a Word document from a course outline.
28
+
29
+ Args:
30
+ title: Title for the course or outline.
31
+ outline_text: Plain text of the outline. Each line will be written as a
32
+ separate paragraph. If you need more structured output, consider
33
+ extending this function to parse the text.
34
+ references: Optional list of dictionaries with 'title' and 'url' keys.
35
+ These will be included in a references section at the end.
36
+ path: File system path where the document will be written. Defaults
37
+ to '/tmp/course_outline.docx'.
38
+
39
+ Returns:
40
+ The file path to the generated document.
41
+ """
42
+ # Create a new document
43
+ doc = Document()
44
+
45
+ # Title heading
46
+ title_heading = doc.add_heading(level=1)
47
+ run = title_heading.add_run(title or "Course Outline")
48
+ run.bold = True
49
+ run.font.size = Pt(20)
50
+
51
+ # Write each line of the outline as a paragraph
52
+ for line in (outline_text or "").splitlines():
53
+ line = line.strip()
54
+ if not line:
55
+ continue
56
+ # Use list bullet style if the line looks like a bullet (starts with '-' or '*')
57
+ if line.lstrip().startswith(("-", "*")):
58
+ # Remove leading bullet characters
59
+ content = line.lstrip("-* ")
60
+ p = doc.add_paragraph(content, style="List Bullet")
61
+ else:
62
+ p = doc.add_paragraph(line)
63
+ # Standard font size
64
+ for r in p.runs:
65
+ r.font.size = Pt(12)
66
+
67
+ # References
68
+ if references:
69
+ doc.add_heading("References", level=2)
70
+ for ref in references:
71
+ ref_title = ref.get("title", "")
72
+ ref_url = ref.get("url", "")
73
+ para = doc.add_paragraph()
74
+ if ref_title:
75
+ para.add_run(ref_title).bold = True
76
+ para.add_run(" — ")
77
+ para.add_run(ref_url)
78
+ for r in para.runs:
79
+ r.font.size = Pt(11)
80
+
81
+ # Timestamp
82
+ doc.add_paragraph()
83
+ timestamp = datetime.now().strftime("Generated %Y-%m-%d %H:%M:%S")
84
+ footer = doc.add_paragraph(timestamp)
85
+ for r in footer.runs:
86
+ r.font.size = Pt(8)
87
+
88
+ # Save document
89
+ doc.save(path)
90
+ return path
requirements.txt CHANGED
@@ -10,4 +10,6 @@ beautifulsoup4>=4.12.0
10
 
11
  # Allow the agent to fetch YouTube video transcripts for summarization
12
  youtube-transcript-api>=1.0.0
 
 
13
  python-docx>=1.1.0
 
10
 
11
  # Allow the agent to fetch YouTube video transcripts for summarization
12
  youtube-transcript-api>=1.0.0
13
+
14
+ # Required to generate Word documents from course outlines
15
  python-docx>=1.1.0