feat: add board memory, approvals, onboarding APIs
This commit is contained in:
83
backend/app/api/approvals.py
Normal file
83
backend/app/api/approvals.py
Normal file
@@ -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
|
||||
54
backend/app/api/board_memory.py
Normal file
54
backend/app/api/board_memory.py
Normal file
@@ -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
|
||||
240
backend/app/api/board_onboarding.py
Normal file
240
backend/app/api/board_onboarding.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user