From 5bf65ef1e17200151be2a1aefd7c16a4f746bc10 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 28 May 2026 19:14:58 +0200 Subject: [PATCH] Clarify AI review filters (#704) * Clarify AI review filters * Avoid Discord XP reload on status changes * Allow superusers to delete read-only admin rows --- .claude/skills/ai-review.md | 304 +++++++++--------- backend/contributions/admin.py | 2 +- backend/contributions/ai_review/views.py | 71 +++- .../tests/test_calibration_data.py | 74 +++++ backend/contributions/views.py | 23 ++ .../src/components/StewardSearchBar.svelte | 4 + frontend/src/lib/searchParser.js | 4 +- frontend/src/lib/searchToParams.js | 32 ++ frontend/src/routes/StewardDiscordXP.svelte | 32 +- frontend/src/routes/StewardSubmissions.svelte | 7 +- frontend/src/tests/searchParser.test.js | 6 + 11 files changed, 388 insertions(+), 171 deletions(-) diff --git a/.claude/skills/ai-review.md b/.claude/skills/ai-review.md index 3d52c34a..ee893579 100644 --- a/.claude/skills/ai-review.md +++ b/.claude/skills/ai-review.md @@ -1,201 +1,203 @@ --- name: ai-review description: > - Review pending community submissions for the GenLayer Testnet Program via the AI Review API. - Use when asked to review submissions, evaluate pending contributions, propose accept/reject/more_info - actions, or process the submission review queue. Authenticates via X-AI-Review-Key header against - the backend API. All proposals require human steward approval before being applied. + Use the GenLayer AI Review API to fetch submissions, inspect details, create + AI review proposals, update AI-created proposals, list active AI proposals, + and inspect reviewed AI proposals for calibration. All requests require + X-AI-Review-Key. The AI Review API creates proposals only; it does not apply + final accept, reject, or more-info decisions. --- -# AI Submission Review +# AI Review API -Review pending community submissions for the GenLayer Testnet Program and propose review actions. +## Rules -## Prerequisites - -- Backend server running (local or remote) -- `AI_REVIEW_API_KEY` configured in the backend `.env` file +- Use only `/api/v1/ai-review/...` endpoints. +- Do not call `/api/v1/steward-submissions/{id}/review/`. +- Fetch `/api/v1/ai-review/{id}/` before proposing on any submission. +- Use POST to create an AI proposal. Use PUT only to update an AI-created proposal. +- If PUT returns 403, stop; the active proposal is not AI-created. +- `proposed_staff_reply` is user-visible. `reasoning` is internal. ## Setup -Before making any API calls: - -1. Read the API key from the backend `.env` file (look for `AI_REVIEW_API_KEY`) -2. Determine the backend base URL (default: `http://localhost:8000`) -3. All requests require the header: `X-AI-Review-Key: ` - -Use `curl` via the Bash tool for all API calls. - -## Workflow - -Follow this sequence for every review session: - -### Step 1: Fetch templates - -```bash -curl -s -H "X-AI-Review-Key: $KEY" "$BASE_URL/api/v1/ai-review/templates/" -``` +Set: -Cache the template list for the session. Templates have `id`, `label`, and `text` fields. -Use template text as the basis for `proposed_staff_reply` to keep responses consistent. +- `KEY`: `AI_REVIEW_API_KEY` from backend `.env` +- `BASE_URL`: backend base URL, usually `http://localhost:8000` -### Step 2: List pending submissions +Every request needs: ```bash -curl -s -H "X-AI-Review-Key: $KEY" "$BASE_URL/api/v1/ai-review/?page_size=10" +-H "X-AI-Review-Key: $KEY" ``` -The list view returns lightweight data (no evidence or user history — just id, type, category, mission, appeal fields, notes, state, created_at). -Use it to identify which submissions to evaluate in detail. - -The AI Review list, `proposed/`, and `reviewed/` endpoints accept the same query parameters generated by the steward submission search bar: - -| Parameter | Values / notes | -|-----------|----------------| -| `state` | `pending`, `accepted`, `rejected`, `more_info_needed` | -| `exclude_state` | Exclude a state | -| `contribution_type` | Contribution type ID | -| `exclude_contribution_type` | Exclude a contribution type ID | -| `category` | Category slug, e.g. `builder`, `validator` | -| `exclude_category` | Exclude a category slug | -| `username_search` | Submitter name, email, or address text | -| `exclude_username` | Exclude submitters matching text | -| `assigned_to` | Steward user ID, `unassigned`, or `null` | -| `exclude_assigned_to` | Exclude steward user ID, `unassigned`, or `null` | -| `search` | Free text across submitter, notes, and evidence | -| `include_content` | Comma-separated terms that must appear in notes/evidence | -| `exclude_content` | Comma-separated terms to exclude from notes/evidence | -| `exclude_empty_evidence` | `true` means require URL evidence or URL in notes | -| `only_empty_evidence` | `true` means no URL evidence and no URL in notes | -| `has_proposal` | `true` or `false` | -| `proposed_action` | `accept`, `reject`, `more_info` | -| `proposed_confidence` | `high`, `medium`, `low` | -| `proposed_template` | Review template ID | -| `has_appeal` | `true` or `false`; main list excludes appeals unless this is explicit | -| `is_interesting` | `true` or `false` | -| `resubmitted_more_info` | `true` for pending submissions edited after a more-info review | -| `mission` | Mission ID, `none`, or `null` | -| `exclude_mission` | Exclude a mission ID, or use `none`/`null` to require a mission | -| `min_accepted_contributions` | Minimum accepted submissions by the submitter | -| `ordering` | `created_at`, `-created_at`, `contribution_date`, `-contribution_date` | - -Examples: - -```bash -curl -s -H "X-AI-Review-Key: $KEY" \ - "$BASE_URL/api/v1/ai-review/?category=builder&exclude_mission=42&resubmitted_more_info=true" - -curl -s -H "X-AI-Review-Key: $KEY" \ - "$BASE_URL/api/v1/ai-review/proposed/?proposed_confidence=low&has_appeal=false" -``` +## Endpoints -### Step 3: Get submission detail +| Endpoint | Method | Purpose | Default Scope | +|---|---:|---|---| +| `/api/v1/ai-review/` | GET | Find new submissions to evaluate | Pending, unproposed, non-appealed unless `has_appeal` is sent | +| `/api/v1/ai-review/{id}/` | GET | Full submission detail | One pending submission | +| `/api/v1/ai-review/{id}/propose/` | POST | Create AI proposal | Fails if an active proposal already exists | +| `/api/v1/ai-review/{id}/propose/` | PUT | Update AI-created proposal | Fails if no proposal exists or proposal is human-created | +| `/api/v1/ai-review/proposed/` | GET | List active AI proposals | Pending submissions proposed by AI | +| `/api/v1/ai-review/reviewed/` | GET | Calibration data | Reviewed submissions that have AI proposal notes | -For each submission to evaluate: +Use `/ai-review/` for new work, `/ai-review/proposed/` for active AI proposals, +and `/ai-review/reviewed/` only for calibration. -```bash -curl -s -H "X-AI-Review-Key: $KEY" "$BASE_URL/api/v1/ai-review/{uuid}/" -``` - -The detail view returns the full submission including `mission`, `has_appeal`, `appeal_reason`, -`evidence_items` (URLs, descriptions), and `user_history` (accepted_count, rejected_count, pending_count). -Use this data to make your evaluation. +## Workflow -### Step 4: Propose a review (POST = create, PUT = update) +1. Fetch candidates from `/api/v1/ai-review/`. +2. Fetch detail for one candidate with `/api/v1/ai-review/{id}/`. +3. Decide `accept`, `reject`, or `more_info`. +4. POST a proposal to `/api/v1/ai-review/{id}/propose/`. +5. Continue pagination with `page` and `page_size`. -**Create** a new proposal (fails with 409 if one already exists): +Create proposal: ```bash -curl -s -X POST -H "X-AI-Review-Key: $KEY" -H "Content-Type: application/json" \ +curl -s -X POST \ + -H "X-AI-Review-Key: $KEY" \ + -H "Content-Type: application/json" \ "$BASE_URL/api/v1/ai-review/{uuid}/propose/" \ -d '{ "proposed_action": "reject", - "proposed_staff_reply": "Your submission lacks verifiable evidence.", - "reasoning": "No evidence URLs. User has 3 rejections, 0 acceptances.", - "confidence": "high", - "template_id": 7 + "proposed_staff_reply": "Your submission lacks verifiable evidence for the work described.", + "reasoning": "No evidence URLs were provided and the notes do not identify a concrete artifact.", + "confidence": "high" }' ``` -**Update** an existing proposal (fails with 404 if no proposal exists): +Update AI-created proposal: ```bash -curl -s -X PUT -H "X-AI-Review-Key: $KEY" -H "Content-Type: application/json" \ +curl -s -X PUT \ + -H "X-AI-Review-Key: $KEY" \ + -H "Content-Type: application/json" \ "$BASE_URL/api/v1/ai-review/{uuid}/propose/" \ -d '{ "proposed_action": "accept", "proposed_points": 3, - "proposed_staff_reply": "After re-evaluation, this contribution meets the criteria.", - "reasoning": "Revisited evidence — GitHub repo has real code and README.", + "proposed_staff_reply": "This contribution meets the program criteria after re-evaluating the repository.", + "reasoning": "The repository includes working code, documentation, and GenLayer-specific implementation details.", "confidence": "medium" }' ``` -Use PUT when you want to change your mind on a previous proposal (e.g. after fetching more context or re-evaluating evidence). Both the old and new proposals are preserved as CRM notes for audit. +## Proposal Body + +| Field | Required When | Values | +|---|---|---| +| `proposed_action` | Always | `accept`, `reject`, `more_info` | +| `proposed_points` | `proposed_action=accept` | Integer within `min_points` and `max_points` from detail | +| `proposed_staff_reply` | `reject`, `more_info`; optional for `accept` | User-visible text | +| `reasoning` | Optional | Internal text | +| `confidence` | Optional | `high`, `medium`, `low`; defaults to `medium` | + +## Filters + +All list endpoints support these parameters unless the endpoint default scope +already makes the filter meaningless. Send backend parameter names exactly. + +| Parameter | Values | Meaning | +|---|---|---| +| `page` | positive integer | Page number | +| `page_size` | 1-100 | Results per page | +| `ordering` | `created_at`, `-created_at`, `contribution_date`, `-contribution_date` | Sort order | +| `state` | `pending`, `accepted`, `rejected`, `more_info_needed`, `canceled` | Current submission state | +| `exclude_state` | same as `state` | Exclude current state | +| `contribution_type` | contribution type ID | Exact contribution type | +| `exclude_contribution_type` | contribution type ID | Exclude contribution type | +| `category` | category slug | Contribution type category | +| `exclude_category` | category slug | Exclude category | +| `mission` | mission ID, `none`, `null` | Exact mission, or no mission | +| `exclude_mission` | mission ID, `none`, `null` | Exclude mission, or require a mission when value is `none`/`null` | +| `username_search` | text | Match submitter name, email, or address | +| `exclude_username` | text | Exclude matching submitters | +| `assigned_to` | user ID, `unassigned`, `null` | Assigned steward, or unassigned | +| `exclude_assigned_to` | user ID, `unassigned`, `null` | Exclude assigned steward, or exclude unassigned | +| `proposed_by` | user ID, `ai`, `none`, `null`, `unproposed` | Proposal creator. On `/proposed/`, this means the active proposal creator. On `/reviewed/`, this means historical proposal-note creator. `ai` means `genlayer-steward@genlayer.foundation`; `none`/`null`/`unproposed` means no creator | +| `exclude_proposed_by` | user ID, `ai`, `none`, `null`, `unproposed` | Exclude proposal creator using the same active-vs-historical rule as `proposed_by` | +| `search` | text | Match submitter name/email/address, notes, evidence URL, or evidence description | +| `include_content` | comma-separated terms | Every term must match notes or evidence | +| `exclude_content` | comma-separated terms | Exclude if any term matches notes or evidence | +| `exclude_empty_evidence` | `true`, `false` | `true` keeps submissions with URL evidence or URL in notes | +| `only_empty_evidence` | `true`, `false` | `true` keeps submissions with no URL evidence and no URL in notes | +| `has_proposal` | `true`, `false` | Whether `proposed_action` is set | +| `proposed_action` | `accept`, `reject`, `more_info` | Active proposal action | +| `proposed_confidence` | `high`, `medium`, `low` | Active proposal confidence | +| `proposed_template` | template ID | Active proposal template | +| `has_appeal` | `true`, `false` | Submitter appeal flag | +| `is_interesting` | `true`, `false` | Internal interesting flag | +| `resubmitted_more_info` | `true`, `false` | Pending submissions that were reviewed before and edited after that review | +| `min_accepted_contributions` | positive integer | Submitter has at least this many accepted submissions | + +## More-Info Filters + +Use the exact filter for the question being asked: + +| Need | Endpoint | Filter | +|---|---|---| +| Submissions currently waiting on submitter info | `/api/v1/ai-review/reviewed/` or steward search | `state=more_info_needed` | +| Pending submissions resubmitted after more info was requested | `/api/v1/ai-review/` | `resubmitted_more_info=true` | +| Active AI proposals recommending more info | `/api/v1/ai-review/proposed/` | `proposed_action=more_info` | +| Reviewed submissions where final steward decision was more info | `/api/v1/ai-review/reviewed/` | `state=more_info_needed` | +| Exclude current more-info submissions | Any list endpoint where state is not fixed | `exclude_state=more_info_needed` | + +There is no direct query parameter for "ever had a more-info request but later +became accepted or rejected". For that, use `/api/v1/ai-review/reviewed/` and +inspect `internal_notes`. + +## Common Filter Examples -**Required fields by action:** +```bash +# New unproposed builder submissions with URL evidence. +curl -s -H "X-AI-Review-Key: $KEY" \ + "$BASE_URL/api/v1/ai-review/?category=builder&exclude_empty_evidence=true" -| Action | Required | -|--------|----------| -| `accept` | `proposed_points` (within contribution type min/max) | -| `reject` | `proposed_staff_reply` | -| `more_info` | `proposed_staff_reply` | +# Pending submissions edited after a more-info request. +curl -s -H "X-AI-Review-Key: $KEY" \ + "$BASE_URL/api/v1/ai-review/?resubmitted_more_info=true" -Optional: `reasoning` (internal CRM note), `confidence` (high/medium/low), `template_id`. +# Active AI more-info proposals. +curl -s -H "X-AI-Review-Key: $KEY" \ + "$BASE_URL/api/v1/ai-review/proposed/?proposed_action=more_info" -`confidence` is stored on the submission and displayed as a color-coded badge to stewards in the CRM notes panel. Set it accurately — stewards use it to prioritize which proposals to review first. +# Active low-confidence AI proposals. +curl -s -H "X-AI-Review-Key: $KEY" \ + "$BASE_URL/api/v1/ai-review/proposed/?proposed_confidence=low" -`template_id` is stored on the submission and stewards can filter by it. Always pass it when using a template so the data is traceable. +# Reviewed AI-proposed submissions with final more-info state. +curl -s -H "X-AI-Review-Key: $KEY" \ + "$BASE_URL/api/v1/ai-review/reviewed/?state=more_info_needed" -Errors: 400 = validation, 404 = not found/not pending/no proposal to update, 409 = already has proposal (POST only). +# Include appealed submissions in the new-work list. +curl -s -H "X-AI-Review-Key: $KEY" \ + "$BASE_URL/api/v1/ai-review/?has_appeal=true" +``` -### Step 5: Continue +## Endpoint Defaults And Conflicts -Page through remaining submissions with `?page=2&page_size=10`. +- `/api/v1/ai-review/` is always pending and unproposed. Do not use `has_proposal=true` there. +- `/api/v1/ai-review/proposed/` is always pending AI-created proposals. Use proposal filters there. +- `/api/v1/ai-review/reviewed/` is historical calibration data. Do not propose from it. +- Main `/api/v1/ai-review/` excludes appeals unless `has_appeal` is present. +- `state=accepted`, `state=rejected`, or `state=more_info_needed` does not make sense on main `/api/v1/ai-review/` because that endpoint starts from pending submissions. -### Calibration: Review past decisions +## Minimal Decision Guidance -```bash -curl -s -H "X-AI-Review-Key: $KEY" "$BASE_URL/api/v1/ai-review/reviewed/?page_size=10" -``` +- Propose `reject` for unverifiable, duplicate, spam, generic, irrelevant, or no-work submissions. +- Propose `more_info` when the work might be valid but evidence is missing, broken, private, or unclear. +- Propose `accept` only for concrete, verifiable work. Use detail `min_points` and `max_points`. +- Use `high` confidence for obvious decisions, `medium` for likely decisions, and `low` for ambiguous decisions. + +## Error Meanings -Returns submissions that were reviewed by stewards after having an AI proposal. Includes the final `state`, `staff_reply`, `mission`, appeal fields, `evidence_items`, and all `internal_notes` (with proposal and final review data). Use this to calibrate future proposals — check whether stewards agreed with your proposals or overrode them. - -The `reviewed/` endpoint accepts the same filter parameters listed above. - -## Evaluation Guidelines - -### Reject when: -- No evidence and no meaningful notes -- Evidence URLs are generic platform pages (points.genlayer.foundation, studio.genlayer.com) -- Notes are spam, gibberish, or very short -- Same URL already submitted by another user (copy-paste gaming) -- Tweets or social posts submitted as "Educational Content" / "Documentation" / "Research & Analysis" — these belong in community content via Discord -- AI-generated content with no specific technical detail -- Plans or intentions with no actual work done - -### Accept when: -- GitHub repos with real code, README, and ideally a deployed demo -- Deep technical content (tutorials with code, architecture analysis) -- Tools, dashboards, SDKs that help the ecosystem -- Original research with genuine insights -- Award 1-2 points for borderline, 3-5+ for substantial work - -### Request more info when: -- Notes describe a project but evidence is missing -- Evidence links are broken or unclear -- The contribution is plausible but needs verification - -### Confidence: -- **high**: Obvious spam, clearly valuable, or clear-cut violations -- **medium**: Likely correct but edge cases exist -- **low**: Ambiguous, needs human judgment - -## Important constraints - -- Never auto-accept. Acceptances always need careful human review. -- Users with many rejections and zero acceptances are likely spammers. -- Users with prior acceptances are more likely legitimate. -- Prefer `more_info` over `reject` when evidence is missing but notes are descriptive — unless spam history. -- `proposed_staff_reply` is shown to the user. Be helpful and specific. -- `reasoning` is internal only. Be candid about red flags. +| Status | Meaning | +|---:|---| +| 400 | Invalid body or invalid query value | +| 401/403 | Missing/invalid key, or forbidden proposal update | +| 404 | Submission not found, not pending, or no AI proposal exists for PUT | +| 409 | POST attempted when an active proposal already exists | +| 500 | Backend setup issue, often missing AI steward user | diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index aa12a17a..f8b8c5fb 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -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] diff --git a/backend/contributions/ai_review/views.py b/backend/contributions/ai_review/views.py index 0c809191..50984948 100644 --- a/backend/contributions/ai_review/views.py +++ b/backend/contributions/ai_review/views.py @@ -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') @@ -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( @@ -416,6 +467,12 @@ 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( @@ -423,6 +480,12 @@ def propose(self, request, pk=None): 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.'}, @@ -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, ) @@ -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', diff --git a/backend/contributions/tests/test_calibration_data.py b/backend/contributions/tests/test_calibration_data.py index 9cf059f9..4593983a 100644 --- a/backend/contributions/tests/test_calibration_data.py +++ b/backend/contributions/tests/test_calibration_data.py @@ -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): @@ -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'] diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 78f318fd..5ec21b93 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -58,6 +58,8 @@ 'community-link-discord', ] +AI_STEWARD_EMAIL = 'genlayer-steward@genlayer.foundation' + class ContributionTypeViewSet(viewsets.ReadOnlyModelViewSet): """ @@ -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') @@ -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: diff --git a/frontend/src/components/StewardSearchBar.svelte b/frontend/src/components/StewardSearchBar.svelte index 76ac4247..89ac67bd 100644 --- a/frontend/src/components/StewardSearchBar.svelte +++ b/frontend/src/components/StewardSearchBar.svelte @@ -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'] }, @@ -237,6 +238,7 @@
from:usernameSearch by user name/email/address
assigned:meFilter by assignment (me, unassigned, name)
reviewed:meFilter by steward who accepted/rejected it
+
proposed-by:aiOnly active proposals from the AI reviewer
exclude:medium.comExclude submissions containing text
include:genlayerOnly show submissions containing text
has:urlOnly submissions with URLs
@@ -256,11 +258,13 @@
Negation
-type:blog-postExclude contribution type
+
-proposed-by:aiExclude active AI proposals
-mission:nameExclude a mission while keeping other matches
Examples
assigned:me exclude:medium.com has:url
+
has:proposal proposed-by:ai confidence:low
status:accepted reviewed:alice
from:alice -type:referral min-contributions:2
type:bug-report -mission:wallet-login is:resubmitted
diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js index 2cd2f73d..ac6c09ea 100644 --- a/frontend/src/lib/searchParser.js +++ b/frontend/src/lib/searchParser.js @@ -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 @@ -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 = { @@ -122,6 +123,7 @@ export function parseSearch(query) { from: null, assigned: null, reviewed: null, + 'proposed-by': null, exclude: [], include: [], has: [], diff --git a/frontend/src/lib/searchToParams.js b/frontend/src/lib/searchToParams.js index cd1a4c04..ec7cc36d 100644 --- a/frontend/src/lib/searchToParams.js +++ b/frontend/src/lib/searchToParams.js @@ -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(','); diff --git a/frontend/src/routes/StewardDiscordXP.svelte b/frontend/src/routes/StewardDiscordXP.svelte index f5217c7c..06da7186 100644 --- a/frontend/src/routes/StewardDiscordXP.svelte +++ b/frontend/src/routes/StewardDiscordXP.svelte @@ -241,11 +241,8 @@ try { const response = await stewardAPI.markDiscordXPDistributed(row.contribution); - updateRow(response.data); + await applyServerRow(response.data); showSuccess('Marked as distributed'); - if (statusFilter !== 'all' && response.data.status !== statusFilter) { - await loadXP(); - } } catch (err) { showError(err.response?.data?.detail || 'Failed to mark distributed'); } finally { @@ -262,11 +259,8 @@ try { const response = await stewardAPI.unsetDiscordXPDistributed(row.contribution); - updateRow(response.data); + await applyServerRow(response.data); showSuccess('Distribution flag unset'); - if (statusFilter !== 'all' && response.data.status !== statusFilter) { - await loadXP(); - } } catch (err) { showError(err.response?.data?.detail || 'Failed to unset distribution flag'); } finally { @@ -279,6 +273,28 @@ rows = rows.map(row => row.contribution === nextRow.contribution ? nextRow : row); } + function rowMatchesActiveStatus(row) { + return statusFilter === 'all' || row.status === statusFilter; + } + + async function applyServerRow(nextRow) { + const existing = rows.find(row => row.contribution === nextRow.contribution); + if (!existing) return; + + if (rowMatchesActiveStatus(nextRow)) { + updateRow(nextRow); + return; + } + + rows = rows.filter(row => row.contribution !== nextRow.contribution); + totalCount = Math.max(0, totalCount - 1); + + if (rows.length === 0 && totalCount > 0) { + currentPage = Math.min(currentPage, Math.max(1, Math.ceil(totalCount / pageSize))); + await loadXP(); + } + } + function formatDate(value) { if (!value) return 'No date'; try { diff --git a/frontend/src/routes/StewardSubmissions.svelte b/frontend/src/routes/StewardSubmissions.svelte index 4ba17bef..ace09001 100644 --- a/frontend/src/routes/StewardSubmissions.svelte +++ b/frontend/src/routes/StewardSubmissions.svelte @@ -242,8 +242,9 @@ missions }); - // Add status from dropdown (overrides search query if present) - if (stateFilter) { + // The dropdown is the default status filter. Only a positive search + // status overrides it; negated statuses are additive exclusions. + if (stateFilter && (!parsed.filters.status || parsed.filters.status.negated)) { params.state = stateFilter; } @@ -700,7 +701,7 @@ {templates} {missions} onSearch={handleSearchChange} - placeholder="type:blog-post reviewed:me -mission:name has:appeal..." + placeholder="type:blog-post reviewed:me proposed-by:ai has:proposal..." />
diff --git a/frontend/src/tests/searchParser.test.js b/frontend/src/tests/searchParser.test.js index 40152ed9..91afce7a 100644 --- a/frontend/src/tests/searchParser.test.js +++ b/frontend/src/tests/searchParser.test.js @@ -46,4 +46,10 @@ describe("steward search negation", () => { include_content: "genlayer", }); }); + + it("maps proposal creator filters", () => { + expect(paramsFor("proposed-by:ai")).toEqual({ proposed_by: "ai" }); + expect(paramsFor("-proposed-by:ai")).toEqual({ exclude_proposed_by: "ai" }); + expect(paramsFor("proposed-by:none")).toEqual({ proposed_by: "none" }); + }); });