File indexing completed on 2026-07-21 08:46:24
0001 """AI app REST surface: propose / decide / delete for the proposal list.
0002
0003 Thin peers over ``ai.services`` — the same auth stack as the PCS API
0004 (tunnel, session, token). The propose verb is the only mutation surface AI
0005 clients get; decide demands an authenticated human.
0006 """
0007 from rest_framework import status
0008 from rest_framework.authentication import (
0009 SessionAuthentication, TokenAuthentication,
0010 )
0011 from rest_framework.permissions import IsAuthenticated
0012 from rest_framework.response import Response
0013 from rest_framework.views import APIView
0014
0015 from monitor_app.middleware import TunnelAuthentication
0016 from pcs.services import ServiceError
0017
0018 from . import services
0019
0020
0021 class _AiApiView(APIView):
0022 authentication_classes = [TunnelAuthentication, SessionAuthentication,
0023 TokenAuthentication]
0024 permission_classes = [IsAuthenticated]
0025
0026
0027 class ProposalProposeView(_AiApiView):
0028 def post(self, request):
0029 """Create AI propagation proposals (AI_PROPOSALS.md).
0030
0031 Body: ``names``, ``state``, ``comment`` (required), ``replaced_by``,
0032 ``proposer``, ``scan_version``, ``batch_id``.
0033 """
0034 try:
0035 result = services.propose_propagation(
0036 request.data.get('names') or [],
0037 request.data.get('state'),
0038 request.data.get('comment'),
0039 replaced_by=request.data.get('replaced_by', ''),
0040 proposer=request.data.get('proposer', ''),
0041 scan_version=request.data.get('scan_version', 1),
0042 batch_id=request.data.get('batch_id', ''),
0043 created_by=request.user.username,
0044 )
0045 except ServiceError as e:
0046 return Response({'detail': e.detail}, status=e.status)
0047 return Response(result, status=status.HTTP_200_OK)
0048
0049
0050 class ProposalDecideView(_AiApiView):
0051 def post(self, request):
0052 """Approve or deny pending AI proposals.
0053
0054 Body: ``names`` and/or ``ids``, ``decision`` ('approve' | 'deny'),
0055 ``quality`` (optional: wrong | poor | ok | good), ``filter``
0056 (optional audit record of the selecting filter).
0057 """
0058 try:
0059 result = services.proposal_decide(
0060 request.data.get('names') or [],
0061 request.data.get('decision'),
0062 decided_by=request.user.username,
0063 quality=request.data.get('quality', ''),
0064 filter_state=request.data.get('filter', ''),
0065 proposal_ids=request.data.get('ids') or [],
0066 )
0067 except ServiceError as e:
0068 return Response({'detail': e.detail}, status=e.status)
0069 except (TypeError, ValueError):
0070 return Response({'detail': 'ids must be integers'},
0071 status=status.HTTP_400_BAD_REQUEST)
0072 return Response(result, status=status.HTTP_200_OK)
0073
0074
0075 class ProposalUndoView(_AiApiView):
0076 def post(self, request):
0077 """Undo executed AI proposals — the compensating action.
0078
0079 Body: ``names`` and/or ``ids`` (selection mirrors decide).
0080 Restores the prior state captured at propose time through the
0081 same executor, origin-stamped ``undo``; expired offers (record
0082 moved on) are counted in ``moved``, never silently skipped.
0083 """
0084 try:
0085 result = services.proposal_undo(
0086 request.data.get('names') or [],
0087 undone_by=request.user.username,
0088 proposal_ids=request.data.get('ids') or [],
0089 )
0090 except ServiceError as e:
0091 return Response({'detail': e.detail}, status=e.status)
0092 except (TypeError, ValueError):
0093 return Response({'detail': 'ids must be integers'},
0094 status=status.HTTP_400_BAD_REQUEST)
0095 return Response(result, status=status.HTTP_200_OK)
0096
0097
0098 class NarrativeSaveView(_AiApiView):
0099 def post(self, request):
0100 """Save an edited narrative as a new corun-ai version.
0101
0102 Body: ``group_id``, ``content``. Title, status, tags, and data are
0103 carried over from the current version — editing revises content;
0104 publication (status change) is a separate deliberate act.
0105 """
0106 from monitor_app.epicprod_logging import log_epicprod_action
0107
0108 from .corun_client import CorunAPIError, CorunClient
0109
0110 group_id = (request.data.get('group_id') or '').strip()
0111 content = request.data.get('content') or ''
0112 if not group_id or not content.strip():
0113 return Response({'detail': 'group_id and content are required'},
0114 status=status.HTTP_400_BAD_REQUEST)
0115 try:
0116 client = CorunClient()
0117 current = client.get_page(group_id)
0118 data = dict(current.get('data') or {})
0119 data['author'] = request.user.username
0120 data['ui_visible'] = True
0121 result = client.create_version(
0122 group_id,
0123 content=content,
0124 title=current.get('title', ''),
0125 status=current.get('status', 'draft'),
0126 data=data,
0127 tags=current.get('tags') or [],
0128 )
0129 except CorunAPIError as exc:
0130 return Response({'detail': str(exc)},
0131 status=status.HTTP_502_BAD_GATEWAY)
0132 name = (current.get('data') or {}).get('name', current.get('title', ''))
0133 log_epicprod_action(
0134 'web', 'narrative_edited',
0135 subject_type='narrative',
0136 subject_key=name,
0137 username=request.user.username,
0138 sublevel='normal', live_default=False,
0139 message=f'narrative {name} revised to version {result.get("version")}',
0140 version=result.get('version'),
0141 )
0142 return Response({'version': result.get('version')},
0143 status=status.HTTP_200_OK)
0144
0145
0146 class NarrativeCommentView(_AiApiView):
0147 def post(self, request):
0148 """Post a comment on a narrative — the non-intrusive contribution
0149 path beside direct editing. Body: ``group_id``, ``content``."""
0150 from monitor_app.epicprod_logging import log_epicprod_action
0151
0152 from .corun_client import CorunAPIError, CorunClient
0153
0154 group_id = (request.data.get('group_id') or '').strip()
0155 content = (request.data.get('content') or '').strip()
0156 if not group_id or not content:
0157 return Response({'detail': 'group_id and content are required'},
0158 status=status.HTTP_400_BAD_REQUEST)
0159 try:
0160 client = CorunClient()
0161 current = client.get_page(group_id)
0162 client.create_comment(group_id, content=content,
0163 data={'author': request.user.username})
0164 except CorunAPIError as exc:
0165 return Response({'detail': str(exc)},
0166 status=status.HTTP_502_BAD_GATEWAY)
0167 name = (current.get('data') or {}).get('name', current.get('title', ''))
0168 log_epicprod_action(
0169 'web', 'narrative_commented',
0170 subject_type='narrative',
0171 subject_key=name,
0172 username=request.user.username,
0173 sublevel='normal', live_default=True,
0174 message=f'comment on narrative {name}',
0175 )
0176 return Response({'ok': True}, status=status.HTTP_200_OK)
0177
0178
0179 class ProposalDeleteView(_AiApiView):
0180 def post(self, request):
0181 """Delete AI proposal list rows (operator housekeeping). Body: ids."""
0182 try:
0183 result = services.proposal_delete(
0184 request.data.get('ids') or [],
0185 deleted_by=request.user.username,
0186 )
0187 except ServiceError as e:
0188 return Response({'detail': e.detail}, status=e.status)
0189 except (TypeError, ValueError):
0190 return Response({'detail': 'ids must be integers'},
0191 status=status.HTTP_400_BAD_REQUEST)
0192 return Response(result, status=status.HTTP_200_OK)