Implement automatic AI analysis of feedback submissions using Claude API, including language detection, categorization, summarization, and translation to product owner's preferred language. Tasks Completed (T065-T092): - T065-T069: Unit tests for AI analyzer (5 tests) - T070: Integration test for full AI analysis workflow - T071: Created AIAnalyzer abstract base class interface - T072: Added AnalysisResult dataclass to feedback model - T073: Implemented ClaudeAnalyzer with Anthropic SDK - T074: Integrated Claude API with 45s timeout - T075: Designed single-call analysis prompt - T076: Language detection implementation - T077: Category extraction with validation - T078: Summary generation (1-2 sentences) - T079: Translation extraction - T080: Timeout handling for Claude API - T081: API error handling with proper exceptions - T082: Analysis storage to analysis.md file - T083: Formatted markdown output for analysis - T084: Background analysis trigger on submission - T085: Non-blocking async analysis via threading - T086: Status update to 'analyzing' before analysis - T087: Status update to 'analyzed' on success - T088: Status update to 'analysis_failed' on error - T089: Metadata update with category and language - T090: Environment configuration for ANTHROPIC_API_KEY - T091: Verification that original content.txt preserved (FR-016) - T092: Verification that images not analyzed via OCR (FR-021) Features: - Abstract AIAnalyzer interface for multiple AI providers - ClaudeAnalyzer implementation using Anthropic API - Background threading for non-blocking analysis - Flask app context management in background threads - Comprehensive error handling and status tracking - Original content preservation (FR-016 compliance) - Image storage without OCR (FR-021 compliance) Testing: - 5 unit tests for AI analyzer components - 2 integration tests for full analysis workflow - All 46 tests passing (1 skipped) - Mock-based testing to avoid API calls Files Changed: - app/models/feedback.py: Added AnalysisResult dataclass - app/routes/submission.py: Background analysis integration - app/services/ai_analyzer.py: NEW - AI analysis service - app/services/feedback_storage.py: Analysis storage methods - tests/unit/test_ai_analyzer.py: NEW - Unit tests (5 tests) - tests/integration/test_ai_analysis_flow.py: NEW - Integration tests (2 tests) - tests/integration/test_feedback_submission_flow.py: Threading mock added 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
180 lines
5.9 KiB
Python
180 lines
5.9 KiB
Python
"""Submission routes - anonymous feedback submission"""
|
|
import threading
|
|
import os
|
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, current_app
|
|
from app.models.product import Product
|
|
from app.services.feedback_storage import FeedbackStorageService
|
|
from app.services.ai_analyzer import ClaudeAnalyzer
|
|
from app.utils.file_validator import validate_file, scan_file_for_viruses
|
|
|
|
|
|
bp = Blueprint('submission', __name__, url_prefix='/submit')
|
|
|
|
|
|
@bp.route('/<product_slug>', methods=['GET'])
|
|
def form(product_slug):
|
|
"""Display feedback submission form
|
|
|
|
Args:
|
|
product_slug: Product submission URL slug
|
|
|
|
Returns:
|
|
Rendered submission form template or 404
|
|
"""
|
|
# Load product by slug
|
|
product = Product.get_by_slug(product_slug)
|
|
|
|
if not product:
|
|
abort(404, description="Product not found")
|
|
|
|
# Check if product is archived
|
|
if product.is_archived():
|
|
abort(404, description="This product is no longer accepting feedback")
|
|
|
|
return render_template('submission/form.html', product=product)
|
|
|
|
|
|
@bp.route('/<product_slug>', methods=['POST'])
|
|
def submit(product_slug):
|
|
"""Process feedback submission
|
|
|
|
Args:
|
|
product_slug: Product submission URL slug
|
|
|
|
Returns:
|
|
Redirect to success page or error page
|
|
"""
|
|
# Load product by slug
|
|
product = Product.get_by_slug(product_slug)
|
|
|
|
if not product:
|
|
abort(404, description="Product not found")
|
|
|
|
# Check if product is archived
|
|
if product.is_archived():
|
|
abort(404, description="This product is no longer accepting feedback")
|
|
|
|
# Get form data
|
|
feedback_text = request.form.get('feedback_text', '').strip()
|
|
|
|
# Get uploaded files
|
|
uploaded_files = request.files.getlist('files')
|
|
# Filter out empty file inputs
|
|
files = [f for f in uploaded_files if f and f.filename != '']
|
|
|
|
# Validation: Must provide either text or files
|
|
if not feedback_text and not files:
|
|
abort(400, description="Please provide either feedback text or attachments")
|
|
|
|
# Validation: Maximum 3 files
|
|
if len(files) > 3:
|
|
abort(400, description="Maximum 3 attachments allowed")
|
|
|
|
# Validate each file
|
|
for file in files:
|
|
is_valid, error_message = validate_file(file)
|
|
if not is_valid:
|
|
abort(400, description=error_message)
|
|
|
|
# Scan for viruses
|
|
is_clean, virus_message = scan_file_for_viruses(file)
|
|
if not is_clean:
|
|
abort(400, description=f"File rejected: {virus_message}")
|
|
|
|
# Save feedback
|
|
try:
|
|
feedback = FeedbackStorageService.save_complete_feedback(
|
|
product_id=product.product_id,
|
|
content_text=feedback_text if feedback_text else None,
|
|
files=files if files else None
|
|
)
|
|
|
|
# Trigger background analysis (T084, T085)
|
|
if feedback_text: # Only analyze if there's text content
|
|
_trigger_background_analysis(feedback, feedback_text, product)
|
|
|
|
return render_template('submission/success.html',
|
|
product=product,
|
|
feedback_id=feedback.feedback_id)
|
|
|
|
except Exception as e:
|
|
# Log error
|
|
current_app.logger.error(f"Error saving feedback: {e}")
|
|
|
|
return render_template('submission/error.html',
|
|
product=product,
|
|
error_message="An error occurred while saving your feedback. Please try again."), 500
|
|
|
|
|
|
def _trigger_background_analysis(feedback, feedback_text, product):
|
|
"""Trigger background AI analysis task (T084)
|
|
|
|
Args:
|
|
feedback: Feedback instance
|
|
feedback_text: Feedback text content
|
|
product: Product instance
|
|
"""
|
|
# Get the current app instance to pass to background thread
|
|
app = current_app._get_current_object()
|
|
|
|
# Run analysis in background thread
|
|
thread = threading.Thread(
|
|
target=_analyze_feedback_background,
|
|
args=(app, feedback.product_id, feedback.feedback_id, feedback_text, product.owner_language)
|
|
)
|
|
thread.daemon = True
|
|
thread.start()
|
|
|
|
|
|
def _analyze_feedback_background(app, product_id, feedback_id, feedback_text, target_language):
|
|
"""Background task for AI analysis (T086-T088)
|
|
|
|
This runs in a separate thread to avoid blocking the submission response.
|
|
|
|
Args:
|
|
app: Flask app instance for application context
|
|
product_id: Product ID
|
|
feedback_id: Feedback ID
|
|
feedback_text: Feedback text to analyze
|
|
target_language: Target language for translation
|
|
"""
|
|
# Run within Flask application context
|
|
with app.app_context():
|
|
try:
|
|
# Update status to "analyzing" (T086)
|
|
FeedbackStorageService.update_feedback_status_by_id(
|
|
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_text,
|
|
target_language=target_language,
|
|
product_id=product_id
|
|
)
|
|
|
|
# Save analysis results
|
|
FeedbackStorageService.save_analysis(product_id, feedback_id, result)
|
|
|
|
# Update status to "analyzed" (T087)
|
|
FeedbackStorageService.update_feedback_status_by_id(
|
|
product_id, feedback_id, 'analyzed'
|
|
)
|
|
|
|
except Exception as e:
|
|
# Update status to "analysis_failed" on error (T088)
|
|
FeedbackStorageService.update_feedback_status_by_id(
|
|
product_id, feedback_id, 'analysis_failed'
|
|
)
|
|
# Log error
|
|
print(f"Analysis failed for feedback {feedback_id}: {e}")
|