@@ -294,6 +294,60 @@ def get_tool_definitions() -> List[Dict[str, Any]]:
294294 },
295295 },
296296 },
297+ {
298+ "type" : "function" ,
299+ "function" : {
300+ "name" : "get_profile_status" ,
301+ "description" : (
302+ "Get the current profile status including the active profile and "
303+ "timestamps of when each profile was last activated. Use this to "
304+ "determine time periods for recap requests — e.g. when the user asks "
305+ "'what happened while I was away?', call this first to find the relevant "
306+ "time window based on profile activation history."
307+ ),
308+ "parameters" : {
309+ "type" : "object" ,
310+ "properties" : {},
311+ "required" : [],
312+ },
313+ },
314+ },
315+ {
316+ "type" : "function" ,
317+ "function" : {
318+ "name" : "get_recap" ,
319+ "description" : (
320+ "Get a recap of all activity (alerts and detections) for a given time period. "
321+ "Use this after calling get_profile_status to retrieve what happened during "
322+ "a specific window — e.g. 'what happened while I was away?'. Returns a "
323+ "chronological list of activity with camera, objects, zones, and GenAI-generated "
324+ "descriptions when available. Summarize the results for the user."
325+ ),
326+ "parameters" : {
327+ "type" : "object" ,
328+ "properties" : {
329+ "after" : {
330+ "type" : "string" ,
331+ "description" : "Start of the time period in ISO 8601 format (e.g. '2025-03-15T08:00:00')." ,
332+ },
333+ "before" : {
334+ "type" : "string" ,
335+ "description" : "End of the time period in ISO 8601 format (e.g. '2025-03-15T17:00:00')." ,
336+ },
337+ "cameras" : {
338+ "type" : "string" ,
339+ "description" : "Comma-separated camera IDs to include, or 'all' for all cameras. Default is 'all'." ,
340+ },
341+ "severity" : {
342+ "type" : "string" ,
343+ "enum" : ["alert" , "detection" ],
344+ "description" : "Filter by severity level. Omit to include both alerts and detections." ,
345+ },
346+ },
347+ "required" : ["after" , "before" ],
348+ },
349+ },
350+ },
297351 ]
298352
299353
@@ -646,10 +700,14 @@ async def _execute_tool_internal(
646700 return await _execute_start_camera_watch (request , arguments )
647701 elif tool_name == "stop_camera_watch" :
648702 return _execute_stop_camera_watch ()
703+ elif tool_name == "get_profile_status" :
704+ return _execute_get_profile_status (request )
705+ elif tool_name == "get_recap" :
706+ return _execute_get_recap (arguments , allowed_cameras )
649707 else :
650708 logger .error (
651709 "Tool call failed: unknown tool %r. Expected one of: search_objects, get_live_context, "
652- "start_camera_watch, stop_camera_watch. Arguments received: %s" ,
710+ "start_camera_watch, stop_camera_watch, get_profile_status, get_recap . Arguments received: %s" ,
653711 tool_name ,
654712 json .dumps (arguments ),
655713 )
@@ -713,6 +771,168 @@ def _execute_stop_camera_watch() -> Dict[str, Any]:
713771 return {"success" : False , "message" : "No active watch job to cancel." }
714772
715773
774+ def _execute_get_profile_status (request : Request ) -> Dict [str , Any ]:
775+ """Return profile status including active profile and activation timestamps."""
776+ profile_manager = getattr (request .app , "profile_manager" , None )
777+ if profile_manager is None :
778+ return {"error" : "Profile manager is not available." }
779+
780+ info = profile_manager .get_profile_info ()
781+
782+ # Convert timestamps to human-readable local times inline
783+ last_activated = {}
784+ for name , ts in info .get ("last_activated" , {}).items ():
785+ try :
786+ dt = datetime .fromtimestamp (ts )
787+ last_activated [name ] = dt .strftime ("%Y-%m-%d %I:%M:%S %p" )
788+ except (TypeError , ValueError , OSError ):
789+ last_activated [name ] = str (ts )
790+
791+ return {
792+ "active_profile" : info .get ("active_profile" ),
793+ "profiles" : info .get ("profiles" , []),
794+ "last_activated" : last_activated ,
795+ }
796+
797+
798+ def _execute_get_recap (
799+ arguments : Dict [str , Any ],
800+ allowed_cameras : List [str ],
801+ ) -> Dict [str , Any ]:
802+ """Fetch review segments with GenAI metadata for a time period."""
803+ from functools import reduce
804+
805+ from peewee import operator
806+
807+ from frigate .models import ReviewSegment
808+
809+ after_str = arguments .get ("after" )
810+ before_str = arguments .get ("before" )
811+
812+ def _parse_as_local_timestamp (s : str ):
813+ s = s .replace ("Z" , "" ).strip ()[:19 ]
814+ dt = datetime .strptime (s , "%Y-%m-%dT%H:%M:%S" )
815+ return time .mktime (dt .timetuple ())
816+
817+ try :
818+ after = _parse_as_local_timestamp (after_str )
819+ except (ValueError , AttributeError , TypeError ):
820+ return {"error" : f"Invalid 'after' timestamp: { after_str } " }
821+
822+ try :
823+ before = _parse_as_local_timestamp (before_str )
824+ except (ValueError , AttributeError , TypeError ):
825+ return {"error" : f"Invalid 'before' timestamp: { before_str } " }
826+
827+ cameras = arguments .get ("cameras" , "all" )
828+ if cameras != "all" :
829+ requested = set (cameras .split ("," ))
830+ camera_list = list (requested .intersection (allowed_cameras ))
831+ if not camera_list :
832+ return {"events" : [], "message" : "No accessible cameras matched." }
833+ else :
834+ camera_list = allowed_cameras
835+
836+ clauses = [
837+ (ReviewSegment .start_time < before )
838+ & ((ReviewSegment .end_time .is_null (True )) | (ReviewSegment .end_time > after )),
839+ (ReviewSegment .camera << camera_list ),
840+ ]
841+
842+ severity_filter = arguments .get ("severity" )
843+ if severity_filter :
844+ clauses .append (ReviewSegment .severity == severity_filter )
845+
846+ try :
847+ rows = (
848+ ReviewSegment .select (
849+ ReviewSegment .camera ,
850+ ReviewSegment .start_time ,
851+ ReviewSegment .end_time ,
852+ ReviewSegment .severity ,
853+ ReviewSegment .data ,
854+ )
855+ .where (reduce (operator .and_ , clauses ))
856+ .order_by (ReviewSegment .start_time .asc ())
857+ .limit (100 )
858+ .dicts ()
859+ .iterator ()
860+ )
861+
862+ events : List [Dict [str , Any ]] = []
863+
864+ for row in rows :
865+ data = row .get ("data" ) or {}
866+ if isinstance (data , str ):
867+ try :
868+ data = json .loads (data )
869+ except json .JSONDecodeError :
870+ data = {}
871+
872+ camera = row ["camera" ]
873+ event : Dict [str , Any ] = {
874+ "camera" : camera .replace ("_" , " " ).title (),
875+ "severity" : row .get ("severity" , "detection" ),
876+ }
877+
878+ # Include GenAI metadata when available
879+ metadata = data .get ("metadata" )
880+ if metadata and isinstance (metadata , dict ):
881+ if metadata .get ("title" ):
882+ event ["title" ] = metadata ["title" ]
883+ if metadata .get ("scene" ):
884+ event ["description" ] = metadata ["scene" ]
885+ threat = metadata .get ("potential_threat_level" )
886+ if threat is not None :
887+ threat_labels = {
888+ 0 : "normal" ,
889+ 1 : "needs_review" ,
890+ 2 : "security_concern" ,
891+ }
892+ event ["threat_level" ] = threat_labels .get (threat , str (threat ))
893+
894+ # Only include objects/zones/audio when there's no GenAI description
895+ # to keep the payload concise — the description already covers these
896+ if "description" not in event :
897+ objects = data .get ("objects" , [])
898+ if objects :
899+ event ["objects" ] = objects
900+ zones = data .get ("zones" , [])
901+ if zones :
902+ event ["zones" ] = zones
903+ audio = data .get ("audio" , [])
904+ if audio :
905+ event ["audio" ] = audio
906+
907+ start_ts = row .get ("start_time" )
908+ end_ts = row .get ("end_time" )
909+ if start_ts is not None :
910+ try :
911+ event ["time" ] = datetime .fromtimestamp (start_ts ).strftime (
912+ "%I:%M %p"
913+ )
914+ except (TypeError , ValueError , OSError ):
915+ pass
916+ if end_ts is not None and start_ts is not None :
917+ try :
918+ event ["duration_seconds" ] = round (end_ts - start_ts )
919+ except (TypeError , ValueError ):
920+ pass
921+
922+ events .append (event )
923+
924+ if not events :
925+ return {
926+ "events" : [],
927+ "message" : "No activity was found during this time period." ,
928+ }
929+
930+ return {"events" : events }
931+ except Exception as e :
932+ logger .error ("Error executing get_recap: %s" , e , exc_info = True )
933+ return {"error" : "Failed to fetch recap data." }
934+
935+
716936async def _execute_pending_tools (
717937 pending_tool_calls : List [Dict [str , Any ]],
718938 request : Request ,
0 commit comments