gohumanloop 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ from abc import ABC
2
+ import re
3
+ from typing import Dict, Any, Optional, List
4
+ import asyncio
5
+ import json
6
+ import uuid
7
+ from datetime import datetime
8
+ from collections import defaultdict
9
+ from gohumanloop.utils.threadsafedict import ThreadSafeDict
10
+
11
+ from gohumanloop.core.interface import (
12
+ HumanLoopProvider, HumanLoopResult, HumanLoopStatus, HumanLoopType
13
+ )
14
+
15
+ class BaseProvider(HumanLoopProvider, ABC):
16
+ """Base implementation of human-in-the-loop provider"""
17
+
18
+
19
+ def __init__(self, name: str, config: Optional[Dict[str, Any]] = None):
20
+ self.config = config or {}
21
+ # Custom name, will use UUID if not provided
22
+ self.name = name
23
+ # Store request information using (conversation_id, request_id) as key
24
+ self._requests = ThreadSafeDict() # Using thread-safe dictionary to store request information
25
+ # Store conversation information, including request list and latest request ID
26
+ self._conversations = {}
27
+ # For quick lookup of requests in conversations
28
+ self._conversation_requests = defaultdict(list)
29
+ # Store timeout tasks
30
+ self._timeout_tasks = {}
31
+
32
+ self.prompt_template = self.config.get("prompt_template", "{context}")
33
+
34
+ def __str__(self) -> str:
35
+ """Returns a string description of this instance"""
36
+ total_conversations = len(self._conversations)
37
+ total_requests = len(self._requests)
38
+ active_requests = sum(1 for req in self._requests.values()
39
+ if req["status"] in [HumanLoopStatus.PENDING, HumanLoopStatus.INPROGRESS])
40
+
41
+ return (f"conversations={total_conversations}, "
42
+ f"total_requests={total_requests}, "
43
+ f"active_requests={active_requests})")
44
+
45
+ def __repr__(self) -> str:
46
+ """Returns a detailed string representation of this instance"""
47
+ return self.__str__()
48
+
49
+ def _generate_request_id(self) -> str:
50
+ """Generates a unique request ID"""
51
+ return str(uuid.uuid4())
52
+
53
+ def _update_request_status_error(
54
+ self,
55
+ conversation_id: str,
56
+ request_id: str,
57
+ error: Optional[str] = None,
58
+ ):
59
+ """Update request status"""
60
+ request_key = (conversation_id, request_id)
61
+ if request_key in self._requests:
62
+ self._requests[request_key]["status"] = HumanLoopStatus.ERROR
63
+ self._requests[request_key]["error"] = error
64
+
65
+ def _store_request(
66
+ self,
67
+ conversation_id: str,
68
+ request_id: str,
69
+ task_id: str,
70
+ loop_type: HumanLoopType,
71
+ context: Dict[str, Any],
72
+ metadata: Dict[str, Any],
73
+ timeout: Optional[int],
74
+ ) -> None:
75
+ """Store request information"""
76
+ # Store request information using tuple (conversation_id, request_id) as key
77
+ self._requests[(conversation_id, request_id)] = {
78
+ "task_id": task_id,
79
+ "loop_type": loop_type,
80
+ "context": context,
81
+ "metadata": metadata,
82
+ "created_at": datetime.now().isoformat(),
83
+ "status": HumanLoopStatus.PENDING,
84
+ "timeout": timeout,
85
+ }
86
+
87
+ # Update conversation information
88
+ if conversation_id not in self._conversations:
89
+ self._conversations[conversation_id] = {
90
+ "task_id": task_id,
91
+ "latest_request_id": None,
92
+ "created_at": datetime.now().isoformat(),
93
+ }
94
+
95
+ # Add request to conversation request list
96
+ self._conversation_requests[conversation_id].append(request_id)
97
+ # Update latest request ID
98
+ self._conversations[conversation_id]["latest_request_id"] = request_id
99
+
100
+ def _get_request(self, conversation_id: str, request_id: str) -> Optional[Dict[str, Any]]:
101
+ """Get request information"""
102
+ return self._requests.get((conversation_id, request_id))
103
+
104
+ def _get_conversation(self, conversation_id: str) -> Optional[Dict[str, Any]]:
105
+ """Get conversation information"""
106
+ return self._conversations.get(conversation_id)
107
+
108
+ def _get_conversation_requests(self, conversation_id: str) -> List[str]:
109
+ """Get all request IDs in the conversation"""
110
+ return self._conversation_requests.get(conversation_id, [])
111
+
112
+ async def request_humanloop(
113
+ self,
114
+ task_id: str,
115
+ conversation_id: str,
116
+ loop_type: HumanLoopType,
117
+ context: Dict[str, Any],
118
+ metadata: Optional[Dict[str, Any]] = None,
119
+ timeout: Optional[int] = None
120
+ ) -> HumanLoopResult:
121
+ """Request human-in-the-loop interaction
122
+
123
+ Args:
124
+ task_id: Task identifier
125
+ conversation_id: Conversation ID for multi-turn dialogues
126
+ loop_type: Type of human loop interaction
127
+ context: Context information provided to human
128
+ metadata: Additional metadata
129
+ timeout: Request timeout in seconds
130
+
131
+ Returns:
132
+ HumanLoopResult: Result object containing request ID and initial status
133
+ """
134
+ # Subclasses must implement this method
135
+ raise NotImplementedError("Subclasses must implement request_humanloop")
136
+
137
+
138
+ async def check_request_status(
139
+ self,
140
+ conversation_id: str,
141
+ request_id: str
142
+ ) -> HumanLoopResult:
143
+ """Check request status
144
+
145
+ Args:
146
+ conversation_id: Conversation identifier for multi-turn dialogues
147
+ request_id: Request identifier for specific interaction request
148
+
149
+ Returns:
150
+ HumanLoopResult: Result object containing current request status, including status, response data, etc.
151
+ """
152
+ request_info = self._get_request(conversation_id, request_id)
153
+ if not request_info:
154
+ return HumanLoopResult(
155
+ conversation_id=conversation_id,
156
+ request_id=request_id,
157
+ loop_type=HumanLoopType.CONVERSATION,
158
+ status=HumanLoopStatus.ERROR,
159
+ error=f"Request '{request_id}' not found in conversation '{conversation_id}'"
160
+ )
161
+
162
+ # Subclasses need to implement specific status check logic
163
+ raise NotImplementedError("Subclasses must implement check_request_status")
164
+
165
+
166
+ async def check_conversation_status(
167
+ self,
168
+ conversation_id: str
169
+ ) -> HumanLoopResult:
170
+ """Check conversation status
171
+
172
+ Args:
173
+ conversation_id: Conversation identifier
174
+
175
+ Returns:
176
+ HumanLoopResult: Result containing the status of the latest request in the conversation
177
+ """
178
+ conversation_info = self._get_conversation(conversation_id)
179
+ if not conversation_info:
180
+ return HumanLoopResult(
181
+ conversation_id=conversation_id,
182
+ request_id="",
183
+ loop_type=HumanLoopType.CONVERSATION,
184
+ status=HumanLoopStatus.ERROR,
185
+ error=f"Conversation '{conversation_id}' not found"
186
+ )
187
+
188
+ latest_request_id = conversation_info.get("latest_request_id")
189
+ if not latest_request_id:
190
+ return HumanLoopResult(
191
+ conversation_id=conversation_id,
192
+ request_id="",
193
+ loop_type=HumanLoopType.CONVERSATION,
194
+ status=HumanLoopStatus.ERROR,
195
+ error=f"No requests found in conversation '{conversation_id}'"
196
+ )
197
+
198
+ return await self.check_request_status(conversation_id, latest_request_id)
199
+
200
+ async def cancel_request(
201
+ self,
202
+ conversation_id: str,
203
+ request_id: str
204
+ ) -> bool:
205
+ """Cancel human-in-the-loop request
206
+
207
+ Args:
208
+ conversation_id: Conversation identifier for multi-turn dialogues
209
+ request_id: Request identifier for specific interaction request
210
+
211
+ Returns:
212
+ bool: Whether cancellation was successful, True indicates success, False indicates failure
213
+ """
214
+
215
+ # Cancel timeout task
216
+ if (conversation_id, request_id) in self._timeout_tasks:
217
+ self._timeout_tasks[(conversation_id, request_id)].cancel()
218
+ del self._timeout_tasks[(conversation_id, request_id)]
219
+
220
+ request_key = (conversation_id, request_id)
221
+ if request_key in self._requests:
222
+ # Update request status to cancelled
223
+ self._requests[request_key]["status"] = HumanLoopStatus.CANCELLED
224
+ return True
225
+ return False
226
+
227
+ async def cancel_conversation(
228
+ self,
229
+ conversation_id: str
230
+ ) -> bool:
231
+ """Cancel the entire conversation
232
+
233
+ Args:
234
+ conversation_id: Conversation identifier
235
+
236
+ Returns:
237
+ bool: Whether the cancellation was successful
238
+ """
239
+ if conversation_id not in self._conversations:
240
+ return False
241
+
242
+ # Cancel all requests in the conversation
243
+ success = True
244
+ for request_id in self._get_conversation_requests(conversation_id):
245
+ request_key = (conversation_id, request_id)
246
+ if request_key in self._requests:
247
+ # Update request status to cancelled
248
+ # Only requests in intermediate states (PENDING/IN_PROGRESS) can be cancelled
249
+ if self._requests[request_key]["status"] in [HumanLoopStatus.PENDING, HumanLoopStatus.INPROGRESS]:
250
+ self._requests[request_key]["status"] = HumanLoopStatus.CANCELLED
251
+
252
+ # Cancel the timeout task for this request
253
+ if request_key in self._timeout_tasks:
254
+ self._timeout_tasks[request_key].cancel()
255
+ del self._timeout_tasks[request_key]
256
+ else:
257
+ success = False
258
+
259
+ return success
260
+
261
+ async def continue_humanloop(
262
+ self,
263
+ conversation_id: str,
264
+ context: Dict[str, Any],
265
+ metadata: Optional[Dict[str, Any]] = None,
266
+ timeout: Optional[int] = None,
267
+ ) -> HumanLoopResult:
268
+ """Continue human-in-the-loop interaction
269
+
270
+ Args:
271
+ conversation_id: Conversation ID for multi-turn dialogues
272
+ context: Context information provided to human
273
+ metadata: Additional metadata
274
+ timeout: Request timeout in seconds
275
+
276
+ Returns:
277
+ HumanLoopResult: Result object containing request ID and status
278
+ """
279
+ # Check if conversation exists
280
+ conversation_info = self._get_conversation(conversation_id)
281
+ if not conversation_info:
282
+ return HumanLoopResult(
283
+ conversation_id=conversation_id,
284
+ request_id="",
285
+ loop_type=HumanLoopType.CONVERSATION,
286
+ status=HumanLoopStatus.ERROR,
287
+ error=f"Conversation '{conversation_id}' not found"
288
+ )
289
+
290
+ # Subclasses need to implement specific continuation logic
291
+ raise NotImplementedError("Subclasses must implement continue_humanloop")
292
+
293
+ def get_conversation_history(self, conversation_id: str) -> List[Dict[str, Any]]:
294
+ """Get complete history for the specified conversation
295
+
296
+ Args:
297
+ conversation_id: Conversation identifier
298
+
299
+ Returns:
300
+ List[Dict[str, Any]]: List of conversation history records, each containing request ID,
301
+ status, context, response and other information
302
+ """
303
+ conversation_history = []
304
+ for request_id in self._get_conversation_requests(conversation_id):
305
+ request_key = (conversation_id, request_id)
306
+ if request_key in self._requests:
307
+ request_info = self._requests[request_key]
308
+ conversation_history.append({
309
+ "request_id": request_id,
310
+ "status": request_info.get("status").value if request_info.get("status") else None,
311
+ "context": request_info.get("context"),
312
+ "response": request_info.get("response"),
313
+ "responded_by": request_info.get("responded_by"),
314
+ "responded_at": request_info.get("responded_at")
315
+ })
316
+ return conversation_history
317
+
318
+
319
+ def _create_timeout_task(
320
+ self,
321
+ conversation_id: str,
322
+ request_id: str,
323
+ timeout: int
324
+ ):
325
+ """Create timeout task
326
+
327
+ Args:
328
+ conversation_id: Conversation ID
329
+ request_id: Request ID
330
+ timeout: Timeout duration in seconds
331
+ """
332
+ async def timeout_task():
333
+ await asyncio.sleep(timeout)
334
+
335
+ # Check current status
336
+ request_info = self._get_request(conversation_id, request_id)
337
+ if not request_info:
338
+ return
339
+
340
+ current_status = request_info.get("status", HumanLoopStatus.PENDING)
341
+
342
+ # Only trigger timeout when status is PENDING
343
+ # INPROGRESS status means conversation is ongoing, should not be considered as timeout
344
+ if current_status == HumanLoopStatus.PENDING:
345
+ # Update request status to expired
346
+ request_info["status"] = HumanLoopStatus.EXPIRED
347
+ request_info["error"] = "Request timed out"
348
+ # If status is INPROGRESS, reset timeout task
349
+ elif current_status == HumanLoopStatus.INPROGRESS:
350
+ # For ongoing conversations, we can choose to extend the timeout
351
+ # Here we simply create a new timeout task with the same timeout duration
352
+ if (conversation_id, request_id) in self._timeout_tasks:
353
+ self._timeout_tasks[(conversation_id, request_id)].cancel()
354
+ new_task = asyncio.create_task(timeout_task())
355
+ self._timeout_tasks[(conversation_id, request_id)] = new_task
356
+
357
+ task = asyncio.create_task(timeout_task())
358
+ self._timeout_tasks[(conversation_id, request_id)] = task
359
+
360
+
361
+ def build_prompt(
362
+ self,
363
+ task_id: str,
364
+ conversation_id: str,
365
+ request_id: str,
366
+ loop_type: Any,
367
+ created_at: str,
368
+ context: Dict[str, Any],
369
+ metadata: Optional[Dict[str, Any]] = None,
370
+ color: Optional[bool] = None
371
+ ) -> str:
372
+ """
373
+ Dynamically generate prompt based on content, only showing sections with content,
374
+ and adapt to different terminal color display.
375
+ color: None=auto detect, True=force color, False=no color
376
+ """
377
+ # Auto detect if terminal supports ANSI colors
378
+ def _supports_color():
379
+ try:
380
+ import sys
381
+ if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
382
+ return False
383
+ import os
384
+ if os.name == "nt":
385
+ # Windows 10+ supports ANSI, older versions don't
386
+ return "ANSICON" in os.environ or "WT_SESSION" in os.environ
387
+ return True
388
+ except Exception:
389
+ return False
390
+
391
+ if color is None:
392
+ color = _supports_color()
393
+
394
+ # Define colors
395
+ if color:
396
+ COLOR_TITLE = "\033[94m" # bright blue
397
+ COLOR_RESET = "\033[0m"
398
+ else:
399
+ COLOR_TITLE = ""
400
+ COLOR_RESET = ""
401
+
402
+ lines = []
403
+ lines.append(f"{COLOR_TITLE}=== Task Information ==={COLOR_RESET}")
404
+ lines.append(f"Task ID: {task_id}")
405
+ lines.append(f"Conversation ID: {conversation_id}")
406
+ lines.append(f"Request ID: {request_id}")
407
+ lines.append(f"HumanLoop Type: {getattr(loop_type, 'value', loop_type)}")
408
+ lines.append(f"Created At: {created_at}")
409
+
410
+ if context.get("message"):
411
+ lines.append(f"\n{COLOR_TITLE}=== Main Context ==={COLOR_RESET}")
412
+ lines.append(json.dumps(context["message"], indent=2, ensure_ascii=False))
413
+
414
+ if context.get("additional"):
415
+ lines.append(f"\n{COLOR_TITLE}=== Additional Context ==={COLOR_RESET}")
416
+ lines.append(json.dumps(context["additional"], indent=2, ensure_ascii=False))
417
+
418
+ if metadata:
419
+ lines.append(f"\n{COLOR_TITLE}=== Metadata ==={COLOR_RESET}")
420
+ lines.append(json.dumps(metadata, indent=2, ensure_ascii=False))
421
+
422
+ if context.get("question"):
423
+ lines.append(f"\n{COLOR_TITLE}=== Question ==={COLOR_RESET}")
424
+ lines.append(str(context["question"]))
425
+
426
+ lines.append(f"\n{COLOR_TITLE}=== END ==={COLOR_RESET}")
427
+
428
+ return "\n".join(lines)