File indexing completed on 2026-07-21 08:46:25
0001 """AI app pages: the AI proposal list."""
0002 from django.db.models import Count
0003 from django.shortcuts import render
0004
0005 from .models import Proposal
0006
0007
0008 def ai_proposals(request):
0009 """The AI proposal list (AI_PROPOSALS.md).
0010
0011 Pending proposals for review, decision history, and per-proposer track
0012 records. Read-open — visible-but-inert; decisions require sign-in and
0013 act through the same proposal-decide service as the catalog and
0014 compose surfaces.
0015 """
0016 def url_with(**updates):
0017 params = request.GET.copy()
0018 for key, value in updates.items():
0019 if value:
0020 params[key] = value
0021 else:
0022 params.pop(key, None)
0023 encoded = params.urlencode()
0024 return f'{request.path}?{encoded}' if encoded else request.path
0025
0026 filters = {key: (request.GET.get(key) or '').strip()
0027 for key in ('status', 'action', 'change', 'decision',
0028 'quality', 'proposer', 'batch', 'subject')}
0029 status_filter = filters['status'] or 'all'
0030
0031 qs = Proposal.objects.all()
0032 if status_filter != 'all':
0033 qs = qs.filter(status=status_filter)
0034 if filters['subject']:
0035 qs = qs.filter(subject_key=filters['subject'])
0036 if filters['action']:
0037 qs = qs.filter(action=filters['action'])
0038 if filters['change'] and ':' in filters['change']:
0039 prev_state, _, new_state = filters['change'].partition(':')
0040 qs = qs.filter(precondition__prev_state=prev_state,
0041 payload__state=new_state)
0042 if filters['decision']:
0043 qs = qs.filter(decided_by=filters['decision'])
0044 if filters['quality']:
0045 qs = qs.filter(quality=filters['quality'])
0046 if filters['proposer']:
0047 qs = qs.filter(proposer=filters['proposer'])
0048 if filters['batch']:
0049 qs = qs.filter(batch_id=filters['batch'])
0050
0051 total_count = qs.count()
0052 rows = list(qs.order_by('-created_at')[:500])
0053
0054
0055
0056 everything = Proposal.objects.all()
0057
0058 def facet_row(title, param, pairs, label_of=str):
0059 items = [{
0060 'label': label_of(value), 'count': count,
0061 'url': url_with(**{param: value}),
0062 'active': filters[param] == value,
0063 } for value, count in pairs if value]
0064 return {'title': title, 'items': items,
0065 'all_url': url_with(**{param: ''}),
0066 'all_active': not filters[param]}
0067
0068 status_counts = dict(everything.values_list('status').annotate(Count('id')))
0069 status_order = ['proposed', 'executed', 'denied', 'withdrawn',
0070 'stale', 'approved_pending_execution']
0071 status_row = {
0072 'title': 'Status',
0073 'items': [{'label': s, 'count': status_counts.get(s, 0),
0074 'url': url_with(status=s), 'active': status_filter == s}
0075 for s in status_order
0076 if status_counts.get(s, 0) or s == 'proposed'],
0077 'all_url': url_with(status=''),
0078 'all_active': status_filter == 'all',
0079 }
0080
0081 action_labels = {'propagation': 'campaign propagation'}
0082 facet_rows = [
0083 status_row,
0084 facet_row('Action', 'action',
0085 everything.values_list('action').annotate(Count('id'))
0086 .order_by('action'),
0087 lambda a: action_labels.get(a, a)),
0088 facet_row('Change', 'change', [
0089 (f'{prev}:{new}', count)
0090 for prev, new, count in everything
0091 .values_list('precondition__prev_state', 'payload__state')
0092 .annotate(Count('id')).order_by()
0093 if prev and new],
0094 lambda v: v.replace(':', ' → ')),
0095 facet_row('Decision', 'decision',
0096 everything.exclude(decided_by='')
0097 .values_list('decided_by').annotate(Count('id'))
0098 .order_by('decided_by')),
0099 facet_row('Quality', 'quality',
0100 everything.exclude(quality='')
0101 .values_list('quality').annotate(Count('id'))
0102 .order_by('quality')),
0103 facet_row('Proposer', 'proposer',
0104 everything.exclude(proposer='')
0105 .values_list('proposer').annotate(Count('id'))
0106 .order_by('proposer')),
0107 facet_row('Batch', 'batch',
0108 everything.exclude(batch_id='')
0109 .values_list('batch_id').annotate(Count('id'))
0110 .order_by('batch_id')),
0111 ]
0112
0113 proposer_stats = []
0114 for proposer in (Proposal.objects.exclude(proposer='').order_by()
0115 .values_list('proposer', flat=True).distinct()):
0116 base = Proposal.objects.filter(proposer=proposer)
0117 proposer_stats.append({
0118 'proposer': proposer,
0119 'total': base.count(),
0120 'pending': base.filter(status='proposed').count(),
0121 'executed': base.filter(status='executed').count(),
0122 'denied': base.filter(status='denied').count(),
0123 'wrong': base.filter(quality='wrong').count(),
0124 })
0125
0126 return render(request, 'ai/proposals.html', {
0127 'rows': rows,
0128 'total_count': total_count,
0129 'shown_count': len(rows),
0130 'facet_rows': facet_rows,
0131 'subject_filter': filters['subject'],
0132 'subject_clear_url': url_with(subject=''),
0133 'proposer_stats': proposer_stats,
0134 })
0135
0136
0137 def _narrative_entry_from_page(page, client, *, with_versions=False,
0138 with_comments=True):
0139 """Shared shaping of a corun narrative page for templates."""
0140 from .assessments import render_assessment_markdown
0141 from .corun_client import CorunAPIError
0142
0143 def _strip_h1(text):
0144
0145
0146
0147 stripped = text.lstrip()
0148 if stripped.startswith('# '):
0149 return stripped.split('\n', 1)[1] if '\n' in stripped else ''
0150 return text
0151
0152 data = page.get('data') or {}
0153 content = page.get('content') or ''
0154 group_id = page.get('group_id') or page.get('id')
0155 entry = {
0156 'name': data.get('name', ''),
0157 'title': page.get('title') or data.get('name', ''),
0158 'version': page.get('version'),
0159 'updated': (page.get('modified_at') or page.get('created_at') or ''),
0160 'content': content,
0161 'html': render_assessment_markdown(_strip_h1(content)),
0162 'group_id': group_id,
0163 'versions': [],
0164 'comments': [],
0165 }
0166 if with_versions:
0167 try:
0168 for v in sorted(client.list_versions(group_id) or [],
0169 key=lambda x: x.get('version', 0), reverse=True):
0170 v_content = v.get('content') or ''
0171 entry['versions'].append({
0172 'version': v.get('version'),
0173 'date': (v.get('created_at') or '')[:16].replace('T', ' '),
0174 'author': (v.get('data') or {}).get('author', ''),
0175 'lines': len(v_content.splitlines()),
0176 'is_current': bool(v.get('is_current')),
0177 'html': render_assessment_markdown(_strip_h1(v_content)),
0178 })
0179 except CorunAPIError:
0180 entry['versions'] = []
0181 if with_comments:
0182 try:
0183 for c in client.list_comments(group_id) or []:
0184
0185
0186
0187 stamped = (c.get('data') or {}).get('author', '')
0188 fallback = c.get('author')
0189 if isinstance(fallback, dict):
0190 fallback = fallback.get('username', '')
0191 entry['comments'].append({
0192 'author': stamped or fallback or '',
0193 'date': (c.get('created_at') or '')[:16].replace('T', ' '),
0194 'content': c.get('content') or '',
0195 })
0196 except CorunAPIError:
0197 entry['comments'] = []
0198 return entry
0199
0200
0201 def _narrative_pages(client):
0202 payload = client.list_pages(
0203 section='epicprod.narrative',
0204 artifact_type='campaign_narrative',
0205 limit=100,
0206 )
0207 if isinstance(payload, dict):
0208 return payload.get('results') or payload.get('items') or []
0209 return payload or []
0210
0211
0212 def ai_narratives(request):
0213 """The Campaign Narratives list (EPICPROD_NARRATIVES.md).
0214
0215 Collapsible read view with comments; document management (editing,
0216 version history) lives on the per-document detail page. Read-open; a
0217 corun-ai failure renders as an error message, never an empty page.
0218 """
0219 from .corun_client import CorunAPIError, CorunClient, corun_configured
0220
0221 entries, error = [], ''
0222 if not corun_configured():
0223 error = 'corun-ai is not configured on this deployment.'
0224 else:
0225 try:
0226 client = CorunClient()
0227 entries = [
0228 _narrative_entry_from_page(p, client, with_comments=True)
0229 for p in _narrative_pages(client)
0230 ]
0231 except CorunAPIError as exc:
0232 error = f'corun-ai retrieval failed: {exc}'
0233
0234
0235 general_entries = sorted(
0236 (e for e in entries if e['name'].startswith('campaign_general')),
0237 key=lambda e: e['updated'], reverse=True)
0238 campaign_entries = sorted(
0239 (e for e in entries if not e['name'].startswith('campaign_general')),
0240 key=lambda e: e['updated'], reverse=True)
0241 return render(request, 'ai/narratives.html',
0242 {'general_entries': general_entries,
0243 'campaign_entries': campaign_entries,
0244 'entries': entries, 'error': error})
0245
0246
0247 def ai_narrative_detail(request, name):
0248 """One narrative document: content, expert editing, the version
0249 history (tjai-style, at the bottom), and comments."""
0250 from django.http import Http404
0251
0252 from .corun_client import CorunAPIError, CorunClient, corun_configured
0253
0254 if not corun_configured():
0255 raise Http404('corun-ai is not configured')
0256 try:
0257 client = CorunClient()
0258 page = next((p for p in _narrative_pages(client)
0259 if (p.get('data') or {}).get('name') == name), None)
0260 except CorunAPIError as exc:
0261 return render(request, 'ai/narrative_detail.html',
0262 {'entry': None, 'error': f'corun-ai retrieval failed: {exc}'})
0263 if page is None:
0264 raise Http404(f'No narrative named {name!r}')
0265 entry = _narrative_entry_from_page(page, client, with_versions=True,
0266 with_comments=True)
0267 return render(request, 'ai/narrative_detail.html',
0268 {'entry': entry, 'error': ''})