botrun-flow-lang 5.10.32__py3-none-any.whl → 5.10.82__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 (84) 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 +481 -481
  6. botrun_flow_lang/api/langgraph_api.py +796 -796
  7. botrun_flow_lang/api/line_bot_api.py +1357 -1357
  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 +316 -316
  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 +174 -174
  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/gov_researcher_2_graph.py +1002 -1002
  24. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_graph.py +822 -822
  25. botrun_flow_lang/langgraph_agents/agents/langgraph_react_agent.py +548 -548
  26. botrun_flow_lang/langgraph_agents/agents/search_agent_graph.py +864 -864
  27. botrun_flow_lang/langgraph_agents/agents/tools/__init__.py +4 -4
  28. botrun_flow_lang/langgraph_agents/agents/tools/gemini_code_execution.py +376 -376
  29. botrun_flow_lang/langgraph_agents/agents/util/gemini_grounding.py +66 -66
  30. botrun_flow_lang/langgraph_agents/agents/util/html_util.py +316 -316
  31. botrun_flow_lang/langgraph_agents/agents/util/img_util.py +294 -294
  32. botrun_flow_lang/langgraph_agents/agents/util/local_files.py +345 -345
  33. botrun_flow_lang/langgraph_agents/agents/util/mermaid_util.py +86 -86
  34. botrun_flow_lang/langgraph_agents/agents/util/model_utils.py +143 -143
  35. botrun_flow_lang/langgraph_agents/agents/util/pdf_analyzer.py +160 -160
  36. botrun_flow_lang/langgraph_agents/agents/util/perplexity_search.py +464 -464
  37. botrun_flow_lang/langgraph_agents/agents/util/plotly_util.py +59 -59
  38. botrun_flow_lang/langgraph_agents/agents/util/tavily_search.py +199 -199
  39. botrun_flow_lang/langgraph_agents/agents/util/youtube_util.py +90 -90
  40. botrun_flow_lang/langgraph_agents/cache/langgraph_botrun_cache.py +197 -197
  41. botrun_flow_lang/llm_agent/llm_agent.py +19 -19
  42. botrun_flow_lang/llm_agent/llm_agent_util.py +83 -83
  43. botrun_flow_lang/log/.gitignore +2 -2
  44. botrun_flow_lang/main.py +61 -61
  45. botrun_flow_lang/main_fast.py +51 -51
  46. botrun_flow_lang/mcp_server/__init__.py +10 -10
  47. botrun_flow_lang/mcp_server/default_mcp.py +711 -711
  48. botrun_flow_lang/models/nodes/utils.py +205 -205
  49. botrun_flow_lang/models/token_usage.py +34 -34
  50. botrun_flow_lang/requirements.txt +21 -21
  51. botrun_flow_lang/services/base/firestore_base.py +30 -30
  52. botrun_flow_lang/services/hatch/hatch_factory.py +11 -11
  53. botrun_flow_lang/services/hatch/hatch_fs_store.py +372 -372
  54. botrun_flow_lang/services/storage/storage_cs_store.py +202 -202
  55. botrun_flow_lang/services/storage/storage_factory.py +12 -12
  56. botrun_flow_lang/services/storage/storage_store.py +65 -65
  57. botrun_flow_lang/services/user_setting/user_setting_factory.py +9 -9
  58. botrun_flow_lang/services/user_setting/user_setting_fs_store.py +66 -66
  59. botrun_flow_lang/static/docs/tools/index.html +926 -926
  60. botrun_flow_lang/tests/api_functional_tests.py +1525 -1525
  61. botrun_flow_lang/tests/api_stress_test.py +357 -357
  62. botrun_flow_lang/tests/shared_hatch_tests.py +333 -333
  63. botrun_flow_lang/tests/test_botrun_app.py +46 -46
  64. botrun_flow_lang/tests/test_html_util.py +31 -31
  65. botrun_flow_lang/tests/test_img_analyzer.py +190 -190
  66. botrun_flow_lang/tests/test_img_util.py +39 -39
  67. botrun_flow_lang/tests/test_local_files.py +114 -114
  68. botrun_flow_lang/tests/test_mermaid_util.py +103 -103
  69. botrun_flow_lang/tests/test_pdf_analyzer.py +104 -104
  70. botrun_flow_lang/tests/test_plotly_util.py +151 -151
  71. botrun_flow_lang/tests/test_run_workflow_engine.py +65 -65
  72. botrun_flow_lang/tools/generate_docs.py +133 -133
  73. botrun_flow_lang/tools/templates/tools.html +153 -153
  74. botrun_flow_lang/utils/__init__.py +7 -7
  75. botrun_flow_lang/utils/botrun_logger.py +344 -344
  76. botrun_flow_lang/utils/clients/rate_limit_client.py +209 -209
  77. botrun_flow_lang/utils/clients/token_verify_client.py +153 -153
  78. botrun_flow_lang/utils/google_drive_utils.py +654 -654
  79. botrun_flow_lang/utils/langchain_utils.py +324 -324
  80. botrun_flow_lang/utils/yaml_utils.py +9 -9
  81. {botrun_flow_lang-5.10.32.dist-info → botrun_flow_lang-5.10.82.dist-info}/METADATA +2 -2
  82. botrun_flow_lang-5.10.82.dist-info/RECORD +99 -0
  83. botrun_flow_lang-5.10.32.dist-info/RECORD +0 -99
  84. {botrun_flow_lang-5.10.32.dist-info → botrun_flow_lang-5.10.82.dist-info}/WHEEL +0 -0
@@ -1,481 +1,481 @@
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
- current_user: CurrentUser = Depends(verify_jwt_token),
290
- hatch_store=Depends(get_hatch_store),
291
- ):
292
- # Verify user permission to access hatches for the specified user_id
293
- verify_user_permission(current_user, user_id)
294
-
295
- hatches, error = await hatch_store.get_hatches(user_id, offset, limit)
296
- if error:
297
- raise HTTPException(status_code=500, detail=error)
298
- return hatches
299
-
300
-
301
- @router.get("/hatch/default/{user_id}", response_model=Hatch)
302
- async def get_default_hatch(
303
- user_id: str,
304
- current_user: CurrentUser = Depends(verify_jwt_token),
305
- store: HatchFsStore = Depends(get_hatch_store),
306
- ):
307
- # Verify user permission to access default hatch for the specified user_id
308
- verify_user_permission(current_user, user_id)
309
-
310
- default_hatch = await store.get_default_hatch(user_id)
311
- if not default_hatch:
312
- raise HTTPException(status_code=404, detail="Default hatch not found")
313
- return default_hatch
314
-
315
-
316
- @router.post("/hatch/set_default")
317
- async def set_default_hatch(
318
- user_id: str = Body(...),
319
- hatch_id: str = Body(...),
320
- current_user: CurrentUser = Depends(verify_jwt_token),
321
- store: HatchFsStore = Depends(get_hatch_store),
322
- ):
323
- # Verify user permission to set default hatch for the specified user_id
324
- verify_user_permission(current_user, user_id)
325
-
326
- success, message = await store.set_default_hatch(user_id, hatch_id)
327
- if not success:
328
- raise HTTPException(status_code=500, detail=message)
329
- return {"success": True, "message": message}
330
-
331
-
332
- @router.get("/hatches/statistics")
333
- async def get_hatches_statistics(
334
- current_user: CurrentUser = Depends(verify_jwt_token),
335
- user_setting_store: UserSettingFsStore = Depends(get_user_setting_store),
336
- hatch_store: HatchFsStore = Depends(get_hatch_store),
337
- ):
338
- """Get statistics about hatches across all users.
339
-
340
- Returns:
341
- dict: Contains total hatch count and per-user hatch counts
342
- """
343
- # Verify admin permission
344
- verify_admin_permission(current_user)
345
-
346
- try:
347
- # Get all user IDs
348
- user_ids = await user_setting_store.get_all_user_ids()
349
-
350
- # Initialize statistics
351
- all_hatches = []
352
- total_count = 0
353
-
354
- # Get hatch counts for each user
355
- for user_id in user_ids:
356
- hatches, _ = await hatch_store.get_hatches(user_id)
357
- count = len(hatches)
358
- if count > 0: # Only include users who have hatches
359
- all_hatches.append({"user_id": user_id, "hatches_count": count})
360
- total_count += count
361
-
362
- return {"all_hatches_count": total_count, "all_hatches": all_hatches}
363
- except Exception as e:
364
- raise HTTPException(
365
- status_code=500, detail=f"Failed to fetch hatch statistics: {str(e)}"
366
- )
367
-
368
-
369
- @router.post("/hatch/{hatch_id}/share")
370
- async def share_hatch(
371
- hatch_id: str,
372
- user_id: str = Body(..., embed=True),
373
- store: HatchFsStore = Depends(get_hatch_store),
374
- ):
375
- """Share a hatch with another user.
376
-
377
- Args:
378
- hatch_id: ID of the hatch to share
379
- user_id: ID of the user to share the hatch with (in request body)
380
-
381
- Returns:
382
- dict: Success status and message
383
- """
384
-
385
- # Get the hatch to verify it exists and to get owner_id
386
- hatch = await store.get_hatch(hatch_id)
387
- if not hatch:
388
- raise HTTPException(status_code=404, detail="Hatch not found")
389
-
390
- # Share the hatch
391
- success, message = await store.share_hatch(hatch_id, hatch.user_id, user_id)
392
- if not success:
393
- raise HTTPException(status_code=400, detail=message)
394
-
395
- return {"success": True, "message": message}
396
-
397
-
398
- @router.delete("/hatch/{hatch_id}/share/{target_user_id}")
399
- async def unshare_hatch(
400
- hatch_id: str,
401
- target_user_id: str,
402
- store: HatchFsStore = Depends(get_hatch_store),
403
- ):
404
- """Remove sharing of a hatch with a user.
405
-
406
- Args:
407
- hatch_id: ID of the hatch to unshare
408
- target_user_id: ID of the user to remove sharing from
409
-
410
- Returns:
411
- dict: Success status and message
412
- """
413
-
414
- # Get the hatch to verify it exists and to get owner_id
415
- hatch = await store.get_hatch(hatch_id)
416
- if not hatch:
417
- raise HTTPException(status_code=404, detail="Hatch not found")
418
-
419
- # Unshare the hatch
420
- success, message = await store.unshare_hatch(
421
- hatch_id, hatch.user_id, target_user_id
422
- )
423
- if not success:
424
- raise HTTPException(status_code=400, detail=message)
425
-
426
- return {"success": True, "message": message}
427
-
428
-
429
- @router.get("/hatches/shared", response_model=List[Hatch])
430
- async def get_shared_hatches(
431
- user_id: str,
432
- offset: int = Query(0, ge=0),
433
- limit: int = Query(20, ge=1, le=100),
434
- current_user: CurrentUser = Depends(verify_jwt_token),
435
- store: HatchFsStore = Depends(get_hatch_store),
436
- ):
437
- """Get all hatches shared with a user.
438
-
439
- Args:
440
- user_id: ID of the user to get shared hatches for
441
- offset: Pagination offset
442
- limit: Maximum number of results to return
443
-
444
- Returns:
445
- List[Hatch]: List of shared hatches
446
- """
447
- # Verify user permission to access shared hatches for the specified user_id
448
- verify_user_permission(current_user, user_id)
449
-
450
- hatches, error = await store.get_shared_hatches(user_id, offset, limit)
451
- if error:
452
- raise HTTPException(status_code=500, detail=error)
453
-
454
- return hatches
455
-
456
-
457
- @router.get("/hatch/{hatch_id}/share/{user_id}")
458
- async def is_hatch_shared_with_user(
459
- hatch_id: str,
460
- user_id: str,
461
- store: HatchFsStore = Depends(get_hatch_store),
462
- ):
463
- """Check if a hatch is shared with a specific user.
464
-
465
- Args:
466
- hatch_id: ID of the hatch to check
467
- user_id: ID of the user to check sharing with
468
-
469
- Returns:
470
- dict: Whether the hatch is shared with the user and a message
471
- """
472
-
473
- # Get the hatch to verify it exists
474
- hatch = await store.get_hatch(hatch_id)
475
- if not hatch:
476
- raise HTTPException(status_code=404, detail="Hatch not found")
477
-
478
- # Check if the hatch is shared with the user
479
- is_shared, message = await store.is_hatch_shared_with_user(hatch_id, user_id)
480
-
481
- 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
+ current_user: CurrentUser = Depends(verify_jwt_token),
290
+ hatch_store=Depends(get_hatch_store),
291
+ ):
292
+ # Verify user permission to access hatches for the specified user_id
293
+ verify_user_permission(current_user, user_id)
294
+
295
+ hatches, error = await hatch_store.get_hatches(user_id, offset, limit)
296
+ if error:
297
+ raise HTTPException(status_code=500, detail=error)
298
+ return hatches
299
+
300
+
301
+ @router.get("/hatch/default/{user_id}", response_model=Hatch)
302
+ async def get_default_hatch(
303
+ user_id: str,
304
+ current_user: CurrentUser = Depends(verify_jwt_token),
305
+ store: HatchFsStore = Depends(get_hatch_store),
306
+ ):
307
+ # Verify user permission to access default hatch for the specified user_id
308
+ verify_user_permission(current_user, user_id)
309
+
310
+ default_hatch = await store.get_default_hatch(user_id)
311
+ if not default_hatch:
312
+ raise HTTPException(status_code=404, detail="Default hatch not found")
313
+ return default_hatch
314
+
315
+
316
+ @router.post("/hatch/set_default")
317
+ async def set_default_hatch(
318
+ user_id: str = Body(...),
319
+ hatch_id: str = Body(...),
320
+ current_user: CurrentUser = Depends(verify_jwt_token),
321
+ store: HatchFsStore = Depends(get_hatch_store),
322
+ ):
323
+ # Verify user permission to set default hatch for the specified user_id
324
+ verify_user_permission(current_user, user_id)
325
+
326
+ success, message = await store.set_default_hatch(user_id, hatch_id)
327
+ if not success:
328
+ raise HTTPException(status_code=500, detail=message)
329
+ return {"success": True, "message": message}
330
+
331
+
332
+ @router.get("/hatches/statistics")
333
+ async def get_hatches_statistics(
334
+ current_user: CurrentUser = Depends(verify_jwt_token),
335
+ user_setting_store: UserSettingFsStore = Depends(get_user_setting_store),
336
+ hatch_store: HatchFsStore = Depends(get_hatch_store),
337
+ ):
338
+ """Get statistics about hatches across all users.
339
+
340
+ Returns:
341
+ dict: Contains total hatch count and per-user hatch counts
342
+ """
343
+ # Verify admin permission
344
+ verify_admin_permission(current_user)
345
+
346
+ try:
347
+ # Get all user IDs
348
+ user_ids = await user_setting_store.get_all_user_ids()
349
+
350
+ # Initialize statistics
351
+ all_hatches = []
352
+ total_count = 0
353
+
354
+ # Get hatch counts for each user
355
+ for user_id in user_ids:
356
+ hatches, _ = await hatch_store.get_hatches(user_id)
357
+ count = len(hatches)
358
+ if count > 0: # Only include users who have hatches
359
+ all_hatches.append({"user_id": user_id, "hatches_count": count})
360
+ total_count += count
361
+
362
+ return {"all_hatches_count": total_count, "all_hatches": all_hatches}
363
+ except Exception as e:
364
+ raise HTTPException(
365
+ status_code=500, detail=f"Failed to fetch hatch statistics: {str(e)}"
366
+ )
367
+
368
+
369
+ @router.post("/hatch/{hatch_id}/share")
370
+ async def share_hatch(
371
+ hatch_id: str,
372
+ user_id: str = Body(..., embed=True),
373
+ store: HatchFsStore = Depends(get_hatch_store),
374
+ ):
375
+ """Share a hatch with another user.
376
+
377
+ Args:
378
+ hatch_id: ID of the hatch to share
379
+ user_id: ID of the user to share the hatch with (in request body)
380
+
381
+ Returns:
382
+ dict: Success status and message
383
+ """
384
+
385
+ # Get the hatch to verify it exists and to get owner_id
386
+ hatch = await store.get_hatch(hatch_id)
387
+ if not hatch:
388
+ raise HTTPException(status_code=404, detail="Hatch not found")
389
+
390
+ # Share the hatch
391
+ success, message = await store.share_hatch(hatch_id, hatch.user_id, user_id)
392
+ if not success:
393
+ raise HTTPException(status_code=400, detail=message)
394
+
395
+ return {"success": True, "message": message}
396
+
397
+
398
+ @router.delete("/hatch/{hatch_id}/share/{target_user_id}")
399
+ async def unshare_hatch(
400
+ hatch_id: str,
401
+ target_user_id: str,
402
+ store: HatchFsStore = Depends(get_hatch_store),
403
+ ):
404
+ """Remove sharing of a hatch with a user.
405
+
406
+ Args:
407
+ hatch_id: ID of the hatch to unshare
408
+ target_user_id: ID of the user to remove sharing from
409
+
410
+ Returns:
411
+ dict: Success status and message
412
+ """
413
+
414
+ # Get the hatch to verify it exists and to get owner_id
415
+ hatch = await store.get_hatch(hatch_id)
416
+ if not hatch:
417
+ raise HTTPException(status_code=404, detail="Hatch not found")
418
+
419
+ # Unshare the hatch
420
+ success, message = await store.unshare_hatch(
421
+ hatch_id, hatch.user_id, target_user_id
422
+ )
423
+ if not success:
424
+ raise HTTPException(status_code=400, detail=message)
425
+
426
+ return {"success": True, "message": message}
427
+
428
+
429
+ @router.get("/hatches/shared", response_model=List[Hatch])
430
+ async def get_shared_hatches(
431
+ user_id: str,
432
+ offset: int = Query(0, ge=0),
433
+ limit: int = Query(20, ge=1, le=100),
434
+ current_user: CurrentUser = Depends(verify_jwt_token),
435
+ store: HatchFsStore = Depends(get_hatch_store),
436
+ ):
437
+ """Get all hatches shared with a user.
438
+
439
+ Args:
440
+ user_id: ID of the user to get shared hatches for
441
+ offset: Pagination offset
442
+ limit: Maximum number of results to return
443
+
444
+ Returns:
445
+ List[Hatch]: List of shared hatches
446
+ """
447
+ # Verify user permission to access shared hatches for the specified user_id
448
+ verify_user_permission(current_user, user_id)
449
+
450
+ hatches, error = await store.get_shared_hatches(user_id, offset, limit)
451
+ if error:
452
+ raise HTTPException(status_code=500, detail=error)
453
+
454
+ return hatches
455
+
456
+
457
+ @router.get("/hatch/{hatch_id}/share/{user_id}")
458
+ async def is_hatch_shared_with_user(
459
+ hatch_id: str,
460
+ user_id: str,
461
+ store: HatchFsStore = Depends(get_hatch_store),
462
+ ):
463
+ """Check if a hatch is shared with a specific user.
464
+
465
+ Args:
466
+ hatch_id: ID of the hatch to check
467
+ user_id: ID of the user to check sharing with
468
+
469
+ Returns:
470
+ dict: Whether the hatch is shared with the user and a message
471
+ """
472
+
473
+ # Get the hatch to verify it exists
474
+ hatch = await store.get_hatch(hatch_id)
475
+ if not hatch:
476
+ raise HTTPException(status_code=404, detail="Hatch not found")
477
+
478
+ # Check if the hatch is shared with the user
479
+ is_shared, message = await store.is_hatch_shared_with_user(hatch_id, user_id)
480
+
481
+ return {"is_shared": is_shared, "message": message}