Sunday, November 09, 2025

(Part 2) The Power of Agentic AI: Transforming Unstructured Notes into Structured Documents




In this part 2 of the Patterns for Agentic AI series we are going to apply the patterns presented in part 1 to a concrete example.

The rapid advancements in Large Language Models (LLMs) have opened new avenues for automating complex cognitive tasks. Beyond simple question-answering, LLMs can now serve as the "brain" for intelligent software agents, leading to the emergence of LLM Agentic AI systems. These systems are designed to perform multi-step, goal-oriented tasks autonomously, often by orchestrating several specialized AI components. One particularly useful application is the transformation of unstructured information, such as machine-written or even raw hand-written notes (via copy-paste), into highly structured and presentable documents. This article delves into the design and implementation of such an LLM Agentic AI system, leveraging established design and architecture patterns to ensure robustness, maintainability, and scalability. Our goal is to explain how to build a system that takes arbitrary notes and produces a well-formatted Microsoft Word document complete with a title, a concise summary, and logically organized paragraphs.

At its core, this system acts as an intelligent assistant, streamlining the process of document creation from disparate pieces of information. It addresses the common challenge faced by engineers and professionals: converting rough ideas or meeting minutes into polished reports or summaries. By applying a structured approach rooted in agent-oriented design principles, we can build a system that is not only effective but also transparent in its operation and extensible for future enhancements.


System Overview: The Orchestrator-Agent Pattern in Action

To manage the complexity of transforming raw notes into a structured document, we employ the Orchestrator-Agent pattern as the foundational architectural choice. This pattern establishes a clear hierarchy and flow of control, making the overall system comprehensible and manageable. A central orchestrator agent takes charge of the entire workflow, delegating specific sub-tasks to specialized worker agents. This approach ensures that each part of the process, from input handling to LLM processing and document generation, is handled by a dedicated, expert component.

The orchestrator serves as the conductor of our intelligent system. It receives the initial request, which in this case is the raw notes provided by the user. Upon receiving these notes, the orchestrator breaks down the overarching goal of "create structured document" into a sequence of smaller, manageable sub-goals. It then dispatches these sub-goals to various specialized agents, monitors their progress, and integrates their results to achieve the final outcome. This centralized coordination simplifies error handling and provides a single point of control for the entire document generation pipeline.

Here is a conceptual representation of the Orchestrator's role and how it might initiate the process:


  class OrchestratorAgent:

      def __init__(self, agent_manager, blackboard):

          self.agent_manager = agent_manager

          self.blackboard = blackboard

          self.workflow_steps = [

              "perceive_notes",

              "process_notes_with_llm",

              "generate_word_document"

          ]


      def start_document_creation_workflow(self, raw_notes_input):

          print("Orchestrator: Starting document creation workflow.")

          self.blackboard.post_data("raw_notes", raw_notes_input)

          self._execute_workflow()


      def _execute_workflow(self):

          for step in self.workflow_steps:

              if step == "perceive_notes":

                  # In a real system, this would involve waiting for input agent

                  # For copy-paste, the raw_notes are already on the blackboard.

                  print("Orchestrator: Notes perceived and available on blackboard.")

                  continue # Already handled by initial start_document_creation_workflow call


              elif step == "process_notes_with_llm":

                  print("Orchestrator: Requesting LLM processing of notes.")

                  llm_agent = self.agent_manager.get_agent_by_role("LLMProcessor")

                  if llm_agent:

                      llm_agent.process_notes_task(self.blackboard.get_data("raw_notes"))

                      # Orchestrator would wait for LLM agent to post results to blackboard

                      # For simplicity, assuming synchronous completion here for demo

                  else:

                      print("Orchestrator: Error - LLMProcessor agent not found.")

                      return


              elif step == "generate_word_document":

                  print("Orchestrator: Requesting Word document generation.")

                  doc_agent = self.agent_manager.get_agent_by_role("DocumentGenerator")

                  if doc_agent:

                      title = self.blackboard.get_data("document_title")

                      summary = self.blackboard.get_data("document_summary")

                      paragraphs = self.blackboard.get_data("document_paragraphs")

                      if title and summary and paragraphs:

                          doc_agent.generate_document_task(title, summary, paragraphs)

                      else:

                          print("Orchestrator: Error - Missing processed data for document generation.")

                  else:

                      print("Orchestrator: Error - DocumentGenerator agent not found.")

                      return


          print("Orchestrator: Document creation workflow completed.")


  # Placeholder for AgentManager and Blackboard, detailed later

  class AgentManager:

      def __init__(self):

          self.agents = {}

      def register_agent(self, role, agent_instance):

          self.agents[role] = agent_instance

      def get_agent_by_role(self, role):

          return self.agents.get(role)


  class Blackboard:

      def __init__(self):

          self.data = {}

      def post_data(self, key, value):

          self.data[key] = value

          print(f"Blackboard: Data '{key}' posted.")

      def get_data(self, key):

          return self.data.get(key)


The above code snippet illustrates the `OrchestratorAgent` which is initialized with an `AgentManager` to find its worker agents and a `Blackboard` for shared data. The `start_document_creation_workflow` method is the entry point, placing the raw notes onto the blackboard and then sequentially executing predefined workflow steps. Each step involves finding the appropriate specialized agent via the `AgentManager` and instructing it to perform its task. This structure clearly delineates responsibilities and the flow of control.


Core Agent Design: Perception-Reasoning-Action (PRA) Loop and Goal-Oriented Agent

Each specialized agent within our system, whether it is processing notes or generating documents, adheres to the Perception-Reasoning-Action (PRA) Loop pattern. This fundamental design pattern describes the continuous cycle of an intelligent agent's operation: perceive the environment, reason about the perceptions and internal state, and then act upon the environment. This loop provides a robust and adaptive framework for individual agent behavior.

Furthermore, these agents are designed as Goal-Oriented Agents. The orchestrator, or even the agents themselves, can set specific goals for their operation. For instance, the LLM processing agent will have the goal of "extract structured information from notes," while the document generation agent will aim to "create a Word document." The agent's reasoning component will then select and execute plans to achieve these goals, adapting its behavior as needed.

Consider the `LLMProcessorAgent` which embodies both the PRA loop and goal-oriented behavior. Its perception involves reading raw notes from the blackboard. Its reasoning involves formulating prompts for the LLM and interpreting the LLM's response. Its action involves posting the extracted title, summary, and paragraphs back to the blackboard. The goal of this agent is explicitly to transform unstructured text into structured components.

Here is a conceptual PRA loop within an LLM processing agent:


  class LLMProcessorAgent:

      def __init__(self, name, agent_manager, blackboard, llm_service):

          self.name = name

          self.agent_manager = agent_manager

          self.blackboard = blackboard

          self.llm_service = llm_service # Placeholder for actual LLM API client

          self.agent_manager.register_agent("LLMProcessor", self)


      def process_notes_task(self, raw_notes):

          print(f"LLMProcessorAgent {self.name}: Starting task to process notes.")

          self.current_notes = raw_notes

          self.goal = "Extract structured document components (title, summary, paragraphs)"

          self.run_pra_cycle()


      def run_pra_cycle(self):

          print(f"LLMProcessorAgent {self.name}: Executing PRA cycle for goal '{self.goal}'")

          

          # Perception Phase: Get raw notes (already passed in this case, or read from blackboard)

          notes_to_process = self.current_notes

          if not notes_to_process:

              print(f"LLMProcessorAgent {self.name}: No notes to perceive. Aborting cycle.")

              return


          # Reasoning Phase: Formulate prompt, interact with LLM, parse response

          print(f"LLMProcessorAgent {self.name}: Reasoning to extract info from notes...")

          try:

              # This is a simplification; a real LLM call would be asynchronous

              # and involve more sophisticated prompt engineering.

              llm_response = self._call_llm_for_extraction(notes_to_process)

              

              # Assume LLM response is structured JSON or similar for easy parsing

              extracted_data = self._parse_llm_response(llm_response)

              

              self.extracted_title = extracted_data.get("title")

              self.extracted_summary = extracted_data.get("summary")

              self.extracted_paragraphs = extracted_data.get("paragraphs")

              

              print(f"LLMProcessorAgent {self.name}: Extracted title: '{self.extracted_title}'")

              print(f"LLMProcessorAgent {self.name}: Extracted summary: '{self.extracted_summary}'")

              print(f"LLMProcessorAgent {self.name}: Extracted {len(self.extracted_paragraphs)} paragraphs.")


          except Exception as e:

              print(f"LLMProcessorAgent {self.name}: Error during LLM processing: {e}")

              # Post error to blackboard or notify orchestrator

              self.blackboard.post_data("error_llm_processing", str(e))

              return


          # Action Phase: Post results to blackboard

          print(f"LLMProcessorAgent {self.name}: Posting extracted data to blackboard.")

          self.blackboard.post_data("document_title", self.extracted_title)

          self.blackboard.post_data("document_summary", self.extracted_summary)

          self.blackboard.post_data("document_paragraphs", self.extracted_paragraphs)

          print(f"LLMProcessorAgent {self.name}: Task completed and results posted.")


      def _call_llm_for_extraction(self, notes):

          # Placeholder for actual LLM API call

          # In a real scenario, this would be an API call to OpenAI, Azure OpenAI, etc.

          # with a carefully crafted prompt.

          prompt = f"Analyze the following notes and extract a concise title, a summary, and logically separated paragraphs. Return in a structured format.\n\nNotes:\n{notes}"

          print(f"LLMProcessorAgent: Calling LLM with prompt (excerpt): '{prompt[:100]}...'")

          

          # Simulate LLM response

          simulated_response = {

              "title": "Summary of Meeting Notes on Project Alpha",

              "summary": "Key discussions included project scope, resource allocation, and next steps for Project Alpha. Decisions were made to prioritize feature X and assign John Doe as lead.",

              "paragraphs": [

                  "The meeting commenced with a review of the current status of Project Alpha, highlighting recent achievements in initial data collection. Participants discussed the unexpected delays encountered due to external dependency issues.",

                  "Resource allocation for the upcoming sprint was a major point of discussion. It was decided that additional engineering support would be required for Feature X, leading to a re-prioritization of some less critical tasks.",

                  "Next steps include a follow-up meeting to finalize the detailed implementation plan for Feature X, with John Doe taking the lead on coordinating the technical team. Deadlines for initial deliverables were set for next Friday."

              ]

          }

          return simulated_response


      def _parse_llm_response(self, llm_response):

          # In a real system, this would parse the actual LLM output string (e.g., JSON, XML)

          # For this example, we assume the _call_llm_for_extraction already returns a dict.

          return llm_response


The `LLMProcessorAgent` above demonstrates its PRA loop. It perceives the `raw_notes` (passed as `current_notes`), reasons by calling the `_call_llm_for_extraction` method (which simulates an LLM API call and parsing), and then acts by posting the `extracted_title`, `extracted_summary`, and `extracted_paragraphs` to the shared `blackboard`. This structured approach ensures that the agent consistently works towards its defined goal.


Handling Input: The Agent-Environment Interface (for Notes Input)

The initial step of our system involves receiving the user's notes. This interaction is modeled using the Agent-Environment Interface pattern. The "environment" in this context is the user's input stream, specifically the copy-paste mechanism. An `InputAgent` acts as the dedicated interface, abstracting away the specifics of how the notes are acquired and presenting them in a standardized format to the rest of the system.

While a true copy-paste listener might involve complex GUI integration, for a conceptual system, we can imagine the `InputAgent` providing a method where the user explicitly "pastes" the text. This agent's role is solely to perceive this input and make it available. It does not process the content itself but ensures it is correctly perceived from the "environment" (the user's clipboard or direct input) and then placed onto the shared blackboard for the orchestrator and other agents to access.

Here is how an `InputAgent` might be structured:


  class InputAgent:

      def __init__(self, name, agent_manager, blackboard):

          self.name = name

          self.agent_manager = agent_manager

          self.blackboard = blackboard

          self.agent_manager.register_agent("InputHandler", self)


      def receive_notes_from_user(self, notes_text):

          print(f"InputAgent {self.name}: Received notes from user (simulated copy-paste).")

          # Action Phase: Post raw notes to blackboard for others to perceive

          self.blackboard.post_data("raw_notes", notes_text)

          print(f"InputAgent {self.name}: Raw notes posted to blackboard.")

          # Notify Orchestrator (or Orchestrator polls blackboard)

          # For this example, Orchestrator is directly called after this.


The `InputAgent` above has a simple `receive_notes_from_user` method that simulates the copy-paste action. Its primary responsibility is to take this raw text and place it onto the `blackboard` under the key "raw_notes", making it accessible to the `OrchestratorAgent` and subsequently the `LLMProcessorAgent`.


Knowledge Sharing: Shared Blackboard Pattern

Central to the collaboration between different agents in our system is the Shared Blackboard pattern. This pattern provides a common, centralized data repository that all agents can read from and write to. It acts as the system's collective memory and communication hub, allowing agents to implicitly coordinate by observing changes to the shared data.

In our document generation system, the blackboard stores the raw input notes, the extracted title, summary, and paragraphs, and potentially any intermediate processing results or error messages. When the `LLMProcessorAgent` finishes its task, it writes the structured document components to the blackboard. The `DocumentGenerationAgent` then reads these components from the same blackboard to construct the final Word document. This decoupling means agents do not need direct knowledge of each other's existence; they only need to know how to interact with the blackboard.

The `Blackboard` class was introduced earlier, but its role in facilitating inter-agent communication is crucial. It ensures that data produced by one agent is readily available for consumption by another, enabling a flexible and loosely coupled architecture.


Document Generation: Agent Role

The final step in our workflow is to take the structured information (title, summary, paragraphs) and format it into a Microsoft Word document. This task is handled by a dedicated `DocumentGenerationAgent`, which embodies a specific Agent Role. This agent specializes solely in document creation and formatting, abstracting away the complexities of interacting with document generation libraries (like `python-docx` for Python).

The `DocumentGenerationAgent` will perceive the structured data from the shared blackboard, reason about the desired document structure (e.g., title first, then summary, then paragraphs), and then act by invoking the necessary Word document API calls to construct the file.

Here is the structure of the `DocumentGenerationAgent`:


  import os

  # Placeholder for a real Word document library like python-docx

  # from docx import Document

  # from docx.shared import Inches


  class DocumentGenerationAgent:

      def __init__(self, name, agent_manager, blackboard):

          self.name = name

          self.agent_manager = agent_manager

          self.blackboard = blackboard

          self.agent_manager.register_agent("DocumentGenerator", self)


      def generate_document_task(self, title, summary, paragraphs):

          print(f"DocumentGenerationAgent {self.name}: Starting task to generate Word document.")

          self.title = title

          self.summary = summary

          self.paragraphs = paragraphs

          self.run_pra_cycle()


      def run_pra_cycle(self):

          print(f"DocumentGenerationAgent {self.name}: Executing PRA cycle for document generation.")

          

          # Perception Phase: Data already provided by orchestrator, or read from blackboard

          if not (self.title and self.summary and self.paragraphs):

              print(f"DocumentGenerationAgent {self.name}: Missing data for document generation. Aborting.")

              return


          # Reasoning Phase: Determine document structure and formatting

          print(f"DocumentGenerationAgent {self.name}: Reasoning on document layout and content.")

          # In a real system, this might involve more complex templating logic

          document_content = self._assemble_document_content()


          # Action Phase: Create and save the Word document

          try:

              output_filename = "Generated_Notes_Summary.docx"

              self._create_word_document(output_filename, document_content)

              print(f"DocumentGenerationAgent {self.name}: Document '{output_filename}' generated successfully.")

              self.blackboard.post_data("generated_document_path", os.path.abspath(output_filename))

          except Exception as e:

              print(f"DocumentGenerationAgent {self.name}: Error generating document: {e}")

              self.blackboard.post_data("error_doc_generation", str(e))


      def _assemble_document_content(self):

          # This method prepares the content for the document library

          content = {

              "title": self.title,

              "summary": self.summary,

              "paragraphs": self.paragraphs

          }

          return content


      def _create_word_document(self, filename, content):

          # This is a placeholder for actual document creation using a library like python-docx

          # document = Document()

          # document.add_heading(content["title"], level=1)

          # document.add_paragraph(content["summary"])

          # document.add_paragraph("") # Add a blank line for separation

          # for para_text in content["paragraphs"]:

          #     document.add_paragraph(para_text)

          # document.save(filename)

          print(f"Simulating Word document creation for '{filename}' with title '{content['title']}'...")

          # Create a dummy file to show success

          with open(filename, "w") as f:

              f.write(f"--- {content['title']} ---\n\n")

              f.write(f"Summary:\n{content['summary']}\n\n")

              f.write("Details:\n")

              for p in content["paragraphs"]:

                  f.write(f"- {p}\n")

          print(f"Dummy Word document saved as '{filename}'.")


The `DocumentGenerationAgent` takes the extracted `title`, `summary`, and `paragraphs` as input. It then orchestrates the process of creating the Word document, including assembling the content and saving the file. The `_create_word_document` method contains comments indicating where actual `python-docx` library calls would be made, demonstrating how this agent encapsulates the document formatting logic.


Putting It All Together: The Workflow

Now, let us trace the complete workflow, demonstrating how these patterns interoperate to achieve the system's goal.


1.  User Input (Agent-Environment Interface): The user copies notes and "pastes" them into the system, which is perceived by the `InputAgent`. The `InputAgent` then places these raw notes onto the `Blackboard`.

2.  Workflow Initiation (Orchestrator-Agent): The `OrchestratorAgent` is notified (or periodically checks) that new `raw_notes` are available on the `Blackboard`. It then initiates its `start_document_creation_workflow`.

3.  LLM Processing (LLMProcessorAgent with PRA Loop and Goal-Oriented behavior): The `OrchestratorAgent` instructs the `LLMProcessorAgent` to process the notes. The `LLMProcessorAgent` retrieves the `raw_notes` from the `Blackboard`, formulates a prompt for the underlying LLM, sends the request, receives and parses the LLM's response (extracting title, summary, and paragraphs). Once processed, it posts these structured components back onto the `Blackboard`.

4.  Document Generation (DocumentGenerationAgent with Agent Role): The `OrchestratorAgent` then instructs the `DocumentGenerationAgent` to create the Word document. The `DocumentGenerationAgent` retrieves the `document_title`, `document_summary`, and `document_paragraphs` from the `Blackboard`. It then uses a document generation library to assemble and save the Microsoft Word file.

5.  Completion and Notification: The `DocumentGenerationAgent` notifies the `OrchestratorAgent` upon successful completion (or posts the file path to the blackboard). The `OrchestratorAgent` can then inform the user that the document is ready.


This sequence demonstrates a clear separation of concerns and a logical flow of information, all facilitated by the chosen design patterns.

Here is the main execution flow that ties all agents together:


  # Initialize core infrastructure components

  agent_manager = AgentManager()

  blackboard = Blackboard()


  # Initialize specialized agents, registering them with the manager

  # In a real system, LLMService would be an actual API client

  llm_service_client = None # Replace with actual LLM client

  input_agent = InputAgent("UserNotesInput", agent_manager, blackboard)

  llm_processor = LLMProcessorAgent("LLMBrain", agent_manager, blackboard, llm_service_client)

  doc_generator = DocumentGenerationAgent("WordDocFormatter", agent_manager, blackboard)


  # Initialize the Orchestrator

  orchestrator = OrchestratorAgent(agent_manager, blackboard)


  # Simulate user providing notes via copy-paste

  user_notes = """

  Meeting held on 2023-10-26.

  Attendees: Alice, Bob, Charlie.

  Discussion points:

  - Project X launch delayed to next quarter due to testing issues.

  - New marketing campaign for Product Y approved. Budget increased by 15%.

  - Hiring freeze for Q4, review in Q1.

  - Action item: Bob to investigate testing bottleneck for Project X.

  - Action item: Alice to draft marketing plan for Product Y by end of week.

  """

  print("\n--- Simulating User Input ---")

  input_agent.receive_notes_from_user(user_notes)


  print("\n--- Orchestrator Kicks Off Workflow ---")

  orchestrator.start_document_creation_workflow(user_notes)


  print("\n--- Workflow Complete ---")

  final_doc_path = blackboard.get_data("generated_document_path")

  if final_doc_path:

      print(f"Final document available at: {final_doc_path}")

  else:

      print("Document generation failed or path not found on blackboard.")


This main execution block demonstrates the instantiation of all agents and the blackboard, followed by the simulation of user input and the initiation of the workflow by the orchestrator. The print statements throughout the agents illustrate the flow of control and data between them, showcasing the practical application of the discussed patterns.


Refinement and Robustness: Agent Lifecycle Management and Error Handling

For a production-ready system, the Agent Lifecycle Management pattern would be crucial. This involves mechanisms for creating, starting, suspending, and terminating agents. Our `AgentManager` class, while simple, lays the groundwork for this by registering and providing access to agents. In a more complex system, it would also handle agent instantiation, resource allocation, and monitoring their health.

Error handling is paramount. Each agent's PRA loop should include robust error handling within its reasoning and action phases. For instance, if the LLM service fails to respond or returns an unparseable output, the `LLMProcessorAgent` should catch this, log the error, and post an error message to the `Blackboard`. The `OrchestratorAgent` would then monitor the blackboard for such error messages and decide on a recovery strategy, such as retrying the task, notifying the user, or escalating the issue. This makes the system resilient to transient failures and provides clear feedback on issues.


Conclusion

Building an LLM Agentic AI system to transform unstructured notes into structured documents is a powerful application of modern AI capabilities. By systematically applying architectural and design patterns such as Orchestrator-Agent, Perception-Reasoning-Action Loop, Goal-Oriented Agent, Agent-Environment Interface, Agent Role, and Shared Blackboard, we can construct a robust, modular, and maintainable solution. These patterns provide a common language and proven solutions for addressing the complexities inherent in multi-agent systems, from managing inter-agent communication to orchestrating complex workflows. The resulting system not only automates a tedious task but also serves as a clear example of how thoughtful software engineering principles can unlock the full potential of advanced AI models, making them practical tools for everyday productivity.

No comments: