mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 03:35:40 +02:00
115 lines
4.2 KiB
Python
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())}
|