mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
feat(automations): add chat watch service
This commit is contained in:
parent
b993bf31d9
commit
2c9862c315
1 changed files with 152 additions and 0 deletions
152
surfsense_backend/app/automations/services/chat_watch.py
Normal file
152
surfsense_backend/app/automations/services/chat_watch.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""A chat watch is an automation bound to a chat: a ``schedule`` trigger firing
|
||||
a single ``chat_message`` step that re-posts the question into the same thread.
|
||||
|
||||
Starting a watch creates that automation; stopping deletes it. Whether a chat
|
||||
is watched is derived from the plan (``plan_targets_thread``), not a stored flag.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.automations.persistence.enums.trigger_type import TriggerType
|
||||
from app.automations.persistence.models.automation import Automation
|
||||
from app.automations.persistence.models.run import AutomationRun
|
||||
from app.automations.persistence.models.trigger import AutomationTrigger
|
||||
from app.automations.schemas.api import AutomationCreate, TriggerCreate
|
||||
from app.automations.schemas.definition import AutomationDefinition, PlanStep
|
||||
from app.automations.services.automation import AutomationService
|
||||
|
||||
WATCH_ACTION_TYPE = "chat_message"
|
||||
_WATCH_STEP_ID = "watch"
|
||||
_NAME_MAX = 200
|
||||
# Watches per chat are few; one generous page finds them all.
|
||||
_WATCH_SCAN_LIMIT = 500
|
||||
|
||||
|
||||
def _derive_name(message: str) -> str:
|
||||
"""A short, human name for the watch from the question it re-asks."""
|
||||
condensed = " ".join(message.split())
|
||||
label = condensed[:80].rstrip()
|
||||
return f"Watch: {label}" if label else "Watch"
|
||||
|
||||
|
||||
def _build_watch_payload(
|
||||
*,
|
||||
workspace_id: int,
|
||||
thread_id: int,
|
||||
message: str,
|
||||
cron: str,
|
||||
timezone: str,
|
||||
name: str | None,
|
||||
description: str | None,
|
||||
) -> AutomationCreate:
|
||||
watch_name = (name or _derive_name(message))[:_NAME_MAX]
|
||||
return AutomationCreate(
|
||||
workspace_id=workspace_id,
|
||||
name=watch_name,
|
||||
description=description,
|
||||
definition=AutomationDefinition(
|
||||
name=watch_name,
|
||||
plan=[
|
||||
PlanStep(
|
||||
step_id=_WATCH_STEP_ID,
|
||||
action=WATCH_ACTION_TYPE,
|
||||
params={"thread_id": thread_id, "message": message},
|
||||
)
|
||||
],
|
||||
),
|
||||
triggers=[
|
||||
TriggerCreate(
|
||||
type=TriggerType.SCHEDULE,
|
||||
params={"cron": cron, "timezone": timezone},
|
||||
enabled=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def create_watch(
|
||||
service: AutomationService,
|
||||
*,
|
||||
workspace_id: int,
|
||||
thread_id: int,
|
||||
message: str,
|
||||
cron: str,
|
||||
timezone: str,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> Automation:
|
||||
"""Bind a schedule + chat_message watch automation to ``thread_id``."""
|
||||
payload = _build_watch_payload(
|
||||
workspace_id=workspace_id,
|
||||
thread_id=thread_id,
|
||||
message=message,
|
||||
cron=cron,
|
||||
timezone=timezone,
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
return await service.create(payload)
|
||||
|
||||
|
||||
async def stop_watch(service: AutomationService, *, automation_id: int) -> None:
|
||||
"""Stop a watch by deleting its automation; the chat reverts to normal."""
|
||||
await service.delete(automation_id)
|
||||
|
||||
|
||||
def plan_targets_thread(definition: dict[str, Any] | None, thread_id: int) -> bool:
|
||||
"""Whether an automation's plan re-posts into ``thread_id`` (i.e. is a watch).
|
||||
|
||||
Pure predicate over the persisted ``definition`` JSON so callers can filter
|
||||
a workspace's automations without a DB round-trip per row.
|
||||
"""
|
||||
for step in (definition or {}).get("plan", []):
|
||||
if step.get("action") != WATCH_ACTION_TYPE:
|
||||
continue
|
||||
if (step.get("params") or {}).get("thread_id") == thread_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def find_watches_for_thread(
|
||||
service: AutomationService,
|
||||
*,
|
||||
workspace_id: int,
|
||||
thread_id: int,
|
||||
) -> list[Automation]:
|
||||
"""Return the workspace automations that watch ``thread_id`` (i.e. its watches)."""
|
||||
automations, _total = await service.list(
|
||||
workspace_id=workspace_id, limit=_WATCH_SCAN_LIMIT, offset=0
|
||||
)
|
||||
return [a for a in automations if plan_targets_thread(a.definition, thread_id)]
|
||||
|
||||
|
||||
def schedule_trigger(automation: Automation) -> AutomationTrigger | None:
|
||||
"""The automation's ``schedule`` trigger row, or ``None``."""
|
||||
for trigger in automation.triggers:
|
||||
if trigger.type == TriggerType.SCHEDULE:
|
||||
return trigger
|
||||
return None
|
||||
|
||||
|
||||
async def run_watch_now(
|
||||
service: AutomationService,
|
||||
*,
|
||||
automation_id: int,
|
||||
) -> AutomationRun:
|
||||
"""Enqueue an immediate run of a watch (a manual refresh)."""
|
||||
# Lazy: launch_run pulls in the automation task graph, which imports
|
||||
# multi_agent_chat; this module is imported from there, so a top-level
|
||||
# import would cycle.
|
||||
from app.automations.dispatch import launch as launch_mod
|
||||
|
||||
automation = await service.get(automation_id)
|
||||
trigger = schedule_trigger(automation)
|
||||
if trigger is None:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="watch has no schedule trigger to run"
|
||||
)
|
||||
return await launch_mod.launch_run(session=service.session, trigger=trigger)
|
||||
Loading…
Add table
Add a link
Reference in a new issue