botrun-flow-lang 5.12.263__py3-none-any.whl → 5.12.264__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.
Files changed (87) hide show
  1. botrun_flow_lang/api/auth_api.py +39 -39
  2. botrun_flow_lang/api/auth_utils.py +183 -183
  3. botrun_flow_lang/api/botrun_back_api.py +65 -65
  4. botrun_flow_lang/api/flow_api.py +3 -3
  5. botrun_flow_lang/api/hatch_api.py +508 -508
  6. botrun_flow_lang/api/langgraph_api.py +811 -811
  7. botrun_flow_lang/api/line_bot_api.py +1484 -1484
  8. botrun_flow_lang/api/model_api.py +300 -300
  9. botrun_flow_lang/api/rate_limit_api.py +32 -32
  10. botrun_flow_lang/api/routes.py +79 -79
  11. botrun_flow_lang/api/search_api.py +53 -53
  12. botrun_flow_lang/api/storage_api.py +395 -395
  13. botrun_flow_lang/api/subsidy_api.py +290 -290
  14. botrun_flow_lang/api/subsidy_api_system_prompt.txt +109 -109
  15. botrun_flow_lang/api/user_setting_api.py +70 -70
  16. botrun_flow_lang/api/version_api.py +31 -31
  17. botrun_flow_lang/api/youtube_api.py +26 -26
  18. botrun_flow_lang/constants.py +13 -13
  19. botrun_flow_lang/langgraph_agents/agents/agent_runner.py +178 -178
  20. botrun_flow_lang/langgraph_agents/agents/agent_tools/step_planner.py +77 -77
  21. botrun_flow_lang/langgraph_agents/agents/checkpointer/firestore_checkpointer.py +666 -666
  22. botrun_flow_lang/langgraph_agents/agents/gov_researcher/GOV_RESEARCHER_PRD.md +192 -192
  23. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gemini_subsidy_graph.py +460 -460
  24. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_2_graph.py +1002 -1002
  25. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_graph.py +822 -822
  26. botrun_flow_lang/langgraph_agents/agents/langgraph_react_agent.py +723 -723
  27. botrun_flow_lang/langgraph_agents/agents/search_agent_graph.py +864 -864
  28. botrun_flow_lang/langgraph_agents/agents/tools/__init__.py +4 -4
  29. botrun_flow_lang/langgraph_agents/agents/tools/gemini_code_execution.py +376 -376
  30. botrun_flow_lang/langgraph_agents/agents/util/gemini_grounding.py +66 -66
  31. botrun_flow_lang/langgraph_agents/agents/util/html_util.py +316 -316
  32. botrun_flow_lang/langgraph_agents/agents/util/img_util.py +294 -294
  33. botrun_flow_lang/langgraph_agents/agents/util/local_files.py +419 -419
  34. botrun_flow_lang/langgraph_agents/agents/util/mermaid_util.py +86 -86
  35. botrun_flow_lang/langgraph_agents/agents/util/model_utils.py +143 -143
  36. botrun_flow_lang/langgraph_agents/agents/util/pdf_analyzer.py +486 -486
  37. botrun_flow_lang/langgraph_agents/agents/util/pdf_cache.py +250 -250
  38. botrun_flow_lang/langgraph_agents/agents/util/pdf_processor.py +204 -204
  39. botrun_flow_lang/langgraph_agents/agents/util/perplexity_search.py +464 -464
  40. botrun_flow_lang/langgraph_agents/agents/util/plotly_util.py +59 -59
  41. botrun_flow_lang/langgraph_agents/agents/util/tavily_search.py +199 -199
  42. botrun_flow_lang/langgraph_agents/agents/util/youtube_util.py +90 -90
  43. botrun_flow_lang/langgraph_agents/cache/langgraph_botrun_cache.py +197 -197
  44. botrun_flow_lang/llm_agent/llm_agent.py +19 -19
  45. botrun_flow_lang/llm_agent/llm_agent_util.py +83 -83
  46. botrun_flow_lang/log/.gitignore +2 -2
  47. botrun_flow_lang/main.py +61 -61
  48. botrun_flow_lang/main_fast.py +51 -51
  49. botrun_flow_lang/mcp_server/__init__.py +10 -10
  50. botrun_flow_lang/mcp_server/default_mcp.py +744 -744
  51. botrun_flow_lang/models/nodes/utils.py +205 -205
  52. botrun_flow_lang/models/token_usage.py +34 -34
  53. botrun_flow_lang/requirements.txt +21 -21
  54. botrun_flow_lang/services/base/firestore_base.py +30 -30
  55. botrun_flow_lang/services/hatch/hatch_factory.py +11 -11
  56. botrun_flow_lang/services/hatch/hatch_fs_store.py +419 -419
  57. botrun_flow_lang/services/storage/storage_cs_store.py +206 -206
  58. botrun_flow_lang/services/storage/storage_factory.py +12 -12
  59. botrun_flow_lang/services/storage/storage_store.py +65 -65
  60. botrun_flow_lang/services/user_setting/user_setting_factory.py +9 -9
  61. botrun_flow_lang/services/user_setting/user_setting_fs_store.py +66 -66
  62. botrun_flow_lang/static/docs/tools/index.html +926 -926
  63. botrun_flow_lang/tests/api_functional_tests.py +1525 -1525
  64. botrun_flow_lang/tests/api_stress_test.py +357 -357
  65. botrun_flow_lang/tests/shared_hatch_tests.py +333 -333
  66. botrun_flow_lang/tests/test_botrun_app.py +46 -46
  67. botrun_flow_lang/tests/test_html_util.py +31 -31
  68. botrun_flow_lang/tests/test_img_analyzer.py +190 -190
  69. botrun_flow_lang/tests/test_img_util.py +39 -39
  70. botrun_flow_lang/tests/test_local_files.py +114 -114
  71. botrun_flow_lang/tests/test_mermaid_util.py +103 -103
  72. botrun_flow_lang/tests/test_pdf_analyzer.py +104 -104
  73. botrun_flow_lang/tests/test_plotly_util.py +151 -151
  74. botrun_flow_lang/tests/test_run_workflow_engine.py +65 -65
  75. botrun_flow_lang/tools/generate_docs.py +133 -133
  76. botrun_flow_lang/tools/templates/tools.html +153 -153
  77. botrun_flow_lang/utils/__init__.py +7 -7
  78. botrun_flow_lang/utils/botrun_logger.py +344 -344
  79. botrun_flow_lang/utils/clients/rate_limit_client.py +209 -209
  80. botrun_flow_lang/utils/clients/token_verify_client.py +153 -153
  81. botrun_flow_lang/utils/google_drive_utils.py +654 -654
  82. botrun_flow_lang/utils/langchain_utils.py +324 -324
  83. botrun_flow_lang/utils/yaml_utils.py +9 -9
  84. {botrun_flow_lang-5.12.263.dist-info → botrun_flow_lang-5.12.264.dist-info}/METADATA +1 -1
  85. botrun_flow_lang-5.12.264.dist-info/RECORD +102 -0
  86. botrun_flow_lang-5.12.263.dist-info/RECORD +0 -102
  87. {botrun_flow_lang-5.12.263.dist-info → botrun_flow_lang-5.12.264.dist-info}/WHEEL +0 -0
@@ -1,508 +1,508 @@
1
- from fastapi import APIRouter, HTTPException, Depends, Query, Body
2
- import logging
3
- from datetime import datetime, timezone
4
-
5
- from botrun_flow_lang.api.auth_utils import (
6
- CurrentUser,
7
- verify_admin_permission,
8
- verify_hatch_access,
9
- verify_hatch_owner,
10
- verify_jwt_token,
11
- verify_user_permission,
12
- )
13
- from botrun_flow_lang.api.user_setting_api import get_user_setting_store
14
-
15
- from botrun_flow_lang.services.hatch.hatch_factory import hatch_store_factory
16
-
17
- from botrun_flow_lang.services.hatch.hatch_fs_store import HatchFsStore
18
-
19
- from botrun_hatch.models.hatch import Hatch
20
-
21
- from typing import List
22
- from pydantic import BaseModel
23
-
24
- from botrun_flow_lang.services.user_setting.user_setting_fs_store import (
25
- UserSettingFsStore,
26
- )
27
-
28
- from botrun_flow_lang.utils.google_drive_utils import fetch_google_doc_content
29
-
30
- router = APIRouter()
31
-
32
-
33
- class HatchResponse(BaseModel):
34
- hatch: Hatch
35
- gdoc_update_success: bool = False
36
-
37
-
38
- async def get_hatch_store():
39
- return hatch_store_factory()
40
-
41
-
42
- @router.post("/hatch", response_model=HatchResponse)
43
- async def create_hatch(
44
- hatch: Hatch,
45
- current_user: CurrentUser = Depends(verify_jwt_token),
46
- store: HatchFsStore = Depends(get_hatch_store),
47
- ):
48
- # Verify user permission to create hatch for the specified user_id
49
- verify_user_permission(current_user, hatch.user_id)
50
-
51
- hatch.last_sync_gdoc_success = False
52
- # Process Google Doc logic if enabled
53
- if (
54
- hatch.enable_google_doc_link
55
- and hatch.google_doc_link
56
- and hatch.google_doc_link.strip()
57
- ):
58
- logging.info(
59
- f"Processing Google Doc link for hatch {hatch.id}: {hatch.google_doc_link}"
60
- )
61
-
62
- # Fetch content from Google Doc
63
- fetched_content = await fetch_google_doc_content(hatch.google_doc_link.strip())
64
-
65
- if fetched_content:
66
- # Update prompt_template with fetched content
67
- hatch.prompt_template = fetched_content
68
- # Update last_sync_gdoc_time with current UTC time
69
- hatch.last_sync_gdoc_time = datetime.now(timezone.utc).isoformat()
70
- hatch.last_sync_gdoc_success = True
71
- logging.info(
72
- f"Successfully updated prompt_template for hatch {hatch.id} from Google Doc"
73
- )
74
- else:
75
- # Log warning but continue with the operation
76
- logging.warning(
77
- f"Failed to fetch Google Doc content for hatch {hatch.id}, keeping existing prompt_template"
78
- )
79
- else:
80
- # If Google Doc link is disabled, clear last_sync_gdoc_time
81
- if not hatch.enable_google_doc_link:
82
- hatch.last_sync_gdoc_time = ""
83
-
84
- # Save to Firestore
85
- success, created_hatch = await store.set_hatch(hatch)
86
- if not success:
87
- raise HTTPException(status_code=500, detail="Failed to create hatch")
88
-
89
- return HatchResponse(
90
- hatch=created_hatch, gdoc_update_success=created_hatch.last_sync_gdoc_success
91
- )
92
-
93
-
94
- @router.put("/hatch/{hatch_id}", response_model=HatchResponse)
95
- async def update_hatch(
96
- hatch_id: str,
97
- hatch: Hatch,
98
- current_user: CurrentUser = Depends(verify_jwt_token),
99
- store: HatchFsStore = Depends(get_hatch_store),
100
- ):
101
- # Verify user is owner of the hatch
102
- await verify_hatch_owner(current_user, hatch_id, store)
103
-
104
- existing_hatch = await store.get_hatch(hatch_id)
105
- if not existing_hatch:
106
- raise HTTPException(status_code=404, detail="Hatch not found")
107
-
108
- hatch.id = hatch_id
109
- hatch.last_sync_gdoc_success = False
110
-
111
- # Process Google Doc logic if enabled
112
- if (
113
- hatch.enable_google_doc_link
114
- and hatch.google_doc_link
115
- and hatch.google_doc_link.strip()
116
- ):
117
- logging.info(
118
- f"Processing Google Doc link for hatch {hatch.id}: {hatch.google_doc_link}"
119
- )
120
-
121
- # Fetch content from Google Doc
122
- fetched_content = await fetch_google_doc_content(hatch.google_doc_link.strip())
123
-
124
- if fetched_content:
125
- # Update prompt_template with fetched content
126
- hatch.prompt_template = fetched_content
127
- # Update last_sync_gdoc_time with current UTC time
128
- hatch.last_sync_gdoc_time = datetime.now(timezone.utc).isoformat()
129
- hatch.last_sync_gdoc_success = True
130
- logging.info(
131
- f"Successfully updated prompt_template for hatch {hatch.id} from Google Doc"
132
- )
133
- else:
134
- # Log warning but continue with the operation
135
- # Keep existing last_sync_gdoc_time from the original hatch
136
- hatch.last_sync_gdoc_time = existing_hatch.last_sync_gdoc_time
137
- logging.warning(
138
- f"Failed to fetch Google Doc content for hatch {hatch.id}, keeping existing prompt_template and last_sync_gdoc_time"
139
- )
140
- else:
141
- # If Google Doc link is disabled, clear last_sync_gdoc_time
142
- if not hatch.enable_google_doc_link:
143
- hatch.last_sync_gdoc_time = ""
144
-
145
- # Save to Firestore
146
- success, updated_hatch = await store.set_hatch(hatch)
147
- if not success:
148
- raise HTTPException(status_code=500, detail="Failed to update hatch")
149
-
150
- return HatchResponse(
151
- hatch=updated_hatch, gdoc_update_success=updated_hatch.last_sync_gdoc_success
152
- )
153
-
154
-
155
- @router.delete("/hatch/{hatch_id}")
156
- async def delete_hatch(
157
- hatch_id: str,
158
- current_user: CurrentUser = Depends(verify_jwt_token),
159
- store: HatchFsStore = Depends(get_hatch_store),
160
- ):
161
- # Verify user is owner of the hatch
162
- await verify_hatch_owner(current_user, hatch_id, store)
163
-
164
- # Get the hatch to verify it exists
165
- hatch = await store.get_hatch(hatch_id)
166
- if not hatch:
167
- raise HTTPException(status_code=404, detail="Hatch not found")
168
-
169
- # Delete all sharing relationships for this hatch
170
- sharing_success, sharing_message = await store.delete_all_hatch_sharing(hatch_id)
171
- if not sharing_success:
172
- raise HTTPException(
173
- status_code=500,
174
- detail=f"Failed to delete hatch sharing relationships: {sharing_message}",
175
- )
176
-
177
- # Delete the hatch itself
178
- success = await store.delete_hatch(hatch_id)
179
- if not success:
180
- raise HTTPException(
181
- status_code=500,
182
- detail={"success": False, "message": f"Failed to delete hatch {hatch_id}"},
183
- )
184
- return {
185
- "success": True,
186
- "message": f"Hatch {hatch_id} and all sharing relationships deleted successfully",
187
- }
188
-
189
-
190
- @router.get("/hatch/{hatch_id}", response_model=Hatch)
191
- async def get_hatch(
192
- hatch_id: str,
193
- current_user: CurrentUser = Depends(verify_jwt_token),
194
- store: HatchFsStore = Depends(get_hatch_store),
195
- ):
196
- # Verify user has access to the hatch (owner or shared)
197
- await verify_hatch_access(current_user, hatch_id, store)
198
-
199
- hatch = await store.get_hatch(hatch_id)
200
- if not hatch:
201
- raise HTTPException(status_code=404, detail="Hatch not found")
202
- return hatch
203
-
204
-
205
- @router.post("/hatch/{hatch_id}/reload-template")
206
- async def reload_template_from_doc(
207
- hatch_id: str,
208
- current_user: CurrentUser = Depends(verify_jwt_token),
209
- store: HatchFsStore = Depends(get_hatch_store),
210
- ):
211
- """
212
- Reload prompt_template from linked Google Doc.
213
-
214
- This endpoint fetches the latest content from the Google Doc specified
215
- in the hatch's google_doc_link field and updates the prompt_template.
216
-
217
- Args:
218
- hatch_id: ID of the hatch to reload template for
219
-
220
- Returns:
221
- dict: Success status and message
222
-
223
- Raises:
224
- HTTPException:
225
- - 404 if hatch not found
226
- - 400 if Google Doc link feature is not enabled or no link configured
227
- - 500 if failed to fetch content or save hatch
228
- """
229
- try:
230
- # Verify user is owner of the hatch
231
- await verify_hatch_owner(current_user, hatch_id, store)
232
-
233
- # 1. Get the hatch
234
- hatch = await store.get_hatch(hatch_id)
235
- if not hatch:
236
- raise HTTPException(status_code=404, detail="Hatch not found")
237
-
238
- # 2. Check Google Doc configuration
239
- if not hatch.enable_google_doc_link:
240
- raise HTTPException(
241
- status_code=400,
242
- detail="Google Doc link feature is not enabled for this Hatch",
243
- )
244
-
245
- if not hatch.google_doc_link or not hatch.google_doc_link.strip():
246
- raise HTTPException(
247
- status_code=400, detail="No Google Doc link configured for this Hatch"
248
- )
249
-
250
- # 3. Fetch content from Google Doc
251
- fetched_content = await fetch_google_doc_content(hatch.google_doc_link.strip())
252
-
253
- if not fetched_content:
254
- raise HTTPException(
255
- status_code=500, detail="Failed to fetch content from Google Doc"
256
- )
257
-
258
- # 4. Update and save hatch
259
- hatch.prompt_template = fetched_content
260
- # Update last_sync_gdoc_time with current UTC time
261
- hatch.last_sync_gdoc_time = datetime.now(timezone.utc).isoformat()
262
- success, _ = await store.set_hatch(hatch)
263
-
264
- if not success:
265
- raise HTTPException(
266
- status_code=500, detail="Failed to save Hatch after reloading template"
267
- )
268
-
269
- return {
270
- "success": True,
271
- "message": "Prompt template successfully reloaded from Google Doc",
272
- }
273
-
274
- except HTTPException:
275
- # Re-raise HTTPException as-is
276
- raise
277
- except Exception as e:
278
- logging.error(f"Error reloading prompt template for hatch {hatch_id}: {e}")
279
- raise HTTPException(
280
- status_code=500, detail=f"An error occurred while reloading: {str(e)}"
281
- )
282
-
283
-
284
- @router.get("/hatches", response_model=List[Hatch])
285
- async def get_hatches(
286
- user_id: str,
287
- offset: int = Query(0, ge=0),
288
- limit: int = Query(20, ge=1, le=100),
289
- sort_by: str = Query("updated_at", description="Field to sort by (name, updated_at)"),
290
- order: str = Query("desc", regex="^(asc|desc)$", description="Sort order: asc or desc"),
291
- current_user: CurrentUser = Depends(verify_jwt_token),
292
- hatch_store=Depends(get_hatch_store),
293
- ):
294
- """Get hatches for a user with sorting options.
295
-
296
- Args:
297
- user_id: User ID to get hatches for
298
- offset: Pagination offset
299
- limit: Maximum number of results (1-100)
300
- sort_by: Field to sort by - only 'name' or 'updated_at' are supported (default: updated_at)
301
- order: Sort order - 'asc' or 'desc' (default: desc for newest first)
302
-
303
- Returns:
304
- List of hatches sorted by the specified field
305
-
306
- Raises:
307
- HTTPException: 400 if sort_by field is not supported
308
- """
309
- # Verify user permission to access hatches for the specified user_id
310
- verify_user_permission(current_user, user_id)
311
-
312
- # Validate sort_by field - only allow fields with Firestore indexes
313
- allowed_sort_fields = ["name", "updated_at"]
314
- if sort_by not in allowed_sort_fields:
315
- raise HTTPException(
316
- status_code=400,
317
- detail=f"Invalid sort_by field '{sort_by}'. Allowed fields: {', '.join(allowed_sort_fields)}",
318
- )
319
-
320
- hatches, error = await hatch_store.get_hatches(
321
- user_id, offset, limit, sort_by, order
322
- )
323
- if error:
324
- raise HTTPException(status_code=500, detail=error)
325
- return hatches
326
-
327
-
328
- @router.get("/hatch/default/{user_id}", response_model=Hatch)
329
- async def get_default_hatch(
330
- user_id: str,
331
- current_user: CurrentUser = Depends(verify_jwt_token),
332
- store: HatchFsStore = Depends(get_hatch_store),
333
- ):
334
- # Verify user permission to access default hatch for the specified user_id
335
- verify_user_permission(current_user, user_id)
336
-
337
- default_hatch = await store.get_default_hatch(user_id)
338
- if not default_hatch:
339
- raise HTTPException(status_code=404, detail="Default hatch not found")
340
- return default_hatch
341
-
342
-
343
- @router.post("/hatch/set_default")
344
- async def set_default_hatch(
345
- user_id: str = Body(...),
346
- hatch_id: str = Body(...),
347
- current_user: CurrentUser = Depends(verify_jwt_token),
348
- store: HatchFsStore = Depends(get_hatch_store),
349
- ):
350
- # Verify user permission to set default hatch for the specified user_id
351
- verify_user_permission(current_user, user_id)
352
-
353
- success, message = await store.set_default_hatch(user_id, hatch_id)
354
- if not success:
355
- raise HTTPException(status_code=500, detail=message)
356
- return {"success": True, "message": message}
357
-
358
-
359
- @router.get("/hatches/statistics")
360
- async def get_hatches_statistics(
361
- current_user: CurrentUser = Depends(verify_jwt_token),
362
- user_setting_store: UserSettingFsStore = Depends(get_user_setting_store),
363
- hatch_store: HatchFsStore = Depends(get_hatch_store),
364
- ):
365
- """Get statistics about hatches across all users.
366
-
367
- Returns:
368
- dict: Contains total hatch count and per-user hatch counts
369
- """
370
- # Verify admin permission
371
- verify_admin_permission(current_user)
372
-
373
- try:
374
- # Get all user IDs
375
- user_ids = await user_setting_store.get_all_user_ids()
376
-
377
- # Initialize statistics
378
- all_hatches = []
379
- total_count = 0
380
-
381
- # Get hatch counts for each user
382
- for user_id in user_ids:
383
- hatches, _ = await hatch_store.get_hatches(user_id)
384
- count = len(hatches)
385
- if count > 0: # Only include users who have hatches
386
- all_hatches.append({"user_id": user_id, "hatches_count": count})
387
- total_count += count
388
-
389
- return {"all_hatches_count": total_count, "all_hatches": all_hatches}
390
- except Exception as e:
391
- raise HTTPException(
392
- status_code=500, detail=f"Failed to fetch hatch statistics: {str(e)}"
393
- )
394
-
395
-
396
- @router.post("/hatch/{hatch_id}/share")
397
- async def share_hatch(
398
- hatch_id: str,
399
- user_id: str = Body(..., embed=True),
400
- store: HatchFsStore = Depends(get_hatch_store),
401
- ):
402
- """Share a hatch with another user.
403
-
404
- Args:
405
- hatch_id: ID of the hatch to share
406
- user_id: ID of the user to share the hatch with (in request body)
407
-
408
- Returns:
409
- dict: Success status and message
410
- """
411
-
412
- # Get the hatch to verify it exists and to get owner_id
413
- hatch = await store.get_hatch(hatch_id)
414
- if not hatch:
415
- raise HTTPException(status_code=404, detail="Hatch not found")
416
-
417
- # Share the hatch
418
- success, message = await store.share_hatch(hatch_id, hatch.user_id, user_id)
419
- if not success:
420
- raise HTTPException(status_code=400, detail=message)
421
-
422
- return {"success": True, "message": message}
423
-
424
-
425
- @router.delete("/hatch/{hatch_id}/share/{target_user_id}")
426
- async def unshare_hatch(
427
- hatch_id: str,
428
- target_user_id: str,
429
- store: HatchFsStore = Depends(get_hatch_store),
430
- ):
431
- """Remove sharing of a hatch with a user.
432
-
433
- Args:
434
- hatch_id: ID of the hatch to unshare
435
- target_user_id: ID of the user to remove sharing from
436
-
437
- Returns:
438
- dict: Success status and message
439
- """
440
-
441
- # Get the hatch to verify it exists and to get owner_id
442
- hatch = await store.get_hatch(hatch_id)
443
- if not hatch:
444
- raise HTTPException(status_code=404, detail="Hatch not found")
445
-
446
- # Unshare the hatch
447
- success, message = await store.unshare_hatch(
448
- hatch_id, hatch.user_id, target_user_id
449
- )
450
- if not success:
451
- raise HTTPException(status_code=400, detail=message)
452
-
453
- return {"success": True, "message": message}
454
-
455
-
456
- @router.get("/hatches/shared", response_model=List[Hatch])
457
- async def get_shared_hatches(
458
- user_id: str,
459
- offset: int = Query(0, ge=0),
460
- limit: int = Query(20, ge=1, le=100),
461
- current_user: CurrentUser = Depends(verify_jwt_token),
462
- store: HatchFsStore = Depends(get_hatch_store),
463
- ):
464
- """Get all hatches shared with a user.
465
-
466
- Args:
467
- user_id: ID of the user to get shared hatches for
468
- offset: Pagination offset
469
- limit: Maximum number of results to return
470
-
471
- Returns:
472
- List[Hatch]: List of shared hatches
473
- """
474
- # Verify user permission to access shared hatches for the specified user_id
475
- verify_user_permission(current_user, user_id)
476
-
477
- hatches, error = await store.get_shared_hatches(user_id, offset, limit)
478
- if error:
479
- raise HTTPException(status_code=500, detail=error)
480
-
481
- return hatches
482
-
483
-
484
- @router.get("/hatch/{hatch_id}/share/{user_id}")
485
- async def is_hatch_shared_with_user(
486
- hatch_id: str,
487
- user_id: str,
488
- store: HatchFsStore = Depends(get_hatch_store),
489
- ):
490
- """Check if a hatch is shared with a specific user.
491
-
492
- Args:
493
- hatch_id: ID of the hatch to check
494
- user_id: ID of the user to check sharing with
495
-
496
- Returns:
497
- dict: Whether the hatch is shared with the user and a message
498
- """
499
-
500
- # Get the hatch to verify it exists
501
- hatch = await store.get_hatch(hatch_id)
502
- if not hatch:
503
- raise HTTPException(status_code=404, detail="Hatch not found")
504
-
505
- # Check if the hatch is shared with the user
506
- is_shared, message = await store.is_hatch_shared_with_user(hatch_id, user_id)
507
-
508
- return {"is_shared": is_shared, "message": message}
1
+ from fastapi import APIRouter, HTTPException, Depends, Query, Body
2
+ import logging
3
+ from datetime import datetime, timezone
4
+
5
+ from botrun_flow_lang.api.auth_utils import (
6
+ CurrentUser,
7
+ verify_admin_permission,
8
+ verify_hatch_access,
9
+ verify_hatch_owner,
10
+ verify_jwt_token,
11
+ verify_user_permission,
12
+ )
13
+ from botrun_flow_lang.api.user_setting_api import get_user_setting_store
14
+
15
+ from botrun_flow_lang.services.hatch.hatch_factory import hatch_store_factory
16
+
17
+ from botrun_flow_lang.services.hatch.hatch_fs_store import HatchFsStore
18
+
19
+ from botrun_hatch.models.hatch import Hatch
20
+
21
+ from typing import List
22
+ from pydantic import BaseModel
23
+
24
+ from botrun_flow_lang.services.user_setting.user_setting_fs_store import (
25
+ UserSettingFsStore,
26
+ )
27
+
28
+ from botrun_flow_lang.utils.google_drive_utils import fetch_google_doc_content
29
+
30
+ router = APIRouter()
31
+
32
+
33
+ class HatchResponse(BaseModel):
34
+ hatch: Hatch
35
+ gdoc_update_success: bool = False
36
+
37
+
38
+ async def get_hatch_store():
39
+ return hatch_store_factory()
40
+
41
+
42
+ @router.post("/hatch", response_model=HatchResponse)
43
+ async def create_hatch(
44
+ hatch: Hatch,
45
+ current_user: CurrentUser = Depends(verify_jwt_token),
46
+ store: HatchFsStore = Depends(get_hatch_store),
47
+ ):
48
+ # Verify user permission to create hatch for the specified user_id
49
+ verify_user_permission(current_user, hatch.user_id)
50
+
51
+ hatch.last_sync_gdoc_success = False
52
+ # Process Google Doc logic if enabled
53
+ if (
54
+ hatch.enable_google_doc_link
55
+ and hatch.google_doc_link
56
+ and hatch.google_doc_link.strip()
57
+ ):
58
+ logging.info(
59
+ f"Processing Google Doc link for hatch {hatch.id}: {hatch.google_doc_link}"
60
+ )
61
+
62
+ # Fetch content from Google Doc
63
+ fetched_content = await fetch_google_doc_content(hatch.google_doc_link.strip())
64
+
65
+ if fetched_content:
66
+ # Update prompt_template with fetched content
67
+ hatch.prompt_template = fetched_content
68
+ # Update last_sync_gdoc_time with current UTC time
69
+ hatch.last_sync_gdoc_time = datetime.now(timezone.utc).isoformat()
70
+ hatch.last_sync_gdoc_success = True
71
+ logging.info(
72
+ f"Successfully updated prompt_template for hatch {hatch.id} from Google Doc"
73
+ )
74
+ else:
75
+ # Log warning but continue with the operation
76
+ logging.warning(
77
+ f"Failed to fetch Google Doc content for hatch {hatch.id}, keeping existing prompt_template"
78
+ )
79
+ else:
80
+ # If Google Doc link is disabled, clear last_sync_gdoc_time
81
+ if not hatch.enable_google_doc_link:
82
+ hatch.last_sync_gdoc_time = ""
83
+
84
+ # Save to Firestore
85
+ success, created_hatch = await store.set_hatch(hatch)
86
+ if not success:
87
+ raise HTTPException(status_code=500, detail="Failed to create hatch")
88
+
89
+ return HatchResponse(
90
+ hatch=created_hatch, gdoc_update_success=created_hatch.last_sync_gdoc_success
91
+ )
92
+
93
+
94
+ @router.put("/hatch/{hatch_id}", response_model=HatchResponse)
95
+ async def update_hatch(
96
+ hatch_id: str,
97
+ hatch: Hatch,
98
+ current_user: CurrentUser = Depends(verify_jwt_token),
99
+ store: HatchFsStore = Depends(get_hatch_store),
100
+ ):
101
+ # Verify user is owner of the hatch
102
+ await verify_hatch_owner(current_user, hatch_id, store)
103
+
104
+ existing_hatch = await store.get_hatch(hatch_id)
105
+ if not existing_hatch:
106
+ raise HTTPException(status_code=404, detail="Hatch not found")
107
+
108
+ hatch.id = hatch_id
109
+ hatch.last_sync_gdoc_success = False
110
+
111
+ # Process Google Doc logic if enabled
112
+ if (
113
+ hatch.enable_google_doc_link
114
+ and hatch.google_doc_link
115
+ and hatch.google_doc_link.strip()
116
+ ):
117
+ logging.info(
118
+ f"Processing Google Doc link for hatch {hatch.id}: {hatch.google_doc_link}"
119
+ )
120
+
121
+ # Fetch content from Google Doc
122
+ fetched_content = await fetch_google_doc_content(hatch.google_doc_link.strip())
123
+
124
+ if fetched_content:
125
+ # Update prompt_template with fetched content
126
+ hatch.prompt_template = fetched_content
127
+ # Update last_sync_gdoc_time with current UTC time
128
+ hatch.last_sync_gdoc_time = datetime.now(timezone.utc).isoformat()
129
+ hatch.last_sync_gdoc_success = True
130
+ logging.info(
131
+ f"Successfully updated prompt_template for hatch {hatch.id} from Google Doc"
132
+ )
133
+ else:
134
+ # Log warning but continue with the operation
135
+ # Keep existing last_sync_gdoc_time from the original hatch
136
+ hatch.last_sync_gdoc_time = existing_hatch.last_sync_gdoc_time
137
+ logging.warning(
138
+ f"Failed to fetch Google Doc content for hatch {hatch.id}, keeping existing prompt_template and last_sync_gdoc_time"
139
+ )
140
+ else:
141
+ # If Google Doc link is disabled, clear last_sync_gdoc_time
142
+ if not hatch.enable_google_doc_link:
143
+ hatch.last_sync_gdoc_time = ""
144
+
145
+ # Save to Firestore
146
+ success, updated_hatch = await store.set_hatch(hatch)
147
+ if not success:
148
+ raise HTTPException(status_code=500, detail="Failed to update hatch")
149
+
150
+ return HatchResponse(
151
+ hatch=updated_hatch, gdoc_update_success=updated_hatch.last_sync_gdoc_success
152
+ )
153
+
154
+
155
+ @router.delete("/hatch/{hatch_id}")
156
+ async def delete_hatch(
157
+ hatch_id: str,
158
+ current_user: CurrentUser = Depends(verify_jwt_token),
159
+ store: HatchFsStore = Depends(get_hatch_store),
160
+ ):
161
+ # Verify user is owner of the hatch
162
+ await verify_hatch_owner(current_user, hatch_id, store)
163
+
164
+ # Get the hatch to verify it exists
165
+ hatch = await store.get_hatch(hatch_id)
166
+ if not hatch:
167
+ raise HTTPException(status_code=404, detail="Hatch not found")
168
+
169
+ # Delete all sharing relationships for this hatch
170
+ sharing_success, sharing_message = await store.delete_all_hatch_sharing(hatch_id)
171
+ if not sharing_success:
172
+ raise HTTPException(
173
+ status_code=500,
174
+ detail=f"Failed to delete hatch sharing relationships: {sharing_message}",
175
+ )
176
+
177
+ # Delete the hatch itself
178
+ success = await store.delete_hatch(hatch_id)
179
+ if not success:
180
+ raise HTTPException(
181
+ status_code=500,
182
+ detail={"success": False, "message": f"Failed to delete hatch {hatch_id}"},
183
+ )
184
+ return {
185
+ "success": True,
186
+ "message": f"Hatch {hatch_id} and all sharing relationships deleted successfully",
187
+ }
188
+
189
+
190
+ @router.get("/hatch/{hatch_id}", response_model=Hatch)
191
+ async def get_hatch(
192
+ hatch_id: str,
193
+ current_user: CurrentUser = Depends(verify_jwt_token),
194
+ store: HatchFsStore = Depends(get_hatch_store),
195
+ ):
196
+ # Verify user has access to the hatch (owner or shared)
197
+ await verify_hatch_access(current_user, hatch_id, store)
198
+
199
+ hatch = await store.get_hatch(hatch_id)
200
+ if not hatch:
201
+ raise HTTPException(status_code=404, detail="Hatch not found")
202
+ return hatch
203
+
204
+
205
+ @router.post("/hatch/{hatch_id}/reload-template")
206
+ async def reload_template_from_doc(
207
+ hatch_id: str,
208
+ current_user: CurrentUser = Depends(verify_jwt_token),
209
+ store: HatchFsStore = Depends(get_hatch_store),
210
+ ):
211
+ """
212
+ Reload prompt_template from linked Google Doc.
213
+
214
+ This endpoint fetches the latest content from the Google Doc specified
215
+ in the hatch's google_doc_link field and updates the prompt_template.
216
+
217
+ Args:
218
+ hatch_id: ID of the hatch to reload template for
219
+
220
+ Returns:
221
+ dict: Success status and message
222
+
223
+ Raises:
224
+ HTTPException:
225
+ - 404 if hatch not found
226
+ - 400 if Google Doc link feature is not enabled or no link configured
227
+ - 500 if failed to fetch content or save hatch
228
+ """
229
+ try:
230
+ # Verify user is owner of the hatch
231
+ await verify_hatch_owner(current_user, hatch_id, store)
232
+
233
+ # 1. Get the hatch
234
+ hatch = await store.get_hatch(hatch_id)
235
+ if not hatch:
236
+ raise HTTPException(status_code=404, detail="Hatch not found")
237
+
238
+ # 2. Check Google Doc configuration
239
+ if not hatch.enable_google_doc_link:
240
+ raise HTTPException(
241
+ status_code=400,
242
+ detail="Google Doc link feature is not enabled for this Hatch",
243
+ )
244
+
245
+ if not hatch.google_doc_link or not hatch.google_doc_link.strip():
246
+ raise HTTPException(
247
+ status_code=400, detail="No Google Doc link configured for this Hatch"
248
+ )
249
+
250
+ # 3. Fetch content from Google Doc
251
+ fetched_content = await fetch_google_doc_content(hatch.google_doc_link.strip())
252
+
253
+ if not fetched_content:
254
+ raise HTTPException(
255
+ status_code=500, detail="Failed to fetch content from Google Doc"
256
+ )
257
+
258
+ # 4. Update and save hatch
259
+ hatch.prompt_template = fetched_content
260
+ # Update last_sync_gdoc_time with current UTC time
261
+ hatch.last_sync_gdoc_time = datetime.now(timezone.utc).isoformat()
262
+ success, _ = await store.set_hatch(hatch)
263
+
264
+ if not success:
265
+ raise HTTPException(
266
+ status_code=500, detail="Failed to save Hatch after reloading template"
267
+ )
268
+
269
+ return {
270
+ "success": True,
271
+ "message": "Prompt template successfully reloaded from Google Doc",
272
+ }
273
+
274
+ except HTTPException:
275
+ # Re-raise HTTPException as-is
276
+ raise
277
+ except Exception as e:
278
+ logging.error(f"Error reloading prompt template for hatch {hatch_id}: {e}")
279
+ raise HTTPException(
280
+ status_code=500, detail=f"An error occurred while reloading: {str(e)}"
281
+ )
282
+
283
+
284
+ @router.get("/hatches", response_model=List[Hatch])
285
+ async def get_hatches(
286
+ user_id: str,
287
+ offset: int = Query(0, ge=0),
288
+ limit: int = Query(20, ge=1, le=100),
289
+ sort_by: str = Query("updated_at", description="Field to sort by (name, updated_at)"),
290
+ order: str = Query("desc", regex="^(asc|desc)$", description="Sort order: asc or desc"),
291
+ current_user: CurrentUser = Depends(verify_jwt_token),
292
+ hatch_store=Depends(get_hatch_store),
293
+ ):
294
+ """Get hatches for a user with sorting options.
295
+
296
+ Args:
297
+ user_id: User ID to get hatches for
298
+ offset: Pagination offset
299
+ limit: Maximum number of results (1-100)
300
+ sort_by: Field to sort by - only 'name' or 'updated_at' are supported (default: updated_at)
301
+ order: Sort order - 'asc' or 'desc' (default: desc for newest first)
302
+
303
+ Returns:
304
+ List of hatches sorted by the specified field
305
+
306
+ Raises:
307
+ HTTPException: 400 if sort_by field is not supported
308
+ """
309
+ # Verify user permission to access hatches for the specified user_id
310
+ verify_user_permission(current_user, user_id)
311
+
312
+ # Validate sort_by field - only allow fields with Firestore indexes
313
+ allowed_sort_fields = ["name", "updated_at"]
314
+ if sort_by not in allowed_sort_fields:
315
+ raise HTTPException(
316
+ status_code=400,
317
+ detail=f"Invalid sort_by field '{sort_by}'. Allowed fields: {', '.join(allowed_sort_fields)}",
318
+ )
319
+
320
+ hatches, error = await hatch_store.get_hatches(
321
+ user_id, offset, limit, sort_by, order
322
+ )
323
+ if error:
324
+ raise HTTPException(status_code=500, detail=error)
325
+ return hatches
326
+
327
+
328
+ @router.get("/hatch/default/{user_id}", response_model=Hatch)
329
+ async def get_default_hatch(
330
+ user_id: str,
331
+ current_user: CurrentUser = Depends(verify_jwt_token),
332
+ store: HatchFsStore = Depends(get_hatch_store),
333
+ ):
334
+ # Verify user permission to access default hatch for the specified user_id
335
+ verify_user_permission(current_user, user_id)
336
+
337
+ default_hatch = await store.get_default_hatch(user_id)
338
+ if not default_hatch:
339
+ raise HTTPException(status_code=404, detail="Default hatch not found")
340
+ return default_hatch
341
+
342
+
343
+ @router.post("/hatch/set_default")
344
+ async def set_default_hatch(
345
+ user_id: str = Body(...),
346
+ hatch_id: str = Body(...),
347
+ current_user: CurrentUser = Depends(verify_jwt_token),
348
+ store: HatchFsStore = Depends(get_hatch_store),
349
+ ):
350
+ # Verify user permission to set default hatch for the specified user_id
351
+ verify_user_permission(current_user, user_id)
352
+
353
+ success, message = await store.set_default_hatch(user_id, hatch_id)
354
+ if not success:
355
+ raise HTTPException(status_code=500, detail=message)
356
+ return {"success": True, "message": message}
357
+
358
+
359
+ @router.get("/hatches/statistics")
360
+ async def get_hatches_statistics(
361
+ current_user: CurrentUser = Depends(verify_jwt_token),
362
+ user_setting_store: UserSettingFsStore = Depends(get_user_setting_store),
363
+ hatch_store: HatchFsStore = Depends(get_hatch_store),
364
+ ):
365
+ """Get statistics about hatches across all users.
366
+
367
+ Returns:
368
+ dict: Contains total hatch count and per-user hatch counts
369
+ """
370
+ # Verify admin permission
371
+ verify_admin_permission(current_user)
372
+
373
+ try:
374
+ # Get all user IDs
375
+ user_ids = await user_setting_store.get_all_user_ids()
376
+
377
+ # Initialize statistics
378
+ all_hatches = []
379
+ total_count = 0
380
+
381
+ # Get hatch counts for each user
382
+ for user_id in user_ids:
383
+ hatches, _ = await hatch_store.get_hatches(user_id)
384
+ count = len(hatches)
385
+ if count > 0: # Only include users who have hatches
386
+ all_hatches.append({"user_id": user_id, "hatches_count": count})
387
+ total_count += count
388
+
389
+ return {"all_hatches_count": total_count, "all_hatches": all_hatches}
390
+ except Exception as e:
391
+ raise HTTPException(
392
+ status_code=500, detail=f"Failed to fetch hatch statistics: {str(e)}"
393
+ )
394
+
395
+
396
+ @router.post("/hatch/{hatch_id}/share")
397
+ async def share_hatch(
398
+ hatch_id: str,
399
+ user_id: str = Body(..., embed=True),
400
+ store: HatchFsStore = Depends(get_hatch_store),
401
+ ):
402
+ """Share a hatch with another user.
403
+
404
+ Args:
405
+ hatch_id: ID of the hatch to share
406
+ user_id: ID of the user to share the hatch with (in request body)
407
+
408
+ Returns:
409
+ dict: Success status and message
410
+ """
411
+
412
+ # Get the hatch to verify it exists and to get owner_id
413
+ hatch = await store.get_hatch(hatch_id)
414
+ if not hatch:
415
+ raise HTTPException(status_code=404, detail="Hatch not found")
416
+
417
+ # Share the hatch
418
+ success, message = await store.share_hatch(hatch_id, hatch.user_id, user_id)
419
+ if not success:
420
+ raise HTTPException(status_code=400, detail=message)
421
+
422
+ return {"success": True, "message": message}
423
+
424
+
425
+ @router.delete("/hatch/{hatch_id}/share/{target_user_id}")
426
+ async def unshare_hatch(
427
+ hatch_id: str,
428
+ target_user_id: str,
429
+ store: HatchFsStore = Depends(get_hatch_store),
430
+ ):
431
+ """Remove sharing of a hatch with a user.
432
+
433
+ Args:
434
+ hatch_id: ID of the hatch to unshare
435
+ target_user_id: ID of the user to remove sharing from
436
+
437
+ Returns:
438
+ dict: Success status and message
439
+ """
440
+
441
+ # Get the hatch to verify it exists and to get owner_id
442
+ hatch = await store.get_hatch(hatch_id)
443
+ if not hatch:
444
+ raise HTTPException(status_code=404, detail="Hatch not found")
445
+
446
+ # Unshare the hatch
447
+ success, message = await store.unshare_hatch(
448
+ hatch_id, hatch.user_id, target_user_id
449
+ )
450
+ if not success:
451
+ raise HTTPException(status_code=400, detail=message)
452
+
453
+ return {"success": True, "message": message}
454
+
455
+
456
+ @router.get("/hatches/shared", response_model=List[Hatch])
457
+ async def get_shared_hatches(
458
+ user_id: str,
459
+ offset: int = Query(0, ge=0),
460
+ limit: int = Query(20, ge=1, le=100),
461
+ current_user: CurrentUser = Depends(verify_jwt_token),
462
+ store: HatchFsStore = Depends(get_hatch_store),
463
+ ):
464
+ """Get all hatches shared with a user.
465
+
466
+ Args:
467
+ user_id: ID of the user to get shared hatches for
468
+ offset: Pagination offset
469
+ limit: Maximum number of results to return
470
+
471
+ Returns:
472
+ List[Hatch]: List of shared hatches
473
+ """
474
+ # Verify user permission to access shared hatches for the specified user_id
475
+ verify_user_permission(current_user, user_id)
476
+
477
+ hatches, error = await store.get_shared_hatches(user_id, offset, limit)
478
+ if error:
479
+ raise HTTPException(status_code=500, detail=error)
480
+
481
+ return hatches
482
+
483
+
484
+ @router.get("/hatch/{hatch_id}/share/{user_id}")
485
+ async def is_hatch_shared_with_user(
486
+ hatch_id: str,
487
+ user_id: str,
488
+ store: HatchFsStore = Depends(get_hatch_store),
489
+ ):
490
+ """Check if a hatch is shared with a specific user.
491
+
492
+ Args:
493
+ hatch_id: ID of the hatch to check
494
+ user_id: ID of the user to check sharing with
495
+
496
+ Returns:
497
+ dict: Whether the hatch is shared with the user and a message
498
+ """
499
+
500
+ # Get the hatch to verify it exists
501
+ hatch = await store.get_hatch(hatch_id)
502
+ if not hatch:
503
+ raise HTTPException(status_code=404, detail="Hatch not found")
504
+
505
+ # Check if the hatch is shared with the user
506
+ is_shared, message = await store.is_hatch_shared_with_user(hatch_id, user_id)
507
+
508
+ return {"is_shared": is_shared, "message": message}