Skip to content

Commit e1245cb

Browse files
authored
Improve profile state management and add recap tool (#22715)
* Improve profile information * Add chat tools * Add quick links to new chats * Improve usefulness * Cleanup * fix
1 parent b821420 commit e1245cb

File tree

6 files changed

+398
-42
lines changed

6 files changed

+398
-42
lines changed

frigate/api/chat.py

Lines changed: 221 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
716936
async def _execute_pending_tools(
717937
pending_tool_calls: List[Dict[str, Any]],
718938
request: Request,

frigate/config/profile_manager.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Profile manager for activating/deactivating named config profiles."""
22

33
import copy
4+
import json
45
import logging
6+
from datetime import datetime, timezone
57
from pathlib import Path
68
from typing import Optional
79

@@ -32,7 +34,7 @@
3234
"zones": CameraConfigUpdateEnum.zones,
3335
}
3436

35-
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile"
37+
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".profiles"
3638

3739

3840
class ProfileManager:
@@ -291,25 +293,36 @@ def _publish_updates(self, changed: dict[str, set[str]]) -> None:
291293
)
292294

293295
def _persist_active_profile(self, profile_name: Optional[str]) -> None:
294-
"""Persist the active profile name to disk."""
296+
"""Persist the active profile state to disk as JSON."""
295297
try:
296-
if profile_name is None:
297-
PERSISTENCE_FILE.unlink(missing_ok=True)
298-
else:
299-
PERSISTENCE_FILE.write_text(profile_name)
298+
data = self._load_persisted_data()
299+
data["active"] = profile_name
300+
if profile_name is not None:
301+
data.setdefault("last_activated", {})[profile_name] = datetime.now(
302+
timezone.utc
303+
).timestamp()
304+
PERSISTENCE_FILE.write_text(json.dumps(data))
300305
except OSError:
301306
logger.exception("Failed to persist active profile")
302307

303308
@staticmethod
304-
def load_persisted_profile() -> Optional[str]:
305-
"""Load the persisted active profile name from disk."""
309+
def _load_persisted_data() -> dict:
310+
"""Load the full persisted profile data from disk."""
306311
try:
307312
if PERSISTENCE_FILE.exists():
308-
name = PERSISTENCE_FILE.read_text().strip()
309-
return name if name else None
310-
except OSError:
311-
logger.exception("Failed to load persisted profile")
312-
return None
313+
raw = PERSISTENCE_FILE.read_text().strip()
314+
if raw:
315+
return json.loads(raw)
316+
except (OSError, json.JSONDecodeError):
317+
logger.exception("Failed to load persisted profile data")
318+
return {"active": None, "last_activated": {}}
319+
320+
@staticmethod
321+
def load_persisted_profile() -> Optional[str]:
322+
"""Load the persisted active profile name from disk."""
323+
data = ProfileManager._load_persisted_data()
324+
name = data.get("active")
325+
return name if name else None
313326

314327
def get_base_configs_for_api(self, camera_name: str) -> dict[str, dict]:
315328
"""Return base (pre-profile) section configs for a camera.
@@ -328,7 +341,9 @@ def get_available_profiles(self) -> list[dict[str, str]]:
328341

329342
def get_profile_info(self) -> dict:
330343
"""Get profile state info for API responses."""
344+
data = self._load_persisted_data()
331345
return {
332346
"profiles": self.get_available_profiles(),
333347
"active_profile": self.config.active_profile,
348+
"last_activated": data.get("last_activated", {}),
334349
}

0 commit comments

Comments
 (0)