From 5471671dc4dda12f268a45064eb8ac3bfdd1c7d9 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:43:25 +0530 Subject: [PATCH] feat: add board memory, approvals, onboarding APIs --- backend/app/api/approvals.py | 83 ++++++++++ backend/app/api/board_memory.py | 54 +++++++ backend/app/api/board_onboarding.py | 240 ++++++++++++++++++++++++++++ backend/app/api/boards.py | 8 + backend/app/main.py | 6 + 5 files changed, 391 insertions(+) create mode 100644 backend/app/api/approvals.py create mode 100644 backend/app/api/board_memory.py create mode 100644 backend/app/api/board_onboarding.py diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py new file mode 100644 index 00000000..7910fd10 --- /dev/null +++ b/backend/app/api/approvals.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import Session, col, select + +from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent +from app.db.session import get_session +from app.models.approvals import Approval +from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate + +router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"]) + +ALLOWED_STATUSES = {"pending", "approved", "rejected"} + + +@router.get("", response_model=list[ApprovalRead]) +def list_approvals( + status_filter: str | None = Query(default=None, alias="status"), + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> list[Approval]: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + statement = select(Approval).where(col(Approval.board_id) == board.id) + if status_filter: + if status_filter not in ALLOWED_STATUSES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + statement = statement.where(col(Approval.status) == status_filter) + statement = statement.order_by(col(Approval.created_at).desc()) + return list(session.exec(statement)) + + +@router.post("", response_model=ApprovalRead) +def create_approval( + payload: ApprovalCreate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> Approval: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + approval = Approval( + board_id=board.id, + agent_id=payload.agent_id, + action_type=payload.action_type, + payload=payload.payload, + confidence=payload.confidence, + rubric_scores=payload.rubric_scores, + status=payload.status, + ) + session.add(approval) + session.commit() + session.refresh(approval) + return approval + + +@router.patch("/{approval_id}", response_model=ApprovalRead) +def update_approval( + approval_id: str, + payload: ApprovalUpdate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + auth=Depends(require_admin_auth), +) -> Approval: + approval = session.get(Approval, approval_id) + if approval is None or approval.board_id != board.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + updates = payload.model_dump(exclude_unset=True) + if "status" in updates: + if updates["status"] not in ALLOWED_STATUSES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + approval.status = updates["status"] + if approval.status != "pending": + approval.resolved_at = datetime.utcnow() + session.add(approval) + session.commit() + session.refresh(approval) + return approval diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py new file mode 100644 index 00000000..e9b1e98f --- /dev/null +++ b/backend/app/api/board_memory.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import Session, col, select + +from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent +from app.db.session import get_session +from app.models.board_memory import BoardMemory +from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead + +router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"]) + + +@router.get("", response_model=list[BoardMemoryRead]) +def list_board_memory( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> list[BoardMemory]: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + statement = ( + select(BoardMemory) + .where(col(BoardMemory.board_id) == board.id) + .order_by(col(BoardMemory.created_at).desc()) + .offset(offset) + .limit(limit) + ) + return list(session.exec(statement)) + + +@router.post("", response_model=BoardMemoryRead) +def create_board_memory( + payload: BoardMemoryCreate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> BoardMemory: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + memory = BoardMemory( + board_id=board.id, + content=payload.content, + tags=payload.tags, + source=payload.source, + ) + session.add(memory) + session.commit() + session.refresh(memory) + return memory diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py new file mode 100644 index 00000000..c7a01eab --- /dev/null +++ b/backend/app/api/board_onboarding.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from app.api.deps import get_board_or_404, require_admin_auth +from app.core.auth import AuthContext +from app.db.session import get_session +from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, get_chat_history, send_message +from app.models.board_onboarding import BoardOnboardingSession +from app.models.boards import Board +from app.models.gateways import Gateway +from app.schemas.board_onboarding import ( + BoardOnboardingAnswer, + BoardOnboardingConfirm, + BoardOnboardingRead, + BoardOnboardingStart, +) +from app.schemas.boards import BoardRead + +router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"]) + +SESSION_PREFIX = "agent:main:onboarding:" + + +def _extract_json(text: str) -> dict[str, object] | None: + try: + return json.loads(text.strip()) + except Exception: + pass + match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) + if match: + try: + return json.loads(match.group(1).strip()) + except Exception: + pass + first = text.find("{") + last = text.rfind("}") + if first != -1 and last > first: + try: + return json.loads(text[first : last + 1]) + except Exception: + return None + return None + + +def _extract_text(content: object) -> str | None: + if isinstance(content, str): + return content + if isinstance(content, list): + for entry in content: + if isinstance(entry, dict) and entry.get("type") == "text": + text = entry.get("text") + if isinstance(text, str): + return text + if isinstance(content, dict): + text = content.get("text") + if isinstance(text, str): + return text + return None + + +def _get_assistant_messages(history: object) -> list[str]: + messages: list[str] = [] + if isinstance(history, dict): + history = history.get("messages") + if not isinstance(history, list): + return messages + for msg in history: + if not isinstance(msg, dict): + continue + if msg.get("role") != "assistant": + continue + text = _extract_text(msg.get("content")) + if text: + messages.append(text) + return messages + + +def _gateway_config(session: Session, board: Board) -> GatewayClientConfig: + if not board.gateway_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + gateway = session.get(Gateway, board.gateway_id) + if gateway is None or not gateway.url: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + return GatewayClientConfig(url=gateway.url, token=gateway.token) + + +def _session_key(board: Board) -> str: + return f"{SESSION_PREFIX}{board.id}" + + +@router.get("", response_model=BoardOnboardingRead) +def get_onboarding( + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> BoardOnboardingSession: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + if onboarding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return onboarding + + +@router.post("/start", response_model=BoardOnboardingRead) +async def start_onboarding( + payload: BoardOnboardingStart, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> BoardOnboardingSession: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .where(BoardOnboardingSession.status == "active") + ).first() + if onboarding: + return onboarding + + config = _gateway_config(session, board) + session_key = _session_key(board) + prompt = ( + "BOARD ONBOARDING REQUEST\n\n" + f"Board Name: {board.name}\n" + "You are the lead agent. Ask the user 3-6 focused questions to clarify their goal.\n" + "Return questions as JSON: {\"question\": \"...\", \"options\": [...]}.\n" + "When you have enough info, return JSON: {\"status\": \"complete\", \"board_type\": \"goal\"|\"general\", " + "\"objective\": \"...\", \"success_metrics\": {...}, \"target_date\": \"YYYY-MM-DD\"}." + ) + + try: + await ensure_session(session_key, config=config, label=f"Onboarding {board.name}") + await send_message(prompt, session_key=session_key, config=config, deliver=True) + except OpenClawGatewayError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + onboarding = BoardOnboardingSession( + board_id=board.id, + session_key=session_key, + status="active", + messages=[{"role": "user", "content": prompt, "timestamp": datetime.utcnow().isoformat()}], + ) + session.add(onboarding) + session.commit() + session.refresh(onboarding) + return onboarding + + +@router.post("/answer", response_model=BoardOnboardingRead) +async def answer_onboarding( + payload: BoardOnboardingAnswer, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> BoardOnboardingSession: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + if onboarding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + config = _gateway_config(session, board) + answer_text = payload.answer + if payload.other_text: + answer_text = f"{payload.answer}: {payload.other_text}" + + messages = onboarding.messages or [] + messages.append( + {"role": "user", "content": answer_text, "timestamp": datetime.utcnow().isoformat()} + ) + + try: + await ensure_session(onboarding.session_key, config=config, label=f"Onboarding {board.name}") + await send_message( + answer_text, session_key=onboarding.session_key, config=config, deliver=True + ) + history = await get_chat_history(onboarding.session_key, config=config) + except OpenClawGatewayError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + assistant_messages = _get_assistant_messages(history) + if assistant_messages: + last = assistant_messages[-1] + messages.append( + {"role": "assistant", "content": last, "timestamp": datetime.utcnow().isoformat()} + ) + parsed = _extract_json(last) + if parsed and parsed.get("status") == "complete": + onboarding.draft_goal = parsed + onboarding.status = "completed" + + onboarding.messages = messages + onboarding.updated_at = datetime.utcnow() + session.add(onboarding) + session.commit() + session.refresh(onboarding) + return onboarding + + +@router.post("/confirm", response_model=BoardRead) +def confirm_onboarding( + payload: BoardOnboardingConfirm, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> Board: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + if onboarding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + board.board_type = payload.board_type + board.objective = payload.objective + board.success_metrics = payload.success_metrics + board.target_date = payload.target_date + board.goal_confirmed = True + board.goal_source = "lead_agent_onboarding" + + onboarding.status = "confirmed" + onboarding.updated_at = datetime.utcnow() + + session.add(board) + session.add(onboarding) + session.commit() + session.refresh(board) + return board diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 3002b64f..7edb9864 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -161,6 +161,14 @@ def update_board( ) for key, value in updates.items(): setattr(board, key, value) + if updates.get("board_type") == "goal": + objective = updates.get("objective") or board.objective + metrics = updates.get("success_metrics") or board.success_metrics + if not objective or not metrics: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Goal boards require objective and success_metrics", + ) if not board.gateway_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/backend/app/main.py b/backend/app/main.py index 2bda38c4..7c5a5060 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,10 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.activity import router as activity_router from app.api.agents import router as agents_router +from app.api.approvals import router as approvals_router from app.api.auth import router as auth_router +from app.api.board_memory import router as board_memory_router +from app.api.board_onboarding import router as board_onboarding_router from app.api.boards import router as boards_router from app.api.gateway import router as gateway_router from app.api.gateways import router as gateways_router @@ -59,6 +62,9 @@ api_v1.include_router(gateway_router) api_v1.include_router(gateways_router) api_v1.include_router(metrics_router) api_v1.include_router(boards_router) +api_v1.include_router(board_memory_router) +api_v1.include_router(board_onboarding_router) +api_v1.include_router(approvals_router) api_v1.include_router(tasks_router) api_v1.include_router(users_router) app.include_router(api_v1)