Files
openswarm/backend/apps/patterns/patterns.py
T

115 lines
4.2 KiB
Python

"""Routes + lifecycle for pattern suggestions. The miner runs shortly after
boot and then daily; accepting a suggestion creates a REAL workflow through
the same create path the Workflows UI uses, so the user reviews and owns it
like any other."""
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import HTTPException
from backend.apps.patterns import store
from backend.apps.patterns.miner import run_mining_pass
from backend.apps.patterns.models import WorkflowSuggestion
from backend.config.Apps import SubApp
logger = logging.getLogger(__name__)
BOOT_DELAY_SECONDS = 45.0
CHECK_INTERVAL_SECONDS = 6 * 3600.0
p_loop_task: Optional["asyncio.Task"] = None
async def p_mining_loop() -> None:
await asyncio.sleep(BOOT_DELAY_SECONDS)
while True:
try:
added = await run_mining_pass()
if added:
logger.info("[pattern-miner] added %d suggestion(s)", added)
except Exception:
logger.exception("pattern mining loop error")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
@asynccontextmanager
async def patterns_lifespan():
global p_loop_task
p_loop_task = asyncio.create_task(p_mining_loop())
try:
yield
finally:
if p_loop_task is not None:
p_loop_task.cancel()
try:
await p_loop_task
except (asyncio.CancelledError, Exception):
pass
p_loop_task = None
patterns = SubApp("patterns", patterns_lifespan)
@patterns.router.get("/suggestions")
async def list_suggestions():
return {"suggestions": [s.model_dump(mode="json") for s in store.pending_suggestions()]}
def p_get_pending_or_404(suggestion_id: str) -> WorkflowSuggestion:
suggestion = store.get_suggestion(suggestion_id)
if suggestion is None:
raise HTTPException(status_code=404, detail="Suggestion not found")
if suggestion.status != "pending":
raise HTTPException(status_code=409, detail=f"Suggestion already {suggestion.status}")
return suggestion
@patterns.router.post("/suggestions/{suggestion_id}/accept")
async def accept_suggestion(suggestion_id: str):
from backend.apps.workflows.models import ScheduleConfig, WorkflowCreate, WorkflowStep
from backend.apps.workflows.workflows import create_workflow
suggestion = p_get_pending_or_404(suggestion_id)
if suggestion.cadence.kind == "weekly":
schedule = ScheduleConfig(enabled=True, repeat_unit="week", repeat_every=1, on_days=suggestion.cadence.on_days, hour=suggestion.cadence.hour, minute=0)
elif suggestion.cadence.kind == "daily":
schedule = ScheduleConfig(enabled=True, repeat_unit="day", repeat_every=1, hour=suggestion.cadence.hour, minute=0)
else:
# No clear rhythm in the evidence: create it ready to run, let the user schedule or add a trigger.
schedule = ScheduleConfig(enabled=False)
steps = [WorkflowStep(text=t) for t in suggestion.workflow_steps]
body = WorkflowCreate(
title=suggestion.workflow_title or "Suggested workflow",
description=suggestion.description,
steps=steps,
schedule=schedule,
auto_named=False,
# The explicit accept IS the validation moment; byte-matches the FE stepsSignature so the test-first nag never fires.
tested_signature=json.dumps([[s.id, s.text] for s in steps], separators=(",", ":"), ensure_ascii=False),
)
workflow = await create_workflow(body)
suggestion.status = "accepted"
suggestion.workflow_id = str(workflow.get("id") or "") or None
store.update_suggestion(suggestion)
return {"suggestion": suggestion.model_dump(mode="json"), "workflow": workflow}
@patterns.router.post("/suggestions/{suggestion_id}/dismiss")
async def dismiss_suggestion(suggestion_id: str):
suggestion = p_get_pending_or_404(suggestion_id)
suggestion.status = "dismissed"
store.update_suggestion(suggestion)
return {"ok": True}
@patterns.router.post("/mine")
async def mine_now():
"""Force a mining pass (ignores the daily throttle; the settings kill switch still applies)."""
added = await run_mining_pass(force=True)
return {"added": added, "pending": len(store.pending_suggestions())}