File indexing completed on 2026-07-22 09:42:03
0001 """MCP tools for append-only AI assessments — the platform mechanism.
0002
0003 Registration, retrieval, and corun storage live here; what an assessment
0004 can be about is domain vocabulary, supplied through the subject-type
0005 registry below (the mechanism/policy split the action registry uses).
0006 The epicprod subject types register from ``swf_epicprod.ai_subjects``.
0007 """
0008
0009 import logging
0010
0011 from asgiref.sync import sync_to_async
0012
0013 from ai.assessments import (
0014 CORUN_ASSESSMENT_SECTION,
0015 append_corun_page_group_id,
0016 corun_page_items,
0017 )
0018 from ai.corun_client import CorunAPIError, CorunClient, corun_configured
0019 from monitor_app.epicprod_logging import log_epicprod_action
0020 from monitor_app.mcp import mcp
0021
0022 logger = logging.getLogger(__name__)
0023
0024
0025
0026
0027
0028 SUBJECT_TYPE_ALIASES = {}
0029 SUBJECT_RESOLVERS = {}
0030
0031
0032 def register_subject_type(canonical, resolver, aliases=()):
0033 """Register a subject type: ``resolver(subject_key, data)`` returns
0034 {target_obj, target_json_field, subject_key, subject_label,
0035 subject_url} or raises when the key resolves to no object."""
0036 SUBJECT_RESOLVERS[canonical] = resolver
0037 SUBJECT_TYPE_ALIASES[canonical] = canonical
0038 for alias in aliases:
0039 SUBJECT_TYPE_ALIASES[alias] = canonical
0040
0041
0042 def _canonical_subject_type(subject_type):
0043 key = str(subject_type or '').strip().lower()
0044 return SUBJECT_TYPE_ALIASES.get(key, str(subject_type or '').strip())
0045
0046
0047 def _resolve_subject(subject_type, subject_key, data):
0048 resolver = SUBJECT_RESOLVERS.get(subject_type)
0049 if resolver is not None:
0050 return resolver(subject_key, data)
0051 return {
0052 'target_obj': None,
0053 'target_json_field': '',
0054 'subject_key': str(subject_key).strip(),
0055 'subject_label': '',
0056 'subject_url': '',
0057 }
0058
0059
0060 def _register_ai_assessment_sync(
0061 subject_type,
0062 subject_key,
0063 assessment,
0064 username,
0065 ai,
0066 subject_label,
0067 subject_url,
0068 data,
0069 title='',
0070 ):
0071 canonical_type = _canonical_subject_type(subject_type)
0072 if not canonical_type:
0073 return {'success': False, 'error': 'subject_type is required'}
0074 if not str(subject_key or '').strip():
0075 return {'success': False, 'error': 'subject_key is required'}
0076 if not str(assessment or '').strip():
0077 return {'success': False, 'error': 'assessment is required'}
0078 if data is not None and not isinstance(data, dict):
0079 return {'success': False, 'error': 'data must be an object when provided'}
0080
0081 payload_data = dict(data or {})
0082 payload_data.setdefault('registered_via', 'mcp')
0083 payload_data.setdefault('mcp_tool', 'epic_register_ai_assessment')
0084 try:
0085 resolved = _resolve_subject(canonical_type, subject_key, payload_data)
0086 except Exception as exc:
0087 logger.warning(
0088 'AI assessment subject resolution failed: type=%s key=%s error=%s',
0089 canonical_type, subject_key, exc,
0090 )
0091 return {
0092 'success': False,
0093 'error': f'No local subject found for {canonical_type}:{subject_key}',
0094 'subject_type': canonical_type,
0095 'subject_key': str(subject_key),
0096 }
0097
0098 if not corun_configured():
0099 return {
0100 'success': False,
0101 'error': 'corun-ai config CORUN_BASE_URL and CORUN_API_TOKEN must be configured',
0102 'subject_type': canonical_type,
0103 'subject_key': resolved['subject_key'],
0104 }
0105
0106 resolved_label = subject_label or resolved.get('subject_label') or ''
0107 resolved_url = subject_url or resolved.get('subject_url') or ''
0108 username_value = str(username or 'mcp').strip() or 'mcp'
0109 ai_value = str(ai or 'unknown').strip() or 'unknown'
0110 assessment_text = str(assessment).strip()
0111 report_title = (str(title).strip()
0112 or f"AI assessment: {canonical_type} {resolved['subject_key']}")
0113 linked = bool(resolved.get('target_obj') and resolved.get('target_json_field'))
0114 page_data = {
0115 **payload_data,
0116 'artifact_type': 'ai_assessment',
0117 'source_system': 'swf-monitor',
0118 'ui_visible': False,
0119 'subject_type': canonical_type,
0120 'subject_key': resolved['subject_key'],
0121 'subject_label': resolved_label,
0122 'subject_url': resolved_url,
0123 'created_by_system': 'epicprod',
0124 'created_by_user': username_value,
0125 'ai': ai_value,
0126 }
0127 try:
0128 client = CorunClient()
0129 page = client.create_page(
0130 section=CORUN_ASSESSMENT_SECTION,
0131 title=report_title,
0132 content=assessment_text,
0133 data=page_data,
0134 tags=[
0135 'epicprod',
0136 'ai-assessment',
0137 canonical_type.replace('_', '-'),
0138 ],
0139 )
0140 except CorunAPIError as exc:
0141 logger.warning(
0142 'corun AI assessment creation failed: type=%s key=%s error=%s',
0143 canonical_type, resolved['subject_key'], exc,
0144 )
0145 log_epicprod_action(
0146 'mcp', 'assessment_register',
0147 subject_type=canonical_type,
0148 subject_key=resolved['subject_key'],
0149 username=username_value,
0150 outcome='error',
0151 sublevel='normal',
0152 live_default=True,
0153 level=logging.ERROR,
0154 message=f'assessment registration failed: {exc}',
0155 )
0156 return {
0157 'success': False,
0158 'error': str(exc),
0159 'subject_type': canonical_type,
0160 'subject_key': resolved['subject_key'],
0161 }
0162
0163 page_group_id = str(page.get('group_id') or '')
0164 if linked and page_group_id:
0165 append_corun_page_group_id(
0166 resolved['target_obj'],
0167 resolved['target_json_field'],
0168 page_group_id,
0169 )
0170
0171
0172
0173 verdict = str(payload_data.get('verdict') or '').strip().lower()
0174 log_epicprod_action(
0175 'mcp', 'assessment_register',
0176 subject_type=canonical_type,
0177 subject_key=resolved['subject_key'],
0178 username=username_value,
0179 outcome='ok',
0180 sublevel='high' if verdict not in ('', 'ok') else 'normal',
0181 live_default=True,
0182 linked=linked,
0183 corun_page_group_id=page_group_id,
0184 report_title=report_title,
0185 report_path=(f'/ai/assessments/{page_group_id}/'
0186 if page_group_id else ''),
0187 assessment_kind=str(payload_data.get('assessment_kind') or ''),
0188 narration=str(payload_data.get('narration') or '')[:600],
0189 **({'verdict': verdict} if verdict else {}),
0190 )
0191 return {
0192 'success': True,
0193 'storage': 'corun',
0194 'id': None,
0195 'corun_page_group_id': page_group_id,
0196 'corun_page_id': str(page.get('id') or ''),
0197 'subject_type': canonical_type,
0198 'subject_key': resolved['subject_key'],
0199 'subject_label': resolved_label,
0200 'subject_url': resolved_url,
0201 'username': username_value,
0202 'ai': ai_value,
0203 'created_at': page.get('created_at'),
0204 'linked': linked,
0205 'json_field': resolved.get('target_json_field') or '',
0206 }
0207
0208
0209 def _row_to_dict(row):
0210 data = row.data or {}
0211 return {
0212 'id': row.pk,
0213 'storage': 'legacy',
0214 'corun_page_group_id': '',
0215 'subject_type': row.subject_type,
0216 'subject_key': row.subject_key,
0217 'subject_label': row.subject_label,
0218 'subject_url': row.subject_url,
0219 'username': row.username,
0220 'ai': row.ai,
0221 'quality': data.get('quality') or '',
0222 'comment': data.get('comment') or '',
0223 'assessment': row.assessment,
0224 'data': data,
0225 'created_at': row.created_at.isoformat() if row.created_at else None,
0226 }
0227
0228
0229 def _corun_item_to_dict(item, page):
0230 data = page.get('data') if isinstance(page.get('data'), dict) else {}
0231 return {
0232 'id': None,
0233 'storage': 'corun',
0234 'corun_page_group_id': item.get('corun_page_group_id') or '',
0235 'subject_type': item.get('subject_type') or '',
0236 'subject_key': item.get('subject_key') or '',
0237 'subject_label': item.get('subject_label') or '',
0238 'subject_url': item.get('subject_url') or '',
0239 'username': item.get('username') or '',
0240 'ai': item.get('ai') or '',
0241 'quality': item.get('quality') or '',
0242 'comment': item.get('comment') or '',
0243 'assessment': item.get('assessment') or '',
0244 'data': data,
0245 'created_at': str(item.get('created_at') or page.get('created_at') or ''),
0246 }
0247
0248
0249 def _ordered_ai_content_ids(ids):
0250 if ids is None:
0251 return []
0252 if not isinstance(ids, list):
0253 raise ValueError('ids must be a list of AIContent ids')
0254
0255 ordered_ids = []
0256 for raw in ids:
0257 try:
0258 item_id = int(raw)
0259 except (TypeError, ValueError):
0260 raise ValueError(f'invalid AIContent id: {raw!r}')
0261 if item_id <= 0:
0262 raise ValueError(f'invalid AIContent id: {raw!r}')
0263 if item_id not in ordered_ids:
0264 ordered_ids.append(item_id)
0265 return ordered_ids
0266
0267
0268 def _ordered_corun_page_group_ids(page_group_ids):
0269 if page_group_ids is None:
0270 return []
0271 if not isinstance(page_group_ids, list):
0272 raise ValueError('corun_page_group_ids must be a list of corun Page group ids')
0273 ordered_ids = []
0274 for raw in page_group_ids:
0275 value = str(raw or '').strip()
0276 if not value:
0277 raise ValueError(f'invalid corun Page group id: {raw!r}')
0278 if value not in ordered_ids:
0279 ordered_ids.append(value)
0280 return ordered_ids
0281
0282
0283 def _get_ai_content_sync(ids=None, corun_page_group_ids=None):
0284 try:
0285 ordered_ids = _ordered_ai_content_ids(ids)
0286 ordered_corun_ids = _ordered_corun_page_group_ids(corun_page_group_ids)
0287 except ValueError as exc:
0288 return {'success': False, 'error': str(exc)}
0289
0290 if not ordered_ids and not ordered_corun_ids:
0291 return {
0292 'success': False,
0293 'error': 'ids or corun_page_group_ids must contain at least one id',
0294 }
0295
0296 from monitor_app.models import AIContent
0297 rows = {
0298 row.pk: row
0299 for row in AIContent.objects.filter(pk__in=ordered_ids)
0300 }
0301 items = [_row_to_dict(rows[item_id]) for item_id in ordered_ids if item_id in rows]
0302 missing_ids = [item_id for item_id in ordered_ids if item_id not in rows]
0303
0304 missing_corun_ids = []
0305 if ordered_corun_ids:
0306 if not corun_configured():
0307 return {
0308 'success': False,
0309 'error': 'corun-ai config CORUN_BASE_URL and CORUN_API_TOKEN must be configured',
0310 }
0311 client = CorunClient()
0312 for page_group_id in ordered_corun_ids:
0313 try:
0314 page = client.get_page(page_group_id)
0315 except CorunAPIError:
0316 missing_corun_ids.append(page_group_id)
0317 continue
0318 normalized = corun_page_items([page])
0319 if normalized:
0320 items.append(_corun_item_to_dict(normalized[0], page))
0321 else:
0322 missing_corun_ids.append(page_group_id)
0323
0324 return {
0325 'success': True,
0326 'count': len(items),
0327 'items': items,
0328 'missing_ids': missing_ids,
0329 'missing_corun_page_group_ids': missing_corun_ids,
0330 }
0331
0332
0333 @mcp.tool()
0334 async def epic_register_ai_assessment(
0335 subject_type: str,
0336 subject_key: str,
0337 assessment: str,
0338 username: str = 'mcp',
0339 ai: str = 'unknown',
0340 subject_label: str = '',
0341 subject_url: str = '',
0342 data: dict = None,
0343 ) -> dict:
0344 """
0345 Register an append-only AI assessment for an epicprod object.
0346
0347 Known subject types are canonicalized and linked into the target object's
0348 JSON `corun_page_group_ids` pointer:
0349 - campaign_task: campaign/production task, keyed by composed name
0350 - panda_task: local PanDA-task association, keyed by JEDI task id or task name
0351 - panda_job: local production job record, keyed by pandaid
0352 - panda_queue: PanDA site/queue record, keyed by queue name; if
0353 the key is a site name, queue_name == site represents site-level content
0354 - campaign: production campaign, keyed by campaign name (e.g. 26.05.0);
0355 the natural subject for campaign-level reports and assessments
0356
0357 Args:
0358 subject_type: Canonical subject type or alias.
0359 subject_key: Human-readable object key such as composed task name,
0360 JEDI task id, pandaid, queue name, or site name.
0361 assessment: Markdown assessment text. It is stored as a corun-ai Page.
0362 username: Human account or service account creating the assessment.
0363 Bot harnesses should pass `bot`, not a mutable bot deployment name.
0364 ai: Model or agent identifier. Bot harnesses should pass the exact
0365 model used to generate the assessment.
0366 subject_label: Optional display label override.
0367 subject_url: Optional monitor URL override.
0368 data: Optional structured metadata captured with the assessment. The
0369 server stamps `registered_via='mcp'` and
0370 `mcp_tool='epic_register_ai_assessment'` on stored metadata.
0371
0372 Returns:
0373 Success status, corun-ai Page ids, canonical subject reference, created_at,
0374 and whether the target object JSON pointer was updated.
0375 """
0376 return await sync_to_async(_register_ai_assessment_sync)(
0377 subject_type=subject_type,
0378 subject_key=subject_key,
0379 assessment=assessment,
0380 username=username,
0381 ai=ai,
0382 subject_label=subject_label,
0383 subject_url=subject_url,
0384 data=data,
0385 )
0386
0387
0388 @mcp.tool()
0389 async def epic_get_ai_content(ids: list = None, corun_page_group_ids: list = None) -> dict:
0390 """
0391 Retrieve append-only epicprod AI assessment content.
0392
0393 Detail tools that can have AI assessments return an `ai_content` block with
0394 this exact retrieval instruction:
0395
0396 {
0397 "available": true,
0398 "count": 2,
0399 "ids": [],
0400 "corun_page_group_ids": ["..."],
0401 "retrieval": {
0402 "tool": "epic_get_ai_content",
0403 "arguments": {"corun_page_group_ids": ["..."]}
0404 }
0405 }
0406
0407 Use the supplied `ids` and/or `corun_page_group_ids` directly. Do not
0408 reconstruct subject_type or subject_key from the parent object when a
0409 detail payload already includes this retrieval block.
0410
0411 Args:
0412 ids: Optional list of legacy AIContent integer ids from an MCP detail payload's
0413 `ai_content.ids` or `ai_content.retrieval.arguments.ids`.
0414 corun_page_group_ids: Optional list of corun-ai Page group ids from
0415 `ai_content.corun_page_group_ids` or
0416 `ai_content.retrieval.arguments.corun_page_group_ids`.
0417
0418 Returns:
0419 success, count, items in requested id order, and missing_ids for any
0420 ids that no longer resolve. Each item includes storage type, subject
0421 metadata, username, ai/model identifier, Markdown assessment text,
0422 structured data, and created_at.
0423 """
0424 return await sync_to_async(_get_ai_content_sync)(
0425 ids=ids,
0426 corun_page_group_ids=corun_page_group_ids,
0427 )