Add manual AI analysis trigger for existing feedback
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 <noreply@anthropic.com>
This commit is contained in:
+92
-2
@@ -1,7 +1,8 @@
|
|||||||
"""Dashboard routes - product owner feedback management"""
|
"""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 flask_login import login_required, current_user
|
||||||
from app.services.feedback_storage import FeedbackStorageService
|
from app.services.feedback_storage import FeedbackStorageService
|
||||||
|
from app.services.ai_analyzer import ClaudeAnalyzer
|
||||||
from app.models.product import Product
|
from app.models.product import Product
|
||||||
import os
|
import os
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -140,10 +141,18 @@ def detail(feedback_id):
|
|||||||
# Load product info
|
# Load product info
|
||||||
product = Product.get_by_id(actual_product_id)
|
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(
|
return render_template(
|
||||||
'dashboard/detail.html',
|
'dashboard/detail.html',
|
||||||
feedback=feedback_data,
|
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))
|
return redirect(url_for('dashboard.detail', feedback_id=feedback_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/feedback/<feedback_id>/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/<feedback_id>/attachment/<filename>')
|
@bp.route('/feedback/<feedback_id>/attachment/<filename>')
|
||||||
@login_required
|
@login_required
|
||||||
def download_attachment(feedback_id, filename):
|
def download_attachment(feedback_id, filename):
|
||||||
|
|||||||
@@ -529,3 +529,20 @@ class FeedbackStorageService:
|
|||||||
"""
|
"""
|
||||||
# Use the raw analysis from Claude, which is already formatted
|
# Use the raw analysis from Claude, which is already formatted
|
||||||
return analysis_result.raw_analysis
|
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)
|
||||||
|
|||||||
@@ -67,6 +67,30 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Analysis Trigger -->
|
||||||
|
{% if can_analyze %}
|
||||||
|
<div style="margin: 20px 0; padding: 15px; {% if has_analysis %}background: #d1ecf1;{% else %}background: #d4edda;{% endif %} border-radius: 5px;">
|
||||||
|
<form method="post" action="{{ url_for('dashboard.trigger_analysis', feedback_id=feedback.feedback_id) }}" style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% if has_analysis %}
|
||||||
|
<span style="font-size: 1.2em;">🔄</span>
|
||||||
|
<label><strong>Re-run AI Analysis:</strong> This feedback has been analyzed. Click to re-analyze.</label>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size: 1.2em;">🤖</span>
|
||||||
|
<label><strong>Run AI Analysis:</strong> This feedback has not been analyzed yet.</label>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" style="padding: 5px 15px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">
|
||||||
|
{% if has_analysis %}Re-analyze{% else %}Analyze{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif not feedback.content %}
|
||||||
|
<div style="margin: 20px 0; padding: 15px; background: #f8d7da; border-radius: 5px; color: #721c24;">
|
||||||
|
<span style="font-size: 1.2em;">ℹ️</span>
|
||||||
|
<strong>Cannot analyze:</strong> This feedback has no text content (attachments only).
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Original Content -->
|
<!-- Original Content -->
|
||||||
<div style="margin: 30px 0;">
|
<div style="margin: 30px 0;">
|
||||||
<h2>Original Feedback</h2>
|
<h2>Original Feedback</h2>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pytest
|
|||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
import io
|
import io
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
from app.models.user import User
|
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
|
# Should NOT have access to feedback from test-product
|
||||||
response = client.get(f'/feedback/{feedback_id}')
|
response = client.get(f'/feedback/{feedback_id}')
|
||||||
assert response.status_code == 403 # Forbidden
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user