195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
"""Optional AI and automation endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import require_admin_auth
|
|
from app.services.ai_assistance import (
|
|
build_action_proposals,
|
|
build_evidence_summary,
|
|
build_safe_context,
|
|
get_assistance_config,
|
|
)
|
|
from app.services.workspace_audit import record_workspace_audit_log
|
|
from app.services.workspaces import assign_default_workspace_to_unscoped_rows
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class SafeContextResponse(BaseModel):
|
|
"""Redacted model/agent context response."""
|
|
|
|
context: Dict[str, Any]
|
|
|
|
|
|
class EvidenceSummaryResponse(BaseModel):
|
|
"""Evidence-first summary response."""
|
|
|
|
summary: Dict[str, Any]
|
|
|
|
|
|
class ActionProposalResponse(BaseModel):
|
|
"""Reviewable action proposals response."""
|
|
|
|
domain: str
|
|
action_tools_enabled: bool
|
|
proposals: list[Dict[str, Any]]
|
|
|
|
|
|
class ProposalConfirmation(BaseModel):
|
|
"""Human confirmation payload for a proposal."""
|
|
|
|
proposal_id: str
|
|
confirmation_text: str
|
|
note: Optional[str] = None
|
|
|
|
|
|
def _require_ai_enabled(db: Session) -> None:
|
|
if not get_assistance_config(db).ai_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="AI assistance is disabled. Enable ai.enabled before using this endpoint.",
|
|
)
|
|
|
|
|
|
@router.get("/config")
|
|
async def get_ai_config(
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
) -> Dict[str, Any]:
|
|
"""Return safe AI/MCP configuration without secrets."""
|
|
return {"config": get_assistance_config(db).to_dict()}
|
|
|
|
|
|
@router.get("/domains/{domain}/context", response_model=SafeContextResponse)
|
|
async def get_domain_safe_context(
|
|
domain: str,
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
) -> SafeContextResponse:
|
|
"""Return a redacted, evidence-linked context payload for one domain."""
|
|
_require_ai_enabled(db)
|
|
try:
|
|
return {"context": build_safe_context(db, domain)}
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
|
|
|
|
@router.get("/domains/{domain}/summary", response_model=EvidenceSummaryResponse)
|
|
async def get_domain_evidence_summary(
|
|
domain: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
) -> EvidenceSummaryResponse:
|
|
"""Return deterministic evidence-first assistance for one domain."""
|
|
_require_ai_enabled(db)
|
|
try:
|
|
summary = build_evidence_summary(db, domain)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
|
record_workspace_audit_log(
|
|
db,
|
|
workspace=workspace,
|
|
action="ai.summary_generated",
|
|
entity_type="domain",
|
|
entity_id=domain,
|
|
entity_name=domain,
|
|
details={
|
|
"provider": summary["provider"]["provider"],
|
|
"recommendations": len(summary["recommendations"]),
|
|
},
|
|
auth_context=_auth,
|
|
request=request,
|
|
)
|
|
db.commit()
|
|
return {"summary": summary}
|
|
|
|
|
|
@router.get("/domains/{domain}/action-proposals", response_model=ActionProposalResponse)
|
|
async def get_domain_action_proposals(
|
|
domain: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
) -> ActionProposalResponse:
|
|
"""Return reviewable proposals; this endpoint never applies changes."""
|
|
_require_ai_enabled(db)
|
|
try:
|
|
payload = build_action_proposals(db, domain)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
|
record_workspace_audit_log(
|
|
db,
|
|
workspace=workspace,
|
|
action="ai.action_proposals_generated",
|
|
entity_type="domain",
|
|
entity_id=domain,
|
|
entity_name=domain,
|
|
details={"proposal_count": len(payload["proposals"]), "mutates_state": False},
|
|
auth_context=_auth,
|
|
request=request,
|
|
)
|
|
db.commit()
|
|
return payload
|
|
|
|
|
|
@router.post("/domains/{domain}/action-proposals/confirm")
|
|
async def confirm_action_proposal(
|
|
domain: str,
|
|
payload: ProposalConfirmation,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
_auth: dict = Depends(require_admin_auth),
|
|
) -> Dict[str, Any]:
|
|
"""Audit human confirmation for a proposal without applying external changes."""
|
|
_require_ai_enabled(db)
|
|
config = get_assistance_config(db)
|
|
if not config.action_tools_enabled:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=(
|
|
"Action tools are disabled. Enable ai.action_tools_enabled to confirm "
|
|
"proposals."
|
|
),
|
|
)
|
|
proposals = build_action_proposals(db, domain)["proposals"]
|
|
proposal = next(
|
|
(item for item in proposals if item["proposal_id"] == payload.proposal_id),
|
|
None,
|
|
)
|
|
if proposal is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Proposal not found")
|
|
if payload.confirmation_text != payload.proposal_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="confirmation_text must match proposal_id",
|
|
)
|
|
workspace = assign_default_workspace_to_unscoped_rows(db, commit=False)
|
|
record_workspace_audit_log(
|
|
db,
|
|
workspace=workspace,
|
|
action="ai.action_proposal_confirmed",
|
|
entity_type="action_proposal",
|
|
entity_id=payload.proposal_id,
|
|
entity_name=proposal["title"],
|
|
details={
|
|
"domain": domain,
|
|
"proposal_id": payload.proposal_id,
|
|
"mutates_state": False,
|
|
"note": payload.note,
|
|
},
|
|
auth_context=_auth,
|
|
request=request,
|
|
)
|
|
db.commit()
|
|
return {"status": "confirmed", "applied": False, "proposal": proposal}
|