Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 153 additions & 151 deletions .claude/skills/ai-review.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/contributions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def has_add_permission(self, request):
return False

def has_delete_permission(self, request, obj=None):
return False
return request.user.is_superuser

def get_readonly_fields(self, request, obj=None):
return [field.name for field in self.model._meta.fields]
Expand Down
71 changes: 64 additions & 7 deletions backend/contributions/ai_review/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class AIReviewFilterSet(FilterSet):
exclude_username = CharFilter(method='filter_exclude_username')
assigned_to = CharFilter(method='filter_assigned_to')
exclude_assigned_to = CharFilter(method='filter_exclude_assigned_to')
proposed_by = CharFilter(method='filter_proposed_by')
exclude_proposed_by = CharFilter(method='filter_exclude_proposed_by')
include_content = CharFilter(method='filter_include_content')
exclude_content = CharFilter(method='filter_exclude_content')
exclude_empty_evidence = BooleanFilter(method='filter_exclude_empty_evidence')
Expand Down Expand Up @@ -113,6 +115,55 @@ def filter_exclude_assigned_to(self, queryset, name, value):
return queryset.exclude(assigned_to_id=value)
return queryset

def _proposed_by_condition(self, value):
if value in ('none', 'null', 'unproposed'):
return Q(proposed_by__isnull=True)
if value == 'ai':
return Q(proposed_by__email=AI_STEWARD_EMAIL)
return Q(proposed_by_id=value)

def _is_reviewed_history_request(self):
parser_context = getattr(self.request, 'parser_context', {}) if self.request else {}
view = parser_context.get('view')
return getattr(view, 'action', None) == 'reviewed'

def _historical_proposal_note_exists(self, value):
notes = SubmissionNote.objects.filter(
submitted_contribution=OuterRef('pk'),
is_proposal=True,
)
if value == 'ai':
notes = notes.filter(user__email=AI_STEWARD_EMAIL)
else:
notes = notes.filter(user_id=value)
return Exists(notes)

def filter_proposed_by(self, queryset, name, value):
if value:
if self._is_reviewed_history_request():
if value in ('none', 'null', 'unproposed'):
notes = SubmissionNote.objects.filter(
submitted_contribution=OuterRef('pk'),
is_proposal=True,
)
return queryset.filter(~Exists(notes))
return queryset.filter(self._historical_proposal_note_exists(value))
return queryset.filter(self._proposed_by_condition(value))
return queryset

def filter_exclude_proposed_by(self, queryset, name, value):
if value:
if self._is_reviewed_history_request():
if value in ('none', 'null', 'unproposed'):
notes = SubmissionNote.objects.filter(
submitted_contribution=OuterRef('pk'),
is_proposal=True,
)
return queryset.exclude(~Exists(notes))
return queryset.exclude(self._historical_proposal_note_exists(value))
return queryset.exclude(self._proposed_by_condition(value))
return queryset

def filter_search(self, queryset, name, value):
if value:
has_matching_evidence = Evidence.objects.filter(
Expand Down Expand Up @@ -416,13 +467,25 @@ def propose(self, request, pk=None):
)

is_update = request.method == 'PUT'
ai_user = self._get_ai_user()
if ai_user is None:
return Response(
{'error': 'AI steward user not found. Run migrations.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

if is_update and submission.proposed_action is None:
return Response(
{'error': 'This submission has no proposal to update.'},
status=status.HTTP_404_NOT_FOUND,
)

if is_update and submission.proposed_by_id != ai_user.id:
return Response(
{'error': 'Only AI-created proposals can be updated through this endpoint.'},
status=status.HTTP_403_FORBIDDEN,
)

if not is_update and submission.proposed_action is not None:
return Response(
{'error': 'This submission already has an active proposal.'},
Expand All @@ -432,13 +495,6 @@ def propose(self, request, pk=None):
serializer = AIReviewProposeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

ai_user = self._get_ai_user()
if ai_user is None:
return Response(
{'error': 'AI steward user not found. Run migrations.'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

return self._validate_and_apply_proposal(
submission, serializer.validated_data, ai_user, is_update=is_update,
)
Expand All @@ -456,6 +512,7 @@ def proposed(self, request):
SubmittedContribution.objects.filter(
state='pending',
proposed_action__isnull=False,
proposed_by__email=AI_STEWARD_EMAIL,
)
.select_related(
'contribution_type',
Expand Down
74 changes: 74 additions & 0 deletions backend/contributions/tests/test_calibration_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,54 @@ def test_ai_propose_endpoint_stores_structured_note_data(self):
self.assertEqual(note.data['confidence'], 'high')
self.assertEqual(note.data['reasoning'], 'The evidence provided does not support the claim.')

def test_proposed_endpoint_returns_only_ai_created_proposals(self):
ai_submission = self.fixtures['submission']
ai_submission.proposed_action = 'reject'
ai_submission.proposed_by = self.fixtures['ai_user']
ai_submission.proposed_confidence = 'low'
ai_submission.save()

human_submission = SubmittedContribution.objects.create(
user=self.fixtures['submitter'],
contribution_type=self.fixtures['ct'],
contribution_date=timezone.now(),
notes='Human-proposed submission',
state='pending',
proposed_action='more_info',
proposed_by=self.fixtures['steward_user'],
proposed_confidence='low',
)

response = self.client.get(
'/api/v1/ai-review/proposed/',
data={'proposed_confidence': 'low'},
HTTP_X_AI_REVIEW_KEY='test-ai-review-key',
)

self.assertEqual(response.status_code, 200)
ids = {str(item['id']) for item in response.data['results']}
self.assertIn(str(ai_submission.id), ids)
self.assertNotIn(str(human_submission.id), ids)

def test_ai_cannot_update_human_created_proposal(self):
submission = self.fixtures['submission']
submission.proposed_action = 'reject'
submission.proposed_by = self.fixtures['steward_user']
submission.save()

response = self.client.put(
f'/api/v1/ai-review/{submission.id}/propose/',
data={
'proposed_action': 'reject',
'proposed_staff_reply': 'Updated reply.',
'confidence': 'medium',
},
content_type='application/json',
HTTP_X_AI_REVIEW_KEY='test-ai-review-key',
)

self.assertEqual(response.status_code, 403)


@override_settings(ALLOWED_HOSTS=['*'])
class TestCalibrationComparison(APITestCase):
Expand Down Expand Up @@ -543,6 +591,32 @@ def test_reviewed_endpoint_returns_reviewed_submission_with_ai_proposal(self):
self.assertEqual(response.data['results'][0]['state'], 'accepted')
self.assertEqual(len(response.data['results'][0]['internal_notes']), 2)

def test_reviewed_endpoint_filters_proposed_by_ai_using_historical_notes(self):
submission = self.fixtures['submission']
ai_user = self.fixtures['ai_user']
steward_user = self.fixtures['steward_user']

submission.state = 'accepted'
submission.reviewed_by = steward_user
submission.reviewed_at = timezone.now()
submission.proposed_action = None
submission.proposed_by = None
submission.save()

SubmissionNote.objects.create(
submitted_contribution=submission,
user=ai_user,
message='AI proposal: accept',
is_proposal=True,
data={'action': 'accept', 'points': 5, 'confidence': 'medium'},
)

response = self._get_reviewed(proposed_by='ai')

self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)
self.assertEqual(str(response.data['results'][0]['id']), str(submission.id))

def test_reviewed_endpoint_excludes_human_only_proposals(self):
ai_submission = self.fixtures['submission']
ai_user = self.fixtures['ai_user']
Expand Down
23 changes: 23 additions & 0 deletions backend/contributions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
'community-link-discord',
]

AI_STEWARD_EMAIL = 'genlayer-steward@genlayer.foundation'


class ContributionTypeViewSet(viewsets.ReadOnlyModelViewSet):
"""
Expand Down Expand Up @@ -1031,6 +1033,8 @@ class StewardSubmissionFilterSet(FilterSet):
exclude_assigned_to = CharFilter(method='filter_exclude_assigned_to')
reviewed_by = CharFilter(method='filter_reviewed_by')
exclude_reviewed_by = CharFilter(method='filter_exclude_reviewed_by')
proposed_by = CharFilter(method='filter_proposed_by')
exclude_proposed_by = CharFilter(method='filter_exclude_proposed_by')
exclude_contribution_type = NumberFilter(method='filter_exclude_contribution_type')
exclude_content = CharFilter(method='filter_exclude_content')
include_content = CharFilter(method='filter_include_content')
Expand Down Expand Up @@ -1128,6 +1132,25 @@ def filter_exclude_reviewed_by(self, queryset, name, value):
return queryset.exclude(reviewed_by_id=value)
return queryset

def _proposed_by_condition(self, value):
if value in ('none', 'null', 'unproposed'):
return Q(proposed_by__isnull=True)
if value == 'ai':
return Q(proposed_by__email=AI_STEWARD_EMAIL)
return Q(proposed_by_id=value)

def filter_proposed_by(self, queryset, name, value):
"""Filter by steward or agent who created the active proposal."""
if value:
return queryset.filter(self._proposed_by_condition(value))
return queryset

def filter_exclude_proposed_by(self, queryset, name, value):
"""Exclude active proposals created by a specific steward or agent."""
if value:
return queryset.exclude(self._proposed_by_condition(value))
return queryset

def filter_exclude_contribution_type(self, queryset, name, value):
"""Exclude submissions of a specific contribution type."""
if value:
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/StewardSearchBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
{ name: 'from', description: 'Search by user name/email/address', values: () => [] },
{ name: 'assigned', description: 'Filter by assignment', values: () => ['me', 'unassigned', ...stewardsList.map(s => s.name || s.address?.slice(0, 10))] },
{ name: 'reviewed', description: 'Filter by steward who reviewed', values: () => ['me', ...stewardsList.map(s => s.name || s.address?.slice(0, 10))] },
{ name: 'proposed-by', description: 'Filter by proposal creator', values: () => ['ai', 'me', 'none', ...stewardsList.map(s => s.name || s.address?.slice(0, 10))] },
{ name: 'exclude', description: 'Exclude submissions containing text', values: () => ['medium.com'] },
{ name: 'include', description: 'Only show submissions containing text', values: () => [] },
{ name: 'has', description: 'Filter by presence', values: () => ['url', 'evidence', 'proposal', 'appeal'] },
Expand Down Expand Up @@ -237,6 +238,7 @@
<div class="help-row"><code>from:username</code><span>Search by user name/email/address</span></div>
<div class="help-row"><code>assigned:me</code><span>Filter by assignment (me, unassigned, name)</span></div>
<div class="help-row"><code>reviewed:me</code><span>Filter by steward who accepted/rejected it</span></div>
<div class="help-row"><code>proposed-by:ai</code><span>Only active proposals from the AI reviewer</span></div>
<div class="help-row"><code>exclude:medium.com</code><span>Exclude submissions containing text</span></div>
<div class="help-row"><code>include:genlayer</code><span>Only show submissions containing text</span></div>
<div class="help-row"><code>has:url</code><span>Only submissions with URLs</span></div>
Expand All @@ -256,11 +258,13 @@
<div class="help-section">
<div class="help-subtitle">Negation</div>
<div class="help-row"><code>-type:blog-post</code><span>Exclude contribution type</span></div>
<div class="help-row"><code>-proposed-by:ai</code><span>Exclude active AI proposals</span></div>
<div class="help-row"><code>-mission:name</code><span>Exclude a mission while keeping other matches</span></div>
</div>
<div class="help-section">
<div class="help-subtitle">Examples</div>
<div class="help-example">assigned:me exclude:medium.com has:url</div>
<div class="help-example">has:proposal proposed-by:ai confidence:low</div>
<div class="help-example">status:accepted reviewed:alice</div>
<div class="help-example">from:alice -type:referral min-contributions:2</div>
<div class="help-example">type:bug-report -mission:wallet-login is:resubmitted</div>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/searchParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* - from:username
* - assigned:me|unassigned|steward-name
* - reviewed:me|steward-name
* - proposed-by:ai|me|none|steward-name
* - exclude:text (multiple allowed)
* - include:text (multiple allowed)
* - has:url|evidence|proposal|appeal
Expand All @@ -20,7 +21,7 @@
* Quoted values: tag:"value with spaces"
*/

const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'reviewed', 'sort', 'confidence', 'template', 'proposal', 'mission'];
const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'reviewed', 'proposed-by', 'sort', 'confidence', 'template', 'proposal', 'mission'];
const MULTI_VALUE_TAGS = ['exclude', 'include', 'has', 'no', 'is', 'not'];
const NUMERIC_TAGS = ['min-contributions'];
const NEGATED_MULTI_VALUE_TAGS = {
Expand Down Expand Up @@ -122,6 +123,7 @@ export function parseSearch(query) {
from: null,
assigned: null,
reviewed: null,
'proposed-by': null,
exclude: [],
include: [],
has: [],
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/lib/searchToParams.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,38 @@ export function searchToParams(parsed, options = {}) {
}
}

// proposed-by → proposed_by (the steward/agent who created the active proposal)
if (filters['proposed-by']) {
const val = filters['proposed-by'].value.toLowerCase();
let proposedByValue = null;

if (val === 'ai') {
proposedByValue = 'ai';
} else if (val === 'me' && currentUserId) {
proposedByValue = currentUserId;
} else if (val === 'none' || val === 'unproposed') {
proposedByValue = 'none';
} else {
const steward = stewardsList.find(s =>
String(s.user_id) === filters['proposed-by'].value ||
s.name?.toLowerCase().includes(val) ||
s.user_name?.toLowerCase().includes(val) ||
s.address?.toLowerCase().includes(val)
);
if (steward) {
proposedByValue = steward.user_id;
}
}

if (proposedByValue) {
if (filters['proposed-by'].negated) {
params.exclude_proposed_by = proposedByValue;
} else {
params.proposed_by = proposedByValue;
}
}
}

// exclude → exclude_content (comma-separated for multiple values)
if (filters.exclude && filters.exclude.length > 0) {
params.exclude_content = filters.exclude.join(',');
Expand Down
Loading
Loading