experiment with sse

This commit is contained in:
zachary62 2025-04-11 12:05:14 -04:00
parent 407cbdc49c
commit 9de04109d8
11 changed files with 618 additions and 0 deletions

View File

@ -0,0 +1,42 @@
# PocketFlow Hello World
Your first PocketFlow application! This simple example demonstrates how to create a basic PocketFlow app from scratch.
## Project Structure
```
.
├── docs/ # Documentation files
├── utils/ # Utility functions
├── flow.py # PocketFlow implementation
├── main.py # Main application entry point
└── README.md # Project documentation
```
## Setup
1. Create a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run the example:
```bash
python main.py
```
## What This Example Demonstrates
- How to create your first PocketFlow application
- Basic PocketFlow concepts and usage
- Simple example of PocketFlow's capabilities
## Additional Resources
- [PocketFlow Documentation](https://the-pocket.github.io/PocketFlow/)

View File

@ -0,0 +1,55 @@
# Human-in-the-Loop Web Service
## 1. Requirements
* **Goal:** Create a web service for task submission, processing, human review (Approve/Reject loop via UI), and finalization.
* **Interface:** Simple web UI (HTML/JS) for input, status display, and feedback buttons.
* **Backend:** Flask application using PocketFlow for workflow management.
* **Real-time Updates:** Use Server-Sent Events (SSE) to push status changes (pending, running, waiting_for_review, completed, failed) and intermediate results to the client without page reloads.
* **State:** Use in-memory storage for task state (Warning: Not suitable for production).
## 2. Flow Design
* **Core Pattern:** Workflow with a conditional loop based on human feedback. SSE for asynchronous status communication.
* **Nodes:**
1. `ProcessNode` (Regular): Takes input, executes the (simulated) task processing.
2. `ReviewNode` (Async): Waits for human feedback signaled via an `asyncio.Event`. Pushes "waiting\_for\_review" status to the SSE queue.
3. `ResultNode` (Regular): Marks the task as complete and logs the final result.
* **Shared Store (`shared` dict per task):**
* `task_input`: Initial data from user.
* `processed_output`: Result from `ProcessNode`.
* `feedback`: 'approved' or 'rejected' set by the `/feedback` endpoint.
* `review_event`: `asyncio.Event` used by `ReviewNode` to wait and `/feedback` to signal.
* `final_result`: The approved output.
* `current_attempt`: Tracks reprocessing count.
* `task_id`: Unique identifier for the task.
* **SSE Communication:** An `asyncio.Queue` (stored alongside the `shared` store in the server's global `tasks` dict, *not directly in PocketFlow's shared store*) is used per task. Nodes (or wrapper code) put status updates onto this queue. The `/stream` endpoint reads from the queue and sends SSE messages.
* **Mermaid Diagram:**
```mermaid
flowchart TD
Process[Process Task] -- "default" --> Review{Wait for Feedback}
Review -- "approved" --> Result[Final Result]
Review -- "rejected" --> Process
```
## 3. Utilities
For this specific example, the core "utility" is the processing logic itself. Let's simulate it with a simple function. The Flask server acts as the external interface.
* `process_task(input_data)`: A placeholder function. In a real scenario, this might call an LLM (`utils/call_llm.py`).
## 4. Node Design (Detailed)
* **`ProcessNode` (Node):**
* `prep`: Reads `task_input`, `current_attempt` from `shared`.
* `exec`: Calls `utils.process_task.process_task`.
* `post`: Writes `processed_output` to `shared`, increments `current_attempt`. Returns "default".
* **`ReviewNode` (AsyncNode):**
* `prep_async`: (As modified/wrapped by server.py) Reads `review_event`, `processed_output` from `shared`. **Puts "waiting\_for\_review" status onto the task's SSE queue.**
* `exec_async`: `await shared["review_event"].wait()`.
* `post_async`: Reads `feedback` from `shared`. Clears the event. Returns "approved" or "rejected". If approved, stores `processed_output` into `final_result`.
* **`ResultNode` (Node):**
* `prep`: Reads `final_result` from `shared`.
* `exec`: Prints/logs the final result.
* `post`: Returns `None` (ends flow).

View File

@ -0,0 +1,176 @@
import asyncio
import uuid
import json
import os
from flask import Flask, request, jsonify, render_template, send_from_directory, Response
from flow import create_feedback_flow
# --- Configuration ---
template_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'templates'))
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'static'))
app = Flask(__name__, template_folder=template_dir, static_folder=static_dir)
# --- State Management (In-Memory - NOT FOR PRODUCTION) ---
tasks = {} # task_id -> {"shared": dict, "status": str, "task_obj": asyncio.Task}
# --- Background Flow Runner ---
async def run_flow_background(task_id, flow, shared):
"""Runs the flow in background, uses queue in shared for SSE."""
if task_id not in tasks: return # Should not happen
queue = shared.get("sse_queue")
if not queue:
print(f"ERROR: Task {task_id} missing sse_queue in shared store!")
tasks[task_id]["status"] = "failed"
# Cannot easily report via SSE if queue is missing
return
tasks[task_id]["status"] = "running"
await queue.put({"status": "running"})
print(f"Task {task_id}: Flow starting.")
final_status = "unknown"
error_message = None
try:
await flow.run_async(shared)
# Check final state
if shared.get("final_result") is not None:
final_status = "completed"
else:
# If flow ends without setting final_result (e.g., error before ResultNode)
final_status = "finished_incomplete"
print(f"Task {task_id}: Flow finished with status: {final_status}")
except Exception as e:
final_status = "failed"
error_message = str(e)
print(f"Task {task_id}: Flow failed: {e}")
finally:
if task_id in tasks:
tasks[task_id]["status"] = final_status
final_update = {"status": final_status}
if final_status == "completed":
final_update["final_result"] = shared.get("final_result")
elif error_message:
final_update["error"] = error_message
await queue.put(final_update)
# Signal end of stream
await queue.put(None)
print(f"Task {task_id}: Background task ended. Final update put on queue.")
# --- Flask Routes ---
@app.route('/')
async def index():
return render_template('index.html')
@app.route('/static/<path:filename>')
async def static_files(filename):
return send_from_directory(app.static_folder, filename)
@app.route('/submit', methods=['POST'])
async def submit_task():
if not request.is_json or 'data' not in request.json:
return jsonify({"error": "Requires JSON with 'data' field"}), 400
task_id = str(uuid.uuid4())
feedback_event = asyncio.Event()
status_queue = asyncio.Queue() # Queue for SSE
# Initial shared state for the flow
shared = {
"task_input": request.json['data'],
"processed_output": None,
"feedback": None,
"review_event": feedback_event,
"sse_queue": status_queue, # Make queue accessible to nodes
"final_result": None,
"task_id": task_id
}
flow = create_feedback_flow()
# Store task state
tasks[task_id] = {
"shared": shared,
"status": "pending",
# "flow": flow, # Not strictly needed if we don't re-use it
"task_obj": None # Will hold the background task
}
await status_queue.put({"status": "pending", "task_id": task_id})
# Start flow execution in background
task_obj = asyncio.create_task(run_flow_background(task_id, flow, shared))
tasks[task_id]["task_obj"] = task_obj
print(f"Task {task_id}: Submitted.")
return jsonify({"message": "Task submitted", "task_id": task_id}), 202
@app.route('/feedback/<task_id>', methods=['POST'])
async def provide_feedback(task_id):
if task_id not in tasks:
return jsonify({"error": "Task not found"}), 404
task_info = tasks[task_id]
shared = task_info["shared"]
queue = shared.get("sse_queue")
async def report_error(message, status_code=400):
print(f"Task {task_id}: Feedback error - {message}")
if queue: await queue.put({"status": "feedback_error", "error": message})
return jsonify({"error": message}), status_code
if not request.is_json or 'feedback' not in request.json:
return await report_error("Requires JSON with 'feedback' field")
feedback = request.json.get('feedback')
if feedback not in ["approved", "rejected"]:
return await report_error("Invalid feedback value")
review_event = shared.get("review_event")
if not review_event or review_event.is_set():
return await report_error("Task not awaiting feedback or feedback already sent", 409)
print(f"Task {task_id}: Received feedback: {feedback}")
if queue: await queue.put({"status": "processing_feedback"})
tasks[task_id]["status"] = "processing_feedback"
shared["feedback"] = feedback
review_event.set() # Signal the waiting ReviewNode
return jsonify({"message": f"Feedback '{feedback}' received"}), 200
# --- SSE Endpoint ---
@app.route('/stream/<task_id>')
async def stream(task_id):
if task_id not in tasks or "sse_queue" not in tasks[task_id]["shared"]:
return Response("data: {\"status\": \"error\", \"error\": \"Task or queue not found\"}\n\n",
mimetype='text/event-stream', status=404)
queue = tasks[task_id]["shared"]["sse_queue"]
async def event_generator():
print(f"SSE Stream: Client connected for {task_id}")
try:
while True:
update = await queue.get()
if update is None: # Sentinel for end of stream
print(f"SSE Stream: Sentinel received for {task_id}, closing.")
yield f"data: {json.dumps({'status': 'stream_closed'})}\n\n"
break
sse_data = json.dumps(update)
print(f"SSE Stream: Sending for {task_id}: {sse_data}")
yield f"data: {sse_data}\n\n"
queue.task_done()
except asyncio.CancelledError:
print(f"SSE Stream: Client disconnected for {task_id}.")
finally:
print(f"SSE Stream: Generator finished for {task_id}.")
# Optional: Cleanup task entry after stream ends?
# if task_id in tasks: del tasks[task_id] # Careful if task state needed elsewhere
headers = {'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache'}
return Response(event_generator(), mimetype='text/event-stream', headers=headers)
# --- Main ---
# Use an ASGI server like Hypercorn: `hypercorn server:app`
if __name__ == '__main__':
print("Run using an ASGI server, e.g., 'hypercorn flask_server:app'")

View File

@ -0,0 +1,18 @@
from pocketflow import AsyncFlow
from nodes import ProcessNode, ReviewNode, ResultNode
def create_feedback_flow():
"""Creates the minimal feedback workflow."""
process_node = ProcessNode()
review_node = ReviewNode()
result_node = ResultNode()
# Define transitions
process_node >> review_node
review_node - "approved" >> result_node
review_node - "rejected" >> process_node # Loop back
# Create the AsyncFlow
flow = AsyncFlow(start=process_node)
print("Minimal feedback flow created.")
return flow

View File

@ -0,0 +1,16 @@
from flow import qa_flow
# Example main function
# Please replace this with your own main function
def main():
shared = {
"question": "In one sentence, what's the end of universe?",
"answer": None
}
qa_flow.run(shared)
print("Question:", shared["question"])
print("Answer:", shared["answer"])
if __name__ == "__main__":
main()

View File

@ -0,0 +1,79 @@
import asyncio
from pocketflow import Node, AsyncNode
from utils.process_task import process_task
class ProcessNode(Node):
def prep(self, shared):
task_input = shared.get("task_input", "No input")
print("ProcessNode Prep")
return task_input
def exec(self, prep_res):
return process_task(prep_res)
def post(self, shared, prep_res, exec_res):
shared["processed_output"] = exec_res
print("ProcessNode Post: Output stored.")
return "default" # Go to ReviewNode
class ReviewNode(AsyncNode):
async def prep_async(self, shared):
review_event = shared.get("review_event")
queue = shared.get("sse_queue") # Expect queue in shared
processed_output = shared.get("processed_output", "N/A")
if not review_event or not queue:
print("ERROR: ReviewNode Prep - Missing review_event or sse_queue in shared store!")
return None # Signal failure
# Push status update to SSE queue
status_update = {
"status": "waiting_for_review",
"output_to_review": processed_output
}
await queue.put(status_update)
print("ReviewNode Prep: Put 'waiting_for_review' on SSE queue.")
return review_event # Return event for exec_async
async def exec_async(self, prep_res):
review_event = prep_res
if not review_event:
print("ReviewNode Exec: Skipping wait (no event from prep).")
return
print("ReviewNode Exec: Waiting on review_event...")
await review_event.wait()
print("ReviewNode Exec: review_event set.")
async def post_async(self, shared, prep_res, exec_res):
feedback = shared.get("feedback")
print(f"ReviewNode Post: Processing feedback '{feedback}'")
# Clear the event for potential loops
review_event = shared.get("review_event")
if review_event:
review_event.clear()
shared["feedback"] = None # Reset feedback
if feedback == "approved":
shared["final_result"] = shared.get("processed_output")
print("ReviewNode Post: Action=approved")
return "approved"
else:
print("ReviewNode Post: Action=rejected")
return "rejected"
class ResultNode(Node):
def prep(self, shared):
print("ResultNode Prep")
return shared.get("final_result", "No final result.")
def exec(self, prep_res):
print(f"--- FINAL RESULT ---")
print(prep_res)
print(f"--------------------")
return prep_res
def post(self, shared, prep_res, exec_res):
print("ResultNode Post: Flow finished.")
return None # End flow

View File

@ -0,0 +1,3 @@
pocketflow>=0.0.1
flask
hypercorn

View File

@ -0,0 +1,13 @@
/* Minimal functional styling */
body { font-family: sans-serif; margin: 15px; background-color: #fdfdfd; }
.container, .status-container { background: #fff; padding: 15px; border: 1px solid #eee; margin-bottom: 15px; border-radius: 4px; max-width: 600px;}
textarea { width: 95%; padding: 8px; margin-bottom: 10px; border: 1px solid #ccc; font-size: 1em; min-height: 50px; }
button { padding: 8px 12px; margin-right: 5px; cursor: pointer; border: 1px solid #ccc; border-radius: 3px;}
button:disabled { cursor: not-allowed; opacity: 0.6; }
#task-id-display { font-size: 0.85em; color: #666; margin-bottom: 5px; }
#status-display { font-weight: bold; margin-bottom: 10px; padding: 8px; background-color: #f0f0f0; border-radius: 3px;}
.hidden { display: none; }
.review-box, .result-box { border: 1px solid #ddd; padding: 10px; margin-top: 10px; background-color: #f9f9f9; }
pre { background-color: #eee; padding: 10px; border: 1px solid #ddd; white-space: pre-wrap; word-wrap: break-word; max-height: 200px; overflow-y: auto;}
.approve { background-color: #d4edda; border-color: #c3e6cb; }
.reject { background-color: #f8d7da; border-color: #f5c6cb; }

View File

@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Minimal Feedback Task (SSE)</title>
<link rel="stylesheet" href="{{ url_for('static_files', filename='style.css') }}">
</head>
<body>
<h1>Minimal Task Submitter (SSE)</h1>
<div class="container">
<textarea id="task-input" rows="3" placeholder="Enter text to process..."></textarea>
<button id="submit-button">Submit</button>
</div>
<div class="status-container">
<h2>Status</h2>
<div id="task-id-display">Task ID: N/A</div>
<div id="status-display">Submit a task.</div>
<div id="review-section" class="hidden review-box">
<h3>Review Output</h3>
<pre id="review-output"></pre>
<button id="approve-button" class="feedback-button approve">Approve</button>
<button id="reject-button" class="feedback-button reject">Reject</button>
</div>
<div id="result-section" class="hidden result-box">
<h3>Final Result</h3>
<pre id="final-result"></pre>
</div>
</div>
<script>
const taskInput = document.getElementById('task-input');
const submitButton = document.getElementById('submit-button');
const taskIdDisplay = document.getElementById('task-id-display');
const statusDisplay = document.getElementById('status-display');
const reviewSection = document.getElementById('review-section');
const reviewOutput = document.getElementById('review-output');
const approveButton = document.getElementById('approve-button');
const rejectButton = document.getElementById('reject-button');
const resultSection = document.getElementById('result-section');
const finalResult = document.getElementById('final-result');
let currentTaskId = null;
let eventSource = null;
submitButton.addEventListener('click', handleSubmit);
approveButton.addEventListener('click', () => handleFeedback('approved'));
rejectButton.addEventListener('click', () => handleFeedback('rejected'));
async function handleSubmit() {
const data = taskInput.value.trim();
if (!data) return alert('Input is empty.');
resetUI();
statusDisplay.textContent = 'Submitting...';
submitButton.disabled = true;
try {
const response = await fetch('/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: data })
});
if (!response.ok) throw new Error(`Submit failed: ${response.status}`);
const result = await response.json();
currentTaskId = result.task_id;
taskIdDisplay.textContent = `Task ID: ${currentTaskId}`;
startSSEListener(currentTaskId);
} catch (error) {
console.error('Submit error:', error);
statusDisplay.textContent = `Submit Error: ${error.message}`;
resetUI();
} finally {
submitButton.disabled = false;
}
}
function startSSEListener(taskId) {
closeSSEListener(); // Close existing connection
eventSource = new EventSource(`/stream/${taskId}`);
eventSource.onmessage = handleSSEMessage;
eventSource.onerror = handleSSEError;
eventSource.onopen = () => console.log(`SSE connected for ${taskId}`);
}
function handleSSEMessage(event) {
console.log("SSE data:", event.data);
try {
const data = JSON.parse(event.data);
updateUI(data);
} catch (e) { console.error("SSE parse error:", e); }
}
function handleSSEError(error) {
console.error("SSE Error:", error);
statusDisplay.textContent = "Status stream error. Connection closed.";
closeSSEListener();
}
function closeSSEListener() {
if (eventSource) {
eventSource.close();
eventSource = null;
console.log("SSE connection closed.");
}
}
function updateUI(data) {
// Always update main status
statusDisplay.textContent = `Status: ${data.status || 'Unknown'}`;
// Hide sections, then show relevant one
reviewSection.classList.add('hidden');
resultSection.classList.add('hidden');
approveButton.disabled = false; // Re-enable by default
rejectButton.disabled = false;
switch(data.status) {
case 'waiting_for_review':
reviewOutput.textContent = data.output_to_review || '';
reviewSection.classList.remove('hidden');
break;
case 'processing_feedback':
approveButton.disabled = true; // Disable while processing
rejectButton.disabled = true;
break;
case 'completed':
finalResult.textContent = data.final_result || '';
resultSection.classList.remove('hidden');
closeSSEListener();
break;
case 'failed':
case 'feedback_error':
statusDisplay.textContent = `Status: ${data.status} - ${data.error || 'Unknown error'}`;
closeSSEListener();
break;
case 'finished_incomplete':
statusDisplay.textContent = `Status: Flow finished unexpectedly.`;
closeSSEListener();
break;
case 'stream_closed':
// Server closed the stream gracefully (usually after completed/failed)
if (!['completed', 'failed', 'finished_incomplete'].includes(tasks[currentTaskId]?.status)) {
statusDisplay.textContent = "Status: Connection closed by server.";
}
closeSSEListener();
break;
case 'pending':
case 'running':
// Just update status text, wait for next message
break;
}
}
async function handleFeedback(feedbackValue) {
if (!currentTaskId) return;
approveButton.disabled = true;
rejectButton.disabled = true;
statusDisplay.textContent = `Sending ${feedbackValue}...`; // Optimistic UI update
try {
const response = await fetch(`/feedback/${currentTaskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback: feedbackValue })
});
if (!response.ok) { // Rely on SSE for status change or error reporting
const errorData = await response.json().catch(()=>({error: `Feedback failed: ${response.status}`}));
throw new Error(errorData.error);
}
console.log(`Feedback ${feedbackValue} POST successful.`);
// Successful POST - wait for SSE to update status to 'processing', then 'running' etc.
} catch (error) {
console.error('Feedback error:', error);
statusDisplay.textContent = `Feedback Error: ${error.message}`;
// Re-enable buttons if feedback POST failed
approveButton.disabled = false;
rejectButton.disabled = false;
}
}
function resetUI() {
closeSSEListener();
currentTaskId = null;
taskIdDisplay.textContent = 'Task ID: N/A';
statusDisplay.textContent = 'Submit a task.';
reviewSection.classList.add('hidden');
resultSection.classList.add('hidden');
taskInput.value = '';
submitButton.disabled = false;
approveButton.disabled = false;
rejectButton.disabled = false;
}
</script>
</body>
</html>

View File

@ -0,0 +1,16 @@
import time
def process_task(input_data):
"""Minimal simulation of processing the input data."""
print(f"Processing: '{input_data[:50]}...'")
# Simulate work
time.sleep(2)
processed_result = f"Processed: {input_data}"
print(f"Finished processing.")
return processed_result
# We don't need a separate utils/call_llm.py for this minimal example,
# but you would add it here if ProcessNode used an LLM.