From 94c6187bb221c14931cc1f69394c648ca15a8cfc Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 16 Oct 2025 21:26:25 +0200 Subject: [PATCH] Add manual AI analysis trigger for existing feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement dashboard functionality to manually trigger AI analysis for feedback that was submitted before Phase 4 or failed analysis. Adds detection mechanism to identify which feedback has been analyzed. Features: - Manual analysis trigger route: POST /feedback/{id}/analyze - Detection of whether feedback has been analyzed (analysis.md exists) - Dashboard UI button showing "Analyze" or "Re-analyze" - Visual feedback for feedback without text content - Comprehensive error handling and flash messages Implementation: - app/routes/dashboard.py: Added trigger_analysis() route handler - app/routes/dashboard.py: Updated detail() to pass analysis status - app/services/feedback_storage.py: Added has_analysis() helper method - app/templates/dashboard/detail.html: Added analyze button UI - tests/contract/test_dashboard_routes.py: Added 3 new contract tests Testing: - test_post_trigger_analysis_success: Successful manual analysis - test_post_trigger_analysis_no_content: Reject empty content - test_post_trigger_analysis_unauthenticated: Auth required - All 49 tests passing (1 skipped) User Experience: - Green box with "Analyze" button for unanalyzed feedback - Blue box with "Re-analyze" button for already analyzed feedback - Red box with info message for feedback without text content - Flash messages show success/error after analysis 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/routes/dashboard.py | 94 +++++++++++++++++++- app/services/feedback_storage.py | 17 ++++ app/templates/dashboard/detail.html | 24 +++++ tests/contract/test_dashboard_routes.py | 112 ++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index d7c46dd..2492665 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,7 +1,8 @@ """Dashboard routes - product owner feedback management""" -from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, abort +from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, abort, current_app from flask_login import login_required, current_user from app.services.feedback_storage import FeedbackStorageService +from app.services.ai_analyzer import ClaudeAnalyzer from app.models.product import Product import os import mimetypes @@ -140,10 +141,18 @@ def detail(feedback_id): # Load product info product = Product.get_by_id(actual_product_id) + # Check if analysis exists + has_analysis = FeedbackStorageService.has_analysis(actual_product_id, feedback_id) + + # Check if feedback can be analyzed (has text content) + can_analyze = bool(feedback_data.get('content')) + return render_template( 'dashboard/detail.html', feedback=feedback_data, - product=product + product=product, + has_analysis=has_analysis, + can_analyze=can_analyze ) @@ -195,6 +204,87 @@ def update_status(feedback_id): return redirect(url_for('dashboard.detail', feedback_id=feedback_id)) +@bp.route('/feedback//analyze', methods=['POST']) +@login_required +def trigger_analysis(feedback_id): + """Manually trigger AI analysis for feedback + + Args: + feedback_id: Feedback ID to analyze + + Returns: + Redirect to detail page with flash message + """ + # Find feedback globally first + all_products = Product.get_all() + actual_product_id = None + feedback_data = None + + for product in all_products: + feedback_data = FeedbackStorageService.load_feedback_detail(product.product_id, feedback_id) + if feedback_data: + actual_product_id = product.product_id + break + + # If not found globally, return 404 + if not actual_product_id or not feedback_data: + abort(404) + + # Check if user has access to this product + if not check_product_access(actual_product_id): + abort(403) + + # Check if feedback has text content + if not feedback_data.get('content'): + flash('Cannot analyze feedback without text content', 'error') + return redirect(url_for('dashboard.detail', feedback_id=feedback_id)) + + # Get product info for owner language + product = Product.get_by_id(actual_product_id) + + try: + # Update status to "analyzing" + FeedbackStorageService.update_feedback_status_by_id( + actual_product_id, feedback_id, 'analyzing' + ) + + # Get API key from environment + api_key = os.getenv('ANTHROPIC_API_KEY') + + if not api_key: + raise Exception("ANTHROPIC_API_KEY not configured") + + # Initialize analyzer + analyzer = ClaudeAnalyzer(api_key=api_key) + + # Analyze feedback + result = analyzer.analyze_feedback( + feedback_text=feedback_data['content'], + target_language=product.owner_language, + product_id=actual_product_id + ) + + # Save analysis results + FeedbackStorageService.save_analysis(actual_product_id, feedback_id, result) + + # Update status to "analyzed" + FeedbackStorageService.update_feedback_status_by_id( + actual_product_id, feedback_id, 'analyzed' + ) + + flash('Analysis completed successfully', 'success') + + except Exception as e: + # Update status to "analysis_failed" on error + FeedbackStorageService.update_feedback_status_by_id( + actual_product_id, feedback_id, 'analysis_failed' + ) + current_app.logger.error(f"Manual analysis failed for feedback {feedback_id}: {e}") + flash(f'Analysis failed: {str(e)}', 'error') + + return redirect(url_for('dashboard.detail', feedback_id=feedback_id)) + + @bp.route('/feedback//attachment/') @login_required def download_attachment(feedback_id, filename): diff --git a/app/services/feedback_storage.py b/app/services/feedback_storage.py index ca84c26..2031a00 100644 --- a/app/services/feedback_storage.py +++ b/app/services/feedback_storage.py @@ -529,3 +529,20 @@ class FeedbackStorageService: """ # Use the raw analysis from Claude, which is already formatted return analysis_result.raw_analysis + + @staticmethod + def has_analysis(product_id, feedback_id): + """Check if feedback has been analyzed + + Args: + product_id: Product ID + feedback_id: Feedback ID + + Returns: + bool: True if analysis.md exists, False otherwise + """ + data_dir = current_app.config['DATA_DIR'] + analysis_file = os.path.join( + data_dir, 'products', product_id, 'feedback', feedback_id, 'analysis.md' + ) + return os.path.exists(analysis_file) diff --git a/app/templates/dashboard/detail.html b/app/templates/dashboard/detail.html index 02d372e..0ba63fb 100644 --- a/app/templates/dashboard/detail.html +++ b/app/templates/dashboard/detail.html @@ -67,6 +67,30 @@ + + {% if can_analyze %} +
+
+ + {% if has_analysis %} + 🔄 + + {% else %} + 🤖 + + {% endif %} + +
+
+ {% elif not feedback.content %} +
+ â„šī¸ + Cannot analyze: This feedback has no text content (attachments only). +
+ {% endif %} +

Original Feedback

diff --git a/tests/contract/test_dashboard_routes.py b/tests/contract/test_dashboard_routes.py index 9061c97..632d692 100644 --- a/tests/contract/test_dashboard_routes.py +++ b/tests/contract/test_dashboard_routes.py @@ -3,6 +3,7 @@ import pytest import os import yaml import io +from unittest.mock import Mock, patch from app.models.user import User @@ -322,3 +323,114 @@ def test_access_control_owner_products(client, app, test_users, test_product): # Should NOT have access to feedback from test-product response = client.get(f'/feedback/{feedback_id}') assert response.status_code == 403 # Forbidden + + +@pytest.mark.contract +def test_post_trigger_analysis_success(client, app, test_users, test_product): + """Contract test for POST /feedback/{id}/analyze - manual analysis trigger + + Expected: 302 redirect with success message, status updated to 'analyzed' + """ + # Login first + client.post('/login', data={'username': 'admin', 'password': 'admin123'}) + + feedback_id = test_product['feedback_id'] + + # Mock the Claude API response + mock_api_response = Mock() + mock_api_response.content = [Mock(text=""" +# Feedback Analysis + +**Category**: bug + +**Original Language**: en + +**Summary**: User reports a test feedback issue + +**Translation**: (same as original) +""")] + + # Patch AI analyzer + with patch('app.routes.dashboard.ClaudeAnalyzer') as mock_analyzer_class: + mock_analyzer = Mock() + mock_analyzer.analyze_feedback.return_value = Mock( + category='bug', + original_language='en', + summary='User reports a test feedback issue', + translation='(same as original)', + raw_analysis=mock_api_response.content[0].text + ) + mock_analyzer_class.return_value = mock_analyzer + + with patch('app.routes.dashboard.os.getenv', return_value='test-api-key'): + response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=False) + + # Should redirect + assert response.status_code == 302 + assert f'/feedback/{feedback_id}' in response.location + + # Verify analysis was saved + with app.app_context(): + data_dir = app.config['DATA_DIR'] + analysis_file = os.path.join( + data_dir, 'products', 'test-product', 'feedback', feedback_id, 'analysis.md' + ) + assert os.path.exists(analysis_file) + + +@pytest.mark.contract +def test_post_trigger_analysis_no_content(client, app, test_users, test_product): + """Contract test for POST /feedback/{id}/analyze - feedback with empty text content + + Expected: 302 redirect with error message + """ + # Create feedback with empty content.txt + with app.app_context(): + product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product') + feedback_id = 'test-feedback-empty-text' + feedback_path = os.path.join(product_dir, 'feedback', feedback_id) + os.makedirs(feedback_path, exist_ok=True) + + metadata = { + 'feedback_id': feedback_id, + 'product_id': 'test-product', + 'status': 'new', + 'submitted_at': '2025-10-16T10:00:00Z', + 'has_attachments': True, + 'attachment_count': 1 + } + + with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f: + yaml.dump(metadata, f) + + # Create empty content.txt + with open(os.path.join(feedback_path, 'content.txt'), 'w') as f: + f.write('') + + # Create attachments directory to show this has attachments only + attachments_dir = os.path.join(feedback_path, 'attachments') + os.makedirs(attachments_dir, exist_ok=True) + with open(os.path.join(attachments_dir, 'image.png'), 'w') as f: + f.write('fake image data') + + # Login and try to analyze + client.post('/login', data={'username': 'admin', 'password': 'admin123'}) + response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=True) + + # Should show error message + assert response.status_code == 200 + assert b'Cannot analyze' in response.data or b'no text content' in response.data + + +@pytest.mark.contract +def test_post_trigger_analysis_unauthenticated(client, app, test_product): + """Contract test for POST /feedback/{id}/analyze - unauthenticated access + + Expected: 302 redirect to login + """ + feedback_id = test_product['feedback_id'] + response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=False) + + # Should redirect to login + assert response.status_code == 302 + assert '/login' in response.location