diff --git a/Gemfile b/Gemfile index 198630a46..3e20158e2 100644 --- a/Gemfile +++ b/Gemfile @@ -140,3 +140,7 @@ gem "rqrcode", "~> 2.2" gem "puppeteer-ruby", "~> 0.45.6" +# Creator Legal Review Agent - AI integration +gem "pdf-reader", "~> 2.11" # For parsing PDF contracts +gem "docx", "~> 0.8" # For parsing DOCX contracts + diff --git a/app/controllers/admin/creator_legal_reviews_controller.rb b/app/controllers/admin/creator_legal_reviews_controller.rb new file mode 100644 index 000000000..28b9a0be5 --- /dev/null +++ b/app/controllers/admin/creator_legal_reviews_controller.rb @@ -0,0 +1,173 @@ +class Admin::CreatorLegalReviewsController < Admin::BaseController + before_action :load_review, except: [:index, :new, :create, :analytics] + + def index + @reviews = CreatorLegalReview.order(created_at: :desc) + @reviews = filter_reviews(@reviews) + @reviews = @reviews.page(params[:page]).per(20) if @reviews.respond_to?(:page) + end + + def show + end + + def new + @review = CreatorLegalReview.new + end + + def create + @review = CreatorLegalReview.new(review_params) + + if params[:document].present? + @review.original_document.attach(params[:document]) + @review.document_text = extract_document_text(params[:document]) + @review.document_metadata = extract_document_metadata(params[:document]) + end + + if @review.save + flash.notice = "Legal review created successfully. Ready for analysis." + redirect_to admin_creator_legal_review_path(@review) + else + flash.alert = "Failed to create review: #{@review.errors.full_messages.join(', ')}" + render :new + end + end + + def edit + end + + def update + if @review.update(review_params) + flash.notice = "Legal review updated successfully." + redirect_to admin_creator_legal_review_path(@review) + else + flash.alert = "Failed to update review: #{@review.errors.full_messages.join(', ')}" + render :edit + end + end + + def destroy + @review.destroy + flash.notice = "Legal review deleted." + redirect_to admin_creator_legal_reviews_path + end + + # Custom actions + def analyze + if @review.document_text.blank? + flash.alert = "Cannot analyze: no document text available." + redirect_to admin_creator_legal_review_path(@review) + return + end + + AnalyzeCreatorLegalReviewJob.perform_later(@review.id) + flash.notice = "Analysis started. This may take a few minutes." + redirect_to admin_creator_legal_review_path(@review) + end + + def approve + @review.mark_as_completed! + flash.notice = "Review approved and marked as completed." + redirect_to admin_creator_legal_review_path(@review) + end + + def escalate + reason = params[:reason] || "Escalated by admin for human review" + @review.mark_as_escalated!(reason) + flash.notice = "Review escalated for human review." + redirect_to admin_creator_legal_review_path(@review) + end + + def add_notes + if @review.update(reviewer_notes: params[:notes], reviewed_by_user_id: current_user.id, reviewed_at: Time.current) + flash.notice = "Notes added successfully." + else + flash.alert = "Failed to add notes." + end + redirect_to admin_creator_legal_review_path(@review) + end + + def analytics + @total_reviews = CreatorLegalReview.count + @reviews_by_domain = CreatorLegalReview.group(:domain_type).count + @reviews_by_status = CreatorLegalReview.group(:status).count + @average_risk_by_domain = CreatorLegalReview.average_risk_by_domain + @escalation_rate = CreatorLegalReview.escalation_rate + @quality_breakdown = CreatorLegalReview.quality_breakdown + @recent_reviews = CreatorLegalReview.order(created_at: :desc).limit(10) + end + + private + + def load_review + @review = CreatorLegalReview.friendly.find(params[:id]) + rescue ActiveRecord::RecordNotFound + flash.alert = "Review not found." + redirect_to admin_creator_legal_reviews_path + end + + def review_params + params.require(:creator_legal_review).permit( + :title, + :domain_type, + :creator_email, + :creator_name, + :document_text, + :reviewer_notes, + creator_context: {} + ) + end + + def filter_reviews(reviews) + reviews = reviews.by_domain(params[:domain]) if params[:domain].present? + reviews = reviews.where(status: params[:status]) if params[:status].present? + reviews = reviews.needs_review if params[:needs_review] == 'true' + reviews = reviews.high_risk if params[:high_risk] == 'true' + reviews + end + + def extract_document_text(document) + return nil unless document.present? + + content_type = document.content_type + tempfile = document.tempfile + + case content_type + when 'application/pdf' + extract_pdf_text(tempfile) + when 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + extract_docx_text(tempfile) + when 'text/plain' + File.read(tempfile) + else + nil + end + rescue => e + Rails.logger.error "Document extraction failed: #{e.message}" + nil + end + + def extract_pdf_text(tempfile) + reader = PDF::Reader.new(tempfile.path) + reader.pages.map(&:text).join("\n\n") + rescue => e + Rails.logger.error "PDF extraction failed: #{e.message}" + nil + end + + def extract_docx_text(tempfile) + doc = Docx::Document.open(tempfile.path) + doc.paragraphs.map(&:to_s).join("\n\n") + rescue => e + Rails.logger.error "DOCX extraction failed: #{e.message}" + nil + end + + def extract_document_metadata(document) + { + original_filename: document.original_filename, + content_type: document.content_type, + size: document.size, + uploaded_at: Time.current.iso8601 + } + end +end diff --git a/app/jobs/analyze_creator_legal_review_job.rb b/app/jobs/analyze_creator_legal_review_job.rb new file mode 100644 index 000000000..93f1657d7 --- /dev/null +++ b/app/jobs/analyze_creator_legal_review_job.rb @@ -0,0 +1,20 @@ +class AnalyzeCreatorLegalReviewJob < ApplicationJob + queue_as :default + + def perform(review_id) + review = CreatorLegalReview.find(review_id) + service = CreatorLegalReviewService.new(review) + result = service.analyze! + + if result[:success] + Rails.logger.info "CreatorLegalReview #{review_id} analyzed successfully" + else + Rails.logger.error "CreatorLegalReview #{review_id} analysis failed: #{result[:error]}" + end + rescue ActiveRecord::RecordNotFound + Rails.logger.error "CreatorLegalReview #{review_id} not found" + rescue => e + Rails.logger.error "CreatorLegalReview #{review_id} job failed: #{e.message}" + raise # Re-raise for job retry mechanism + end +end diff --git a/app/models/creator_legal_review.rb b/app/models/creator_legal_review.rb new file mode 100644 index 000000000..d7ac06e2d --- /dev/null +++ b/app/models/creator_legal_review.rb @@ -0,0 +1,216 @@ +class CreatorLegalReview < ApplicationRecord + extend FriendlyId + friendly_id :slug_generator, use: :slugged + + # Validations + validates :title, presence: true + validates :domain_type, presence: true, inclusion: { in: %w[brand_deal mcn_negotiation business_formation merchandise team_hire] } + validates :status, inclusion: { in: %w[pending analyzing reviewed escalated completed] } + validates :recommended_action, inclusion: { in: %w[sign_as_is negotiate escalate_to_lawyer walk_away] }, allow_nil: true + validates :overall_risk_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true + validates :composite_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true + validates_format_of :creator_email, with: Devise.email_regexp, allow_blank: true + + # Attachments for document uploads + has_one_attached :original_document + + # Scopes + scope :pending, -> { where(status: 'pending') } + scope :analyzing, -> { where(status: 'analyzing') } + scope :reviewed, -> { where(status: 'reviewed') } + scope :escalated, -> { where(status: 'escalated') } + scope :completed, -> { where(status: 'completed') } + scope :needs_review, -> { where(needs_human_review: true) } + scope :high_risk, -> { where('overall_risk_score >= ?', 7.0) } + scope :by_domain, ->(domain) { where(domain_type: domain) } + + # Domain type constants and configurations + DOMAIN_TYPES = { + brand_deal: { + name: 'Brand Deal', + description: 'Contract review for brand sponsorship and influencer marketing deals', + legal_complexity: 'medium', + key_touchpoints: %w[term_extraction risk_identification industry_benchmarking negotiation_language], + escalation_triggers: %w[novel_terms deal_value_above_50k cross_border litigation_risk] + }, + mcn_negotiation: { + name: 'MCN Negotiation', + description: 'Multi-Channel Network partnership agreement analysis', + legal_complexity: 'high', + key_touchpoints: %w[revenue_split_analysis service_guarantees exit_clause_analysis term_benchmarking], + escalation_triggers: %w[breach_implications existing_mcn_exit] + }, + business_formation: { + name: 'Business Formation', + description: 'Entity selection and formation guidance for creators', + legal_complexity: 'low_medium', + key_touchpoints: %w[entity_recommendation state_selection formation_checklist operating_agreement], + escalation_triggers: %w[multi_member_llc s_corp_election complex_ownership existing_liability] + }, + merchandise: { + name: 'Merchandise', + description: 'Product launch legal protection and compliance review', + legal_complexity: 'medium_high', + key_touchpoints: %w[trademark_search pod_vs_inventory manufacturer_agreement label_compliance], + escalation_triggers: %w[trademark_filing international_trademark product_liability_claims licensing_deals] + }, + team_hire: { + name: 'Team Hire', + description: 'Worker classification and employment agreement review', + legal_complexity: 'medium', + key_touchpoints: %w[classification_guidance contractor_agreements ip_assignment nda_templates], + escalation_triggers: %w[employee_handbook equity_profit_sharing worker_disputes state_specific_law termination] + } + }.freeze + + # Risk level configurations + RISK_LEVELS = { + green: { range: (0..3), label: 'Low Risk', emoji: '🟢', action: 'Proceed with confidence' }, + yellow: { range: (3..5), label: 'Moderate Risk', emoji: '🟡', action: 'Review recommended terms' }, + orange: { range: (5..7), label: 'Medium-High Risk', emoji: '🟠', action: 'Negotiate key terms' }, + red: { range: (7..10), label: 'High Risk', emoji: '🔴', action: 'Escalate or walk away' } + }.freeze + + # Evaluation dimensions (from spec) + EVALUATION_DIMENSIONS = { + legal_accuracy: { weight: 0.30, description: 'Terms correctly interpreted, risks properly identified' }, + business_practicality: { weight: 0.25, description: 'Advice is actionable, considers relationship dynamics' }, + clarity: { weight: 0.20, description: 'Creator can understand and act on output' }, + completeness: { weight: 0.15, description: 'All material terms addressed, nothing critical missed' }, + calibration: { weight: 0.10, description: 'Risk scores match actual risk level' } + }.freeze + + # Callbacks + before_validation :set_defaults, on: :create + after_save :notify_if_needs_review, if: -> { saved_change_to_needs_human_review? && needs_human_review? } + + # Instance methods + def domain_config + DOMAIN_TYPES[domain_type.to_sym] + end + + def risk_level + return nil unless overall_risk_score + RISK_LEVELS.find { |_, config| config[:range].include?(overall_risk_score) }&.last + end + + def risk_emoji + risk_level&.dig(:emoji) || '⚪' + end + + def risk_label + risk_level&.dig(:label) || 'Not assessed' + end + + def mark_as_analyzing! + update!(status: 'analyzing') + end + + def mark_as_reviewed!(reviewer_id: nil, notes: nil) + update!( + status: 'reviewed', + reviewed_by_user_id: reviewer_id, + reviewed_at: Time.current, + reviewer_notes: notes + ) + end + + def mark_as_escalated!(reason) + update!( + status: 'escalated', + needs_human_review: true, + escalation_reason: reason + ) + end + + def mark_as_completed! + update!(status: 'completed') + end + + def should_escalate? + return true if overall_risk_score && overall_risk_score >= 8.0 + return true if needs_human_review? + + triggers = domain_config&.dig(:escalation_triggers) || [] + analysis_triggers = analysis_result.dig('escalation_triggers') || [] + (triggers & analysis_triggers).any? + end + + def calculate_composite_score + return nil unless evaluation_scores.present? + + total = EVALUATION_DIMENSIONS.sum do |dimension, config| + score = evaluation_scores[dimension.to_s].to_f + score * config[:weight] + end + + update!(composite_score: total.round(2)) + total.round(2) + end + + def production_ready? + composite_score.present? && composite_score >= 8.0 + end + + def acceptable_with_review? + composite_score.present? && composite_score >= 7.0 && composite_score < 8.0 + end + + def needs_improvement? + composite_score.present? && composite_score >= 6.0 && composite_score < 7.0 + end + + def not_acceptable? + composite_score.present? && composite_score < 6.0 + end + + def quality_status + return 'Not evaluated' unless composite_score + return 'Production ready' if production_ready? + return 'Acceptable with human review' if acceptable_with_review? + return 'Needs improvement' if needs_improvement? + 'Not acceptable' + end + + # Class methods for analytics + def self.average_risk_by_domain + group(:domain_type).average(:overall_risk_score) + end + + def self.escalation_rate + return 0 if count.zero? + (escalated.count.to_f / count * 100).round(2) + end + + def self.quality_breakdown + { + production_ready: where('composite_score >= ?', 8.0).count, + acceptable_with_review: where('composite_score >= ? AND composite_score < ?', 7.0, 8.0).count, + needs_improvement: where('composite_score >= ? AND composite_score < ?', 6.0, 7.0).count, + not_acceptable: where('composite_score < ?', 6.0).count + } + end + + private + + def slug_generator + SecureRandom.hex(10) + end + + def set_defaults + self.status ||= 'pending' + self.creator_context ||= {} + self.analysis_result ||= {} + self.extracted_terms ||= {} + self.risk_scores ||= {} + self.negotiation_points ||= [] + self.evaluation_scores ||= {} + self.ai_conversation_log ||= [] + end + + def notify_if_needs_review + # Placeholder for notification logic + # In production, this would send email/slack notification to legal review team + Rails.logger.info "CreatorLegalReview #{id} requires human review: #{escalation_reason}" + end +end diff --git a/app/services/creator_legal_review_service.rb b/app/services/creator_legal_review_service.rb new file mode 100644 index 000000000..75cb47c80 --- /dev/null +++ b/app/services/creator_legal_review_service.rb @@ -0,0 +1,588 @@ +class CreatorLegalReviewService + attr_reader :review, :conversation_log + + # Error classes + class AnalysisError < StandardError; end + class ConfigurationError < StandardError; end + + def initialize(review) + @review = review + @conversation_log = [] + @start_time = Time.current + end + + def analyze! + validate_configuration! + review.mark_as_analyzing! + + begin + # Step 1: Initial Analysis + initial_analysis = perform_initial_analysis + log_conversation('initial_analysis', initial_analysis) + + # Step 2: Mid-Loop Evaluation (expert personas) + evaluation_result = perform_mid_loop_evaluation(initial_analysis) + log_conversation('mid_loop_evaluation', evaluation_result) + + # Step 3: Refinement if needed + if needs_refinement?(evaluation_result) + refined_analysis = perform_refinement(initial_analysis, evaluation_result) + log_conversation('refinement', refined_analysis) + initial_analysis = refined_analysis + end + + # Step 4: Generate Final Output + final_output = generate_final_output(initial_analysis) + log_conversation('final_output', final_output) + + # Step 5: End-Loop Evaluation (scoring) + scores = perform_end_loop_evaluation(final_output) + log_conversation('end_loop_evaluation', scores) + + # Step 6: Save Results + save_results!(final_output, scores) + + # Step 7: Check for escalation + check_and_escalate! if review.should_escalate? + + { success: true, review: review.reload } + rescue => e + handle_error(e) + end + end + + private + + def validate_configuration! + raise ConfigurationError, "ANTHROPIC_API_KEY not configured" unless anthropic_api_key.present? + raise ConfigurationError, "Review document text is required" unless review.document_text.present? + end + + def anthropic_api_key + ENV['ANTHROPIC_API_KEY'] + end + + def perform_initial_analysis + prompt = build_initial_analysis_prompt + response = call_claude(prompt, system: initial_analysis_system_prompt) + parse_structured_response(response) + end + + def perform_mid_loop_evaluation(analysis) + evaluations = {} + + # Legal Expert Evaluation + legal_prompt = build_legal_expert_prompt(analysis) + evaluations[:legal_expert] = call_claude(legal_prompt, system: legal_expert_system_prompt) + + # Business Expert Evaluation + business_prompt = build_business_expert_prompt(analysis) + evaluations[:business_expert] = call_claude(business_prompt, system: business_expert_system_prompt) + + parse_evaluation_response(evaluations) + end + + def needs_refinement?(evaluation_result) + avg_score = evaluation_result.values.map { |v| v[:score].to_f }.sum / evaluation_result.size + avg_score < 4.0 # On scale of 1-5, refine if below 4 + end + + def perform_refinement(initial_analysis, evaluation_result) + prompt = build_refinement_prompt(initial_analysis, evaluation_result) + response = call_claude(prompt, system: initial_analysis_system_prompt) + parse_structured_response(response) + end + + def generate_final_output(analysis) + prompt = build_final_output_prompt(analysis) + response = call_claude(prompt, system: final_output_system_prompt) + parse_final_output(response) + end + + def perform_end_loop_evaluation(final_output) + prompt = build_end_loop_evaluation_prompt(final_output) + response = call_claude(prompt, system: evaluator_system_prompt) + parse_scores(response) + end + + def save_results!(final_output, scores) + processing_time = ((Time.current - @start_time) * 1000).to_i + + review.update!( + status: 'reviewed', + analysis_result: final_output[:full_analysis], + extracted_terms: final_output[:extracted_terms], + risk_scores: final_output[:risk_scores], + overall_risk_score: final_output[:overall_risk_score], + plain_english_summary: final_output[:summary], + negotiation_points: final_output[:negotiation_points], + recommended_action: final_output[:recommended_action], + evaluation_scores: scores, + ai_conversation_log: @conversation_log, + ai_model_used: 'claude-3-opus-20240229', + processing_time_ms: processing_time + ) + + review.calculate_composite_score + end + + def check_and_escalate! + reason = determine_escalation_reason + review.mark_as_escalated!(reason) + end + + def determine_escalation_reason + reasons = [] + reasons << "High overall risk score: #{review.overall_risk_score}" if review.overall_risk_score >= 8.0 + reasons << "Low composite quality score: #{review.composite_score}" if review.composite_score && review.composite_score < 7.0 + reasons << "Complex terms requiring human review" if review.analysis_result['escalation_triggers']&.any? + reasons.join("; ") + end + + def handle_error(error) + log_conversation('error', { message: error.message, backtrace: error.backtrace.first(5) }) + + review.update!( + status: 'pending', + ai_conversation_log: @conversation_log, + needs_human_review: true, + escalation_reason: "Analysis failed: #{error.message}" + ) + + { success: false, error: error.message } + end + + def log_conversation(step, data) + @conversation_log << { + step: step, + timestamp: Time.current.iso8601, + data: data + } + end + + def call_claude(prompt, system: nil) + # Using HTTParty for API calls (already in Gemfile) + response = HTTParty.post( + 'https://api.anthropic.com/v1/messages', + headers: { + 'Content-Type' => 'application/json', + 'x-api-key' => anthropic_api_key, + 'anthropic-version' => '2023-06-01' + }, + body: { + model: 'claude-3-opus-20240229', + max_tokens: 4096, + system: system, + messages: [{ role: 'user', content: prompt }] + }.to_json, + timeout: 120 + ) + + if response.success? + response.parsed_response['content'].first['text'] + else + raise AnalysisError, "Claude API error: #{response.code} - #{response.message}" + end + end + + # System prompts for different analysis stages + def initial_analysis_system_prompt + domain_config = review.domain_config + <<~PROMPT + You are a legal analysis AI specialized in #{domain_config[:name]} contracts for content creators. + Your task is to analyze documents and extract structured information. + + Legal Complexity Level: #{domain_config[:legal_complexity]} + Key Analysis Areas: #{domain_config[:key_touchpoints].join(', ')} + + Output your analysis in valid JSON format with the following structure: + { + "parties": {}, + "material_terms": {}, + "risk_areas": [], + "key_findings": [], + "escalation_triggers": [] + } + + Be thorough but practical. Focus on what matters most to creators. + PROMPT + end + + def legal_expert_system_prompt + <<~PROMPT + You are a contract law expert evaluating AI-generated legal analysis for accuracy. + + Evaluate on these criteria (score 1-5 each): + 1. Term Extraction Accuracy: Were all material terms correctly identified? + 2. Legal Interpretation: Is the plain-English explanation legally accurate? + 3. Risk Assessment: Is the risk score calibrated correctly? + 4. Completeness: Were any important clauses missed? + + Output JSON: {"term_extraction": X, "legal_interpretation": X, "risk_assessment": X, "completeness": X, "feedback": "...", "score": X} + PROMPT + end + + def business_expert_system_prompt + <<~PROMPT + You are a creator economy business expert evaluating legal analysis for practical relevance. + + Evaluate on these criteria (score 1-5 each): + 1. Practical Relevance: Does advice account for creator's business reality? + 2. Negotiability Assessment: Are suggestions actually achievable? + 3. Opportunity Cost: Does analysis consider deal value vs. risk? + 4. Career Impact: Are long-term implications considered? + + Output JSON: {"practical_relevance": X, "negotiability": X, "opportunity_cost": X, "career_impact": X, "feedback": "...", "score": X} + PROMPT + end + + def final_output_system_prompt + domain_templates[review.domain_type.to_sym] || default_output_template + end + + def evaluator_system_prompt + <<~PROMPT + You are evaluating the final output of a legal review AI system. + + Score each dimension from 0-10: + - legal_accuracy (30% weight): Terms correctly interpreted, risks properly identified + - business_practicality (25% weight): Advice is actionable, considers relationship dynamics + - clarity (20% weight): Creator can understand and act on output + - completeness (15% weight): All material terms addressed, nothing critical missed + - calibration (10% weight): Risk scores match actual risk level + + Output JSON: {"legal_accuracy": X, "business_practicality": X, "clarity": X, "completeness": X, "calibration": X} + PROMPT + end + + # Domain-specific templates + def domain_templates + { + brand_deal: brand_deal_template, + mcn_negotiation: mcn_negotiation_template, + business_formation: business_formation_template, + merchandise: merchandise_template, + team_hire: team_hire_template + } + end + + def brand_deal_template + <<~PROMPT + You are generating the final output for a Brand Deal contract review. + + Structure your response as JSON with: + { + "extracted_terms": { + "compensation": {"base_fee": "", "payment_timing": "", "kill_fee": "", "risk_score": ""}, + "deliverables": {"content_type": "", "quantity": "", "platforms": [], "revision_rounds": "", "risk_score": ""}, + "ip_and_usage": {"ownership": "", "license_duration": "", "license_scope": "", "modification_rights": "", "risk_score": ""}, + "exclusivity": {"category": "", "duration": "", "geography": "", "risk_score": ""}, + "termination": {"creator_can_exit": "", "brand_can_exit": "", "morals_clause": "", "cure_period": "", "risk_score": ""} + }, + "risk_scores": { + "compensation": 0-10, + "deliverables": 0-10, + "ip_and_usage": 0-10, + "exclusivity": 0-10, + "termination": 0-10 + }, + "overall_risk_score": 0-10, + "summary": "Plain English summary...", + "negotiation_points": ["point1", "point2"], + "recommended_action": "sign_as_is|negotiate|escalate_to_lawyer|walk_away", + "full_analysis": {}, + "escalation_triggers": [] + } + + Risk Score Guide: + - 0-3 (Green): Low risk, creator-friendly terms + - 3-5 (Yellow): Moderate risk, standard terms + - 5-7 (Orange): Medium-high risk, should negotiate + - 7-10 (Red): High risk, predatory terms + PROMPT + end + + def mcn_negotiation_template + <<~PROMPT + You are generating the final output for an MCN Partnership Agreement review. + + Structure your response as JSON with: + { + "extracted_terms": { + "network_info": {"name": "", "reputation_notes": ""}, + "revenue_structure": {"adsense_split": "", "effective_creator_take": "", "sponsorship_handling": "", "risk_score": ""}, + "term_and_exit": {"initial_term": "", "auto_renewal": "", "exit_conditions": [], "penalty_for_exit": "", "risk_score": ""}, + "services_promised": {"guaranteed_in_contract": [], "mentioned_not_guaranteed": [], "risk_score": ""}, + "channel_ownership": {"who_owns_channel": "", "content_rights": "", "post_termination": "", "risk_score": ""} + }, + "risk_scores": {"revenue": 0-10, "term": 0-10, "services": 0-10, "ownership": 0-10}, + "overall_risk_score": 0-10, + "revenue_calculation": {"gross_example": 10000, "youtube_cut": 4500, "mcn_cut": 0, "creator_receives": 0, "effective_percentage": 0}, + "summary": "Plain English summary...", + "negotiation_points": [], + "recommended_action": "sign_as_is|negotiate|escalate_to_lawyer|walk_away", + "full_analysis": {}, + "escalation_triggers": [] + } + PROMPT + end + + def business_formation_template + <<~PROMPT + You are generating the final output for a Business Formation consultation. + + Structure your response as JSON with: + { + "creator_profile": {"annual_revenue": "", "state": "", "number_of_owners": "", "employees": "", "physical_products": ""}, + "recommendation": { + "entity_type": "sole_prop|single_member_llc|multi_member_llc|s_corp", + "formation_state": "", + "reasoning": "", + "estimated_cost": "" + }, + "decision_factors": { + "liability_protection": "", + "tax_treatment": "", + "complexity": "" + }, + "s_corp_analysis": {"recommended": true/false, "threshold_income": "", "reasoning": ""}, + "next_steps": {"immediate": [], "within_30_days": [], "ongoing_compliance": []}, + "risk_scores": {"liability": 0-10, "tax": 0-10, "compliance": 0-10}, + "overall_risk_score": 0-10, + "summary": "", + "recommended_action": "proceed|consult_accountant|consult_lawyer", + "full_analysis": {}, + "escalation_triggers": [] + } + PROMPT + end + + def merchandise_template + <<~PROMPT + You are generating the final output for a Merchandise Launch legal review. + + Structure your response as JSON with: + { + "product_info": {"type": "", "production_model": ""}, + "trademark_status": {"brand_name_search": "", "logo_search": "", "filing_recommended": "", "classes_to_file": []}, + "manufacturer_agreement": {"key_terms": {}, "risk_score": ""}, + "product_liability": {"risk_level": "", "insurance_recommended": "", "coverage_type": "", "estimated_cost": ""}, + "compliance_requirements": {"labeling": [], "special_requirements": []}, + "risk_scores": {"trademark": 0-10, "liability": 0-10, "compliance": 0-10, "manufacturing": 0-10}, + "overall_risk_score": 0-10, + "summary": "", + "negotiation_points": [], + "recommended_action": "proceed|trademark_first|insurance_first|escalate_to_lawyer", + "full_analysis": {}, + "escalation_triggers": [] + } + PROMPT + end + + def team_hire_template + <<~PROMPT + You are generating the final output for a Team Hire / Worker Classification review. + + Structure your response as JSON with: + { + "worker_info": {"role": "", "work_description": "", "hours_per_week": "", "duration": ""}, + "classification_factors": { + "behavioral_control": {"creator_controls_how": "", "set_schedule": "", "tools_required": ""}, + "financial_control": {"payment_method": "", "other_clients": "", "own_equipment": "", "profit_loss_opportunity": ""}, + "relationship_type": {"written_contract": "", "benefits": "", "ongoing": "", "key_to_business": ""} + }, + "classification_result": {"recommended": "independent_contractor|employee", "confidence": "", "misclassification_risk": ""}, + "agreement_essentials": {"scope_of_work": "", "compensation": "", "ip_assignment": "", "confidentiality": "", "termination": ""}, + "risk_scores": {"classification": 0-10, "ip_protection": 0-10, "compliance": 0-10}, + "overall_risk_score": 0-10, + "next_steps": [], + "summary": "", + "recommended_action": "proceed_as_contractor|proceed_as_employee|escalate_to_lawyer", + "full_analysis": {}, + "escalation_triggers": [] + } + PROMPT + end + + def default_output_template + <<~PROMPT + Generate a structured legal review output as JSON with: + { + "extracted_terms": {}, + "risk_scores": {}, + "overall_risk_score": 0-10, + "summary": "", + "negotiation_points": [], + "recommended_action": "", + "full_analysis": {}, + "escalation_triggers": [] + } + PROMPT + end + + # Prompt builders + def build_initial_analysis_prompt + <<~PROMPT + Analyze the following #{review.domain_config[:name]} document for a content creator. + + Creator Context: + #{review.creator_context.to_json} + + Document Text: + --- + #{review.document_text} + --- + + Provide a thorough analysis identifying: + 1. All parties involved + 2. Material terms and conditions + 3. Risk areas and red flags + 4. Key findings + 5. Any terms that should trigger escalation to a human lawyer + PROMPT + end + + def build_legal_expert_prompt(analysis) + <<~PROMPT + Evaluate this legal analysis for accuracy and completeness: + + #{analysis.to_json} + + Original document excerpt (first 2000 chars): + #{review.document_text[0..2000]} + + Provide your evaluation scores and feedback. + PROMPT + end + + def build_business_expert_prompt(analysis) + <<~PROMPT + Evaluate this legal analysis for business practicality: + + #{analysis.to_json} + + Creator Context: + #{review.creator_context.to_json} + + Is this advice practical for a creator in this situation? Would a savvy creator manager give similar advice? + PROMPT + end + + def build_refinement_prompt(initial_analysis, evaluation) + <<~PROMPT + Your initial analysis received the following feedback: + + Legal Expert: #{evaluation[:legal_expert].to_json} + Business Expert: #{evaluation[:business_expert].to_json} + + Original Analysis: + #{initial_analysis.to_json} + + Please provide a refined analysis addressing the feedback while maintaining accuracy. + PROMPT + end + + def build_final_output_prompt(analysis) + <<~PROMPT + Based on this analysis, generate the final structured output: + + #{analysis.to_json} + + Creator Context: + #{review.creator_context.to_json} + + Ensure the output is practical, clear, and actionable for the creator. + PROMPT + end + + def build_end_loop_evaluation_prompt(final_output) + <<~PROMPT + Evaluate this final legal review output: + + #{final_output.to_json} + + Original Document (excerpt): + #{review.document_text[0..2000]} + + Score each dimension from 0-10 based on the evaluation criteria. + PROMPT + end + + # Response parsers + def parse_structured_response(response) + # Try to extract JSON from response + json_match = response.match(/\{[\s\S]*\}/) + return {} unless json_match + + JSON.parse(json_match[0]) + rescue JSON::ParserError + { raw_response: response } + end + + def parse_evaluation_response(evaluations) + result = {} + evaluations.each do |expert, response| + parsed = parse_structured_response(response) + result[expert] = parsed.is_a?(Hash) ? parsed.symbolize_keys : { raw: response, score: 3 } + end + result + end + + def parse_final_output(response) + parsed = parse_structured_response(response) + return { error: 'Failed to parse output' } unless parsed.is_a?(Hash) + + { + extracted_terms: parsed['extracted_terms'] || {}, + risk_scores: parsed['risk_scores'] || {}, + overall_risk_score: parsed['overall_risk_score'].to_f, + summary: parsed['summary'] || '', + negotiation_points: parsed['negotiation_points'] || [], + recommended_action: normalize_action(parsed['recommended_action']), + full_analysis: parsed, + escalation_triggers: parsed['escalation_triggers'] || [] + } + end + + def parse_scores(response) + parsed = parse_structured_response(response) + return default_scores unless parsed.is_a?(Hash) + + { + 'legal_accuracy' => parsed['legal_accuracy'].to_f, + 'business_practicality' => parsed['business_practicality'].to_f, + 'clarity' => parsed['clarity'].to_f, + 'completeness' => parsed['completeness'].to_f, + 'calibration' => parsed['calibration'].to_f + } + end + + def default_scores + { + 'legal_accuracy' => 5.0, + 'business_practicality' => 5.0, + 'clarity' => 5.0, + 'completeness' => 5.0, + 'calibration' => 5.0 + } + end + + def normalize_action(action) + valid_actions = %w[sign_as_is negotiate escalate_to_lawyer walk_away] + return action if valid_actions.include?(action) + + # Try to map common variations + case action&.downcase + when /sign/, /proceed/, /approve/ + 'sign_as_is' + when /negotiat/ + 'negotiate' + when /escalat/, /lawyer/, /attorney/, /consult/ + 'escalate_to_lawyer' + when /walk/, /decline/, /reject/ + 'walk_away' + else + 'negotiate' # Default to cautious action + end + end +end diff --git a/app/views/admin/creator_legal_reviews/analytics.html.haml b/app/views/admin/creator_legal_reviews/analytics.html.haml new file mode 100644 index 000000000..6671da47e --- /dev/null +++ b/app/views/admin/creator_legal_reviews/analytics.html.haml @@ -0,0 +1,159 @@ +.container-fluid + .row.mb-4 + .col-12 + = link_to '← Back to Reviews'.html_safe, admin_creator_legal_reviews_path, class: 'btn btn-outline-secondary mb-3' + %h1 Legal Review Analytics Dashboard + + / Key Metrics + .row.mb-4 + .col-md-3 + .card.text-white.bg-primary + .card-body + %h2.card-title= @total_reviews + %p.card-text Total Reviews + .col-md-3 + .card.text-white.bg-warning + .card-body + %h2.card-title= "#{@escalation_rate}%" + %p.card-text Escalation Rate + .col-md-3 + .card.text-white.bg-success + .card-body + %h2.card-title= @quality_breakdown[:production_ready] + %p.card-text Production Ready + .col-md-3 + .card.text-white.bg-danger + .card-body + %h2.card-title= @quality_breakdown[:not_acceptable] + %p.card-text Not Acceptable + + .row.mb-4 + / Reviews by Domain + .col-md-6 + .card + .card-header + %h5 Reviews by Domain + .card-body + - if @reviews_by_domain.any? + %table.table + %thead + %tr + %th Domain + %th Count + %th Avg Risk + %tbody + - @reviews_by_domain.each do |domain, count| + %tr + %td= CreatorLegalReview::DOMAIN_TYPES[domain.to_sym]&.dig(:name) || domain.humanize + %td= count + %td= @average_risk_by_domain[domain] ? "#{@average_risk_by_domain[domain].round(1)}/10" : 'N/A' + - else + %p.text-muted No data yet + + / Reviews by Status + .col-md-6 + .card + .card-header + %h5 Reviews by Status + .card-body + - if @reviews_by_status.any? + %table.table + %thead + %tr + %th Status + %th Count + %th Percentage + %tbody + - @reviews_by_status.each do |status, count| + %tr + %td + - status_class = case status + - when 'pending' then 'badge-secondary' + - when 'analyzing' then 'badge-info' + - when 'reviewed' then 'badge-success' + - when 'escalated' then 'badge-warning' + - when 'completed' then 'badge-primary' + - else 'badge-light' + %span.badge{ class: status_class }= status.humanize + %td= count + %td= "#{(@total_reviews > 0 ? (count.to_f / @total_reviews * 100).round(1) : 0)}%" + - else + %p.text-muted No data yet + + .row.mb-4 + / Quality Breakdown + .col-md-6 + .card + .card-header + %h5 Quality Score Distribution + .card-body + %table.table + %thead + %tr + %th Quality Level + %th Count + %th Threshold + %tbody + %tr.table-success + %td Production Ready + %td= @quality_breakdown[:production_ready] + %td >= 8.0 + %tr.table-warning + %td Acceptable with Review + %td= @quality_breakdown[:acceptable_with_review] + %td 7.0 - 7.9 + %tr.table-info + %td Needs Improvement + %td= @quality_breakdown[:needs_improvement] + %td 6.0 - 6.9 + %tr.table-danger + %td Not Acceptable + %td= @quality_breakdown[:not_acceptable] + %td < 6.0 + + / Risk Distribution + .col-md-6 + .card + .card-header + %h5 Risk Level Guide + .card-body + %table.table + %thead + %tr + %th Level + %th Range + %th Recommended Action + %tbody + - CreatorLegalReview::RISK_LEVELS.each do |_, config| + %tr + %td= "#{config[:emoji]} #{config[:label]}" + %td= "#{config[:range].min} - #{config[:range].max}" + %td= config[:action] + + .row + .col-12 + .card + .card-header + %h5 Recent Reviews + .card-body + - if @recent_reviews.any? + %table.table.table-sm + %thead + %tr + %th Title + %th Domain + %th Status + %th Risk + %th Quality + %th Created + %tbody + - @recent_reviews.each do |review| + %tr + %td= link_to review.title, admin_creator_legal_review_path(review) + %td= review.domain_config[:name] + %td= review.status.humanize + %td= review.overall_risk_score ? "#{review.risk_emoji} #{review.overall_risk_score}" : '-' + %td= review.composite_score || '-' + %td= review.created_at.strftime('%Y-%m-%d') + - else + %p.text-muted No reviews yet diff --git a/app/views/admin/creator_legal_reviews/edit.html.haml b/app/views/admin/creator_legal_reviews/edit.html.haml new file mode 100644 index 000000000..755502a3d --- /dev/null +++ b/app/views/admin/creator_legal_reviews/edit.html.haml @@ -0,0 +1,70 @@ +.container-fluid + .row.mb-4 + .col-12 + = link_to '← Back to Review'.html_safe, admin_creator_legal_review_path(@review), class: 'btn btn-outline-secondary mb-3' + %h1 Edit Legal Review + + .row + .col-md-8 + .card + .card-header + %h5 Edit Review: #{@review.title} + .card-body + = form_for @review, url: admin_creator_legal_review_path(@review), method: :patch, html: { class: 'needs-validation' } do |f| + .form-group + = f.label :title, 'Review Title *' + = f.text_field :title, class: 'form-control', required: true + + .form-group + = f.label :domain_type, 'Domain Type *' + = f.select :domain_type, CreatorLegalReview::DOMAIN_TYPES.map { |k, v| [v[:name], k] }, {}, class: 'form-control', required: true + + %hr + + %h6 Creator Information + .row + .col-md-6 + .form-group + = f.label :creator_name, 'Creator Name' + = f.text_field :creator_name, class: 'form-control' + .col-md-6 + .form-group + = f.label :creator_email, 'Creator Email' + = f.email_field :creator_email, class: 'form-control' + + %hr + + %h6 Document Text + .form-group + = f.label :document_text, 'Document Text' + = f.text_area :document_text, class: 'form-control', rows: 15 + %small.form-text.text-muted + Edit the extracted document text if needed before re-analysis + + %hr + + %h6 Reviewer Notes + .form-group + = f.label :reviewer_notes, 'Notes' + = f.text_area :reviewer_notes, class: 'form-control', rows: 5 + + %hr + + .form-group + = f.submit 'Update Review', class: 'btn btn-primary btn-lg' + = link_to 'Cancel', admin_creator_legal_review_path(@review), class: 'btn btn-outline-secondary btn-lg ml-2' + + .col-md-4 + .card.mb-4 + .card-header + %h5 Current Status + .card-body + %dl + %dt Status + %dd= @review.status.humanize + %dt Domain + %dd= @review.domain_config[:name] + %dt Risk Score + %dd= @review.overall_risk_score ? "#{@review.overall_risk_score}/10" : 'Not assessed' + %dt Created + %dd= @review.created_at.strftime('%Y-%m-%d %H:%M') diff --git a/app/views/admin/creator_legal_reviews/index.html.haml b/app/views/admin/creator_legal_reviews/index.html.haml new file mode 100644 index 000000000..155b34461 --- /dev/null +++ b/app/views/admin/creator_legal_reviews/index.html.haml @@ -0,0 +1,90 @@ +.container-fluid + .row.mb-4 + .col-12 + .d-flex.justify-content-between.align-items-center + %h1 Creator Legal Reviews + = link_to 'New Review', new_admin_creator_legal_review_path, class: 'btn btn-primary' + + .row.mb-4 + .col-12 + .card + .card-header + %h5 Filters + .card-body + = form_tag admin_creator_legal_reviews_path, method: :get, class: 'd-flex flex-wrap gap-3' do + .form-group + = label_tag :domain, 'Domain Type' + = select_tag :domain, options_for_select([['All', '']] + CreatorLegalReview::DOMAIN_TYPES.map { |k, v| [v[:name], k] }, params[:domain]), class: 'form-control' + .form-group + = label_tag :status, 'Status' + = select_tag :status, options_for_select([['All', ''], ['Pending', 'pending'], ['Analyzing', 'analyzing'], ['Reviewed', 'reviewed'], ['Escalated', 'escalated'], ['Completed', 'completed']], params[:status]), class: 'form-control' + .form-group.d-flex.align-items-end + .form-check.mr-3 + = check_box_tag :needs_review, 'true', params[:needs_review] == 'true', class: 'form-check-input' + = label_tag :needs_review, 'Needs Human Review', class: 'form-check-label' + .form-group.d-flex.align-items-end + .form-check.mr-3 + = check_box_tag :high_risk, 'true', params[:high_risk] == 'true', class: 'form-check-input' + = label_tag :high_risk, 'High Risk Only', class: 'form-check-label' + .form-group.d-flex.align-items-end + = submit_tag 'Filter', class: 'btn btn-secondary' + = link_to 'Clear', admin_creator_legal_reviews_path, class: 'btn btn-outline-secondary ml-2' + + .row.mb-4 + .col-12 + = link_to 'View Analytics Dashboard', analytics_admin_creator_legal_reviews_path, class: 'btn btn-info' + + .row + .col-12 + - if @reviews.any? + .table-responsive + %table.table.table-hover + %thead.thead-light + %tr + %th Title + %th Domain + %th Creator + %th Status + %th Risk + %th Quality + %th Created + %th Actions + %tbody + - @reviews.each do |review| + %tr{ class: review.needs_human_review? ? 'table-warning' : '' } + %td + = link_to review.title, admin_creator_legal_review_path(review) + %td + %span.badge.badge-secondary= review.domain_config[:name] + %td + = review.creator_name || review.creator_email || 'N/A' + %td + - status_class = case review.status + - when 'pending' then 'badge-secondary' + - when 'analyzing' then 'badge-info' + - when 'reviewed' then 'badge-success' + - when 'escalated' then 'badge-warning' + - when 'completed' then 'badge-primary' + - else 'badge-light' + %span.badge{ class: status_class }= review.status.humanize + %td + - if review.overall_risk_score + %span{ title: review.risk_label }= "#{review.risk_emoji} #{review.overall_risk_score}/10" + - else + %span.text-muted Not assessed + %td + - if review.composite_score + = "#{review.composite_score}/10" + %small.text-muted= "(#{review.quality_status})" + - else + %span.text-muted N/A + %td + = review.created_at.strftime('%Y-%m-%d %H:%M') + %td + .btn-group.btn-group-sm + = link_to 'View', admin_creator_legal_review_path(review), class: 'btn btn-outline-primary' + - if review.status == 'pending' && review.document_text.present? + = link_to 'Analyze', analyze_admin_creator_legal_review_path(review), method: :post, class: 'btn btn-outline-success' + - else + .alert.alert-info + No reviews found. Create your first legal review to get started. diff --git a/app/views/admin/creator_legal_reviews/new.html.haml b/app/views/admin/creator_legal_reviews/new.html.haml new file mode 100644 index 000000000..1b8c90148 --- /dev/null +++ b/app/views/admin/creator_legal_reviews/new.html.haml @@ -0,0 +1,85 @@ +.container-fluid + .row.mb-4 + .col-12 + = link_to '← Back to Reviews'.html_safe, admin_creator_legal_reviews_path, class: 'btn btn-outline-secondary mb-3' + %h1 New Legal Review + + .row + .col-md-8 + .card + .card-header + %h5 Create New Legal Review + .card-body + = form_for @review, url: admin_creator_legal_reviews_path, html: { multipart: true, class: 'needs-validation' } do |f| + .form-group + = f.label :title, 'Review Title *' + = f.text_field :title, class: 'form-control', required: true, placeholder: 'e.g., Nike Brand Deal Q1 2024' + + .form-group + = f.label :domain_type, 'Domain Type *' + = f.select :domain_type, CreatorLegalReview::DOMAIN_TYPES.map { |k, v| [v[:name], k] }, { prompt: 'Select domain type...' }, class: 'form-control', required: true + + %hr + + %h6 Creator Information + .row + .col-md-6 + .form-group + = f.label :creator_name, 'Creator Name' + = f.text_field :creator_name, class: 'form-control', placeholder: 'Full name' + .col-md-6 + .form-group + = f.label :creator_email, 'Creator Email' + = f.email_field :creator_email, class: 'form-control', placeholder: 'email@example.com' + + .form-group + %label Creator Context (JSON) + %textarea.form-control{ name: 'creator_legal_review[creator_context]', rows: 4, placeholder: '{"follower_count": 250000, "platform": "Instagram", "niche": "Fitness", "previous_deals": 5}' } + %small.form-text.text-muted + Optional: Provide additional context about the creator as JSON + + %hr + + %h6 Document + .form-group + %label Upload Contract Document + %input.form-control-file{ type: 'file', name: 'document', accept: '.pdf,.docx,.txt' } + %small.form-text.text-muted + Accepted formats: PDF, DOCX, TXT + + .form-group + = f.label :document_text, 'Or Paste Document Text' + = f.text_area :document_text, class: 'form-control', rows: 10, placeholder: 'Paste the contract text here if you cannot upload a file...' + %small.form-text.text-muted + You can either upload a document or paste the text directly + + %hr + + .form-group + = f.submit 'Create Review', class: 'btn btn-primary btn-lg' + = link_to 'Cancel', admin_creator_legal_reviews_path, class: 'btn btn-outline-secondary btn-lg ml-2' + + .col-md-4 + .card.mb-4 + .card-header + %h5 Domain Types Guide + .card-body + %dl + - CreatorLegalReview::DOMAIN_TYPES.each do |key, config| + %dt + %strong= config[:name] + %dd + = config[:description] + %br + %small.text-muted + Complexity: #{config[:legal_complexity].humanize} + + .card + .card-header + %h5 Tips + .card-body + %ul.mb-0 + %li Upload the original contract document for best results + %li Include creator context for more personalized analysis + %li The AI will extract terms, identify risks, and suggest negotiation points + %li High-risk reviews will be flagged for human review diff --git a/app/views/admin/creator_legal_reviews/show.html.haml b/app/views/admin/creator_legal_reviews/show.html.haml new file mode 100644 index 000000000..8725685e5 --- /dev/null +++ b/app/views/admin/creator_legal_reviews/show.html.haml @@ -0,0 +1,246 @@ +.container-fluid + .row.mb-4 + .col-12 + = link_to '← Back to Reviews'.html_safe, admin_creator_legal_reviews_path, class: 'btn btn-outline-secondary mb-3' + + .d-flex.justify-content-between.align-items-center + %h1= @review.title + .btn-group + - if @review.status == 'pending' && @review.document_text.present? + = link_to 'Start Analysis', analyze_admin_creator_legal_review_path(@review), method: :post, class: 'btn btn-success', data: { confirm: 'Start AI analysis? This may take a few minutes.' } + - if @review.status == 'reviewed' + = link_to 'Approve', approve_admin_creator_legal_review_path(@review), method: :post, class: 'btn btn-primary', data: { confirm: 'Approve this review?' } + - unless @review.status == 'escalated' + = link_to 'Escalate', escalate_admin_creator_legal_review_path(@review), method: :post, class: 'btn btn-warning', data: { confirm: 'Escalate for human review?' } + = link_to 'Edit', edit_admin_creator_legal_review_path(@review), class: 'btn btn-outline-primary' + = link_to 'Delete', admin_creator_legal_review_path(@review), method: :delete, class: 'btn btn-outline-danger', data: { confirm: 'Are you sure?' } + + .row + .col-md-8 + / Status and Risk Overview + .card.mb-4 + .card-header + %h5 Review Status + .card-body + .row + .col-md-3 + %h6 Status + - status_class = case @review.status + - when 'pending' then 'badge-secondary' + - when 'analyzing' then 'badge-info' + - when 'reviewed' then 'badge-success' + - when 'escalated' then 'badge-warning' + - when 'completed' then 'badge-primary' + - else 'badge-light' + %span.badge.badge-lg{ class: status_class }= @review.status.humanize + .col-md-3 + %h6 Domain + %span.badge.badge-secondary= @review.domain_config[:name] + .col-md-3 + %h6 Overall Risk + - if @review.overall_risk_score + %span{ style: 'font-size: 1.5em' }= @review.risk_emoji + %span= "#{@review.overall_risk_score}/10" + %br + %small.text-muted= @review.risk_label + - else + %span.text-muted Not assessed + .col-md-3 + %h6 Quality Score + - if @review.composite_score + %span.h4= "#{@review.composite_score}/10" + %br + %small{ class: @review.production_ready? ? 'text-success' : (@review.acceptable_with_review? ? 'text-warning' : 'text-danger') } + = @review.quality_status + - else + %span.text-muted Not evaluated + + / Recommended Action + - if @review.recommended_action.present? + .card.mb-4 + .card-header + %h5 Recommended Action + .card-body + - action_config = { 'sign_as_is' => ['success', 'Proceed with signing'], 'negotiate' => ['warning', 'Negotiate key terms before signing'], 'escalate_to_lawyer' => ['danger', 'Consult with a human lawyer'], 'walk_away' => ['dark', 'Consider declining this deal'] } + - config = action_config[@review.recommended_action] || ['secondary', @review.recommended_action.humanize] + .alert{ class: "alert-#{config[0]}" } + %strong= config[1] + + / Plain English Summary + - if @review.plain_english_summary.present? + .card.mb-4 + .card-header + %h5 Plain English Summary + .card-body + = simple_format(@review.plain_english_summary) + + / Risk Scores by Category + - if @review.risk_scores.present? && @review.risk_scores.any? + .card.mb-4 + .card-header + %h5 Risk Scores by Category + .card-body + .table-responsive + %table.table + %thead + %tr + %th Category + %th Score + %th Risk Level + %tbody + - @review.risk_scores.each do |category, score| + %tr + %td= category.to_s.humanize + %td + .progress{ style: 'height: 20px; width: 200px;' } + - bar_class = score.to_f <= 3 ? 'bg-success' : (score.to_f <= 5 ? 'bg-warning' : (score.to_f <= 7 ? 'bg-orange' : 'bg-danger')) + .progress-bar{ class: bar_class, style: "width: #{score.to_f * 10}%", role: 'progressbar' } + = "#{score}/10" + %td + - level = CreatorLegalReview::RISK_LEVELS.find { |_, c| c[:range].include?(score.to_f) }&.last + = level ? "#{level[:emoji]} #{level[:label]}" : 'N/A' + + / Negotiation Points + - if @review.negotiation_points.present? && @review.negotiation_points.any? + .card.mb-4 + .card-header + %h5 Suggested Negotiation Points + .card-body + %ul.list-group + - @review.negotiation_points.each do |point| + %li.list-group-item= point + + / Extracted Terms + - if @review.extracted_terms.present? && @review.extracted_terms.any? + .card.mb-4 + .card-header + %h5 Extracted Terms + .card-body + %pre.bg-light.p-3{ style: 'max-height: 400px; overflow-y: auto;' } + = JSON.pretty_generate(@review.extracted_terms) + + / Full Analysis + - if @review.analysis_result.present? && @review.analysis_result.any? + .card.mb-4 + .card-header.d-flex.justify-content-between.align-items-center + %h5.mb-0 Full AI Analysis + %button.btn.btn-sm.btn-outline-secondary{ type: 'button', data: { toggle: 'collapse', target: '#fullAnalysis' } } + Toggle Details + .card-body.collapse#fullAnalysis + %pre.bg-light.p-3{ style: 'max-height: 600px; overflow-y: auto;' } + = JSON.pretty_generate(@review.analysis_result) + + / Document Text + - if @review.document_text.present? + .card.mb-4 + .card-header.d-flex.justify-content-between.align-items-center + %h5.mb-0 Original Document Text + %button.btn.btn-sm.btn-outline-secondary{ type: 'button', data: { toggle: 'collapse', target: '#documentText' } } + Toggle Document + .card-body.collapse#documentText + %pre.bg-light.p-3{ style: 'max-height: 600px; overflow-y: auto; white-space: pre-wrap;' } + = @review.document_text + + .col-md-4 + / Creator Info + .card.mb-4 + .card-header + %h5 Creator Information + .card-body + %dl + %dt Name + %dd= @review.creator_name || 'N/A' + %dt Email + %dd= @review.creator_email || 'N/A' + - if @review.creator_context.present? && @review.creator_context.any? + - @review.creator_context.each do |key, value| + %dt= key.to_s.humanize + %dd= value + + / Evaluation Scores + - if @review.evaluation_scores.present? && @review.evaluation_scores.any? + .card.mb-4 + .card-header + %h5 Evaluation Scores + .card-body + - CreatorLegalReview::EVALUATION_DIMENSIONS.each do |dimension, config| + - score = @review.evaluation_scores[dimension.to_s] + - next unless score + .mb-3 + .d-flex.justify-content-between + %strong= dimension.to_s.humanize + %span= "#{score}/10 (#{(config[:weight] * 100).to_i}% weight)" + .progress{ style: 'height: 10px;' } + .progress-bar{ style: "width: #{score.to_f * 10}%", role: 'progressbar' } + %small.text-muted= config[:description] + + / Document Info + .card.mb-4 + .card-header + %h5 Document Information + .card-body + - if @review.original_document.attached? + %p + %strong Original File: + = @review.document_metadata['original_filename'] + %p + %strong Type: + = @review.document_metadata['content_type'] + %p + %strong Size: + = number_to_human_size(@review.document_metadata['size']) + = link_to 'Download Original', rails_blob_path(@review.original_document, disposition: 'attachment'), class: 'btn btn-sm btn-outline-primary' + - else + %p.text-muted No document attached + + / Review Notes + .card.mb-4 + .card-header + %h5 Reviewer Notes + .card-body + - if @review.reviewer_notes.present? + = simple_format(@review.reviewer_notes) + %hr + %small.text-muted + Reviewed by User ##{@review.reviewed_by_user_id} on #{@review.reviewed_at&.strftime('%Y-%m-%d %H:%M')} + - else + %p.text-muted No notes yet + %hr + = form_tag add_notes_admin_creator_legal_review_path(@review), method: :post do + .form-group + = text_area_tag :notes, '', class: 'form-control', rows: 3, placeholder: 'Add reviewer notes...' + = submit_tag 'Add Notes', class: 'btn btn-sm btn-primary' + + / Escalation Info + - if @review.escalation_reason.present? + .card.mb-4.border-warning + .card-header.bg-warning + %h5.mb-0 Escalation Reason + .card-body + = @review.escalation_reason + + / AI Processing Info + .card.mb-4 + .card-header + %h5 Processing Details + .card-body + %dl + %dt Model Used + %dd= @review.ai_model_used || 'N/A' + %dt Processing Time + %dd= @review.processing_time_ms ? "#{@review.processing_time_ms}ms" : 'N/A' + %dt Created + %dd= @review.created_at.strftime('%Y-%m-%d %H:%M:%S') + %dt Updated + %dd= @review.updated_at.strftime('%Y-%m-%d %H:%M:%S') + + / AI Conversation Log + - if @review.ai_conversation_log.present? && @review.ai_conversation_log.any? + .card.mb-4 + .card-header.d-flex.justify-content-between.align-items-center + %h5.mb-0 AI Conversation Log + %button.btn.btn-sm.btn-outline-secondary{ type: 'button', data: { toggle: 'collapse', target: '#conversationLog' } } + Toggle Log + .card-body.collapse#conversationLog + %pre.bg-light.p-3{ style: 'max-height: 400px; overflow-y: auto; font-size: 0.75em;' } + = JSON.pretty_generate(@review.ai_conversation_log) diff --git a/config/routes.rb b/config/routes.rb index ec83d3406..e86d4b907 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -139,6 +139,19 @@ def self.matches?(request) end end resources :subdomains + + # Creator Legal Review Agent - AI-powered contract analysis for creators + resources :creator_legal_reviews do + collection do + get 'analytics' + end + member do + post 'analyze' + post 'approve' + post 'escalate' + post 'add_notes' + end + end end diff --git a/db/migrate/20241214200000_create_creator_legal_reviews.rb b/db/migrate/20241214200000_create_creator_legal_reviews.rb new file mode 100644 index 000000000..618f5b42d --- /dev/null +++ b/db/migrate/20241214200000_create_creator_legal_reviews.rb @@ -0,0 +1,59 @@ +class CreateCreatorLegalReviews < ActiveRecord::Migration[6.1] + def change + create_table :creator_legal_reviews do |t| + # Core identification + t.string :slug, null: false + t.string :title, null: false + t.string :status, default: 'pending' # pending, analyzing, reviewed, escalated, completed + + # Domain categorization (5 domains from spec) + t.string :domain_type, null: false # brand_deal, mcn_negotiation, business_formation, merchandise, team_hire + + # Creator context + t.string :creator_email + t.string :creator_name + t.jsonb :creator_context, default: {} # follower_count, platform, niche, previous_deals, etc. + + # Input documents + t.text :document_text # Extracted text from uploaded contract/document + t.jsonb :document_metadata, default: {} # original filename, upload date, etc. + + # AI Analysis Results + t.jsonb :analysis_result, default: {} # Full structured analysis + t.jsonb :extracted_terms, default: {} # Material terms extracted + t.jsonb :risk_scores, default: {} # Risk scores per category + t.decimal :overall_risk_score, precision: 3, scale: 1 # 0.0 - 10.0 + t.text :plain_english_summary + t.jsonb :negotiation_points, default: [] # Suggested negotiation talking points + t.string :recommended_action # sign_as_is, negotiate, escalate_to_lawyer, walk_away + + # Evaluation scores (multi-dimensional scorecard) + t.jsonb :evaluation_scores, default: {} # legal_accuracy, business_practicality, clarity, completeness, calibration + t.decimal :composite_score, precision: 3, scale: 2 + + # Human review + t.boolean :needs_human_review, default: false + t.text :escalation_reason + t.bigint :reviewed_by_user_id + t.datetime :reviewed_at + t.text :reviewer_notes + + # Audit trail + t.jsonb :ai_conversation_log, default: [] # Full conversation with AI for transparency + t.string :ai_model_used + t.integer :processing_time_ms + + t.datetime :deleted_at + t.timestamps + end + + add_index :creator_legal_reviews, :slug, unique: true + add_index :creator_legal_reviews, :status + add_index :creator_legal_reviews, :domain_type + add_index :creator_legal_reviews, :creator_email + add_index :creator_legal_reviews, :recommended_action + add_index :creator_legal_reviews, :needs_human_review + add_index :creator_legal_reviews, :deleted_at + add_index :creator_legal_reviews, :overall_risk_score + end +end diff --git a/docs/creator_legal_review_agent.md b/docs/creator_legal_review_agent.md new file mode 100644 index 000000000..7b82bf3ab --- /dev/null +++ b/docs/creator_legal_review_agent.md @@ -0,0 +1,382 @@ +# Creator Legal Review Agent + +## Overview + +The Creator Legal Review Agent is an AI-powered legal analysis system designed to help content creators navigate complex legal documents across five key domains: + +1. **Brand Deals** - Sponsorship and influencer marketing contracts +2. **MCN Negotiations** - Multi-Channel Network partnership agreements +3. **Business Formation** - Entity selection and formation guidance +4. **Merchandise** - Product launch legal protection and compliance +5. **Team Hires** - Worker classification and employment agreements + +## Architecture + +### Evaluation-First Architecture + +The system implements an evaluation-first approach with multiple feedback loops: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ USER INPUT │ +│ (Contract PDF, question, context) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ INITIAL ANALYSIS │ +│ (Primary LLM Pass) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MID-LOOP EVALUATION │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Legal │ │ Business │ │ Industry │ │ +│ │ Expert │ │ Expert │ │ Expert │ │ +│ │ (LLM) │ │ (LLM) │ │ (LLM) │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ └────────────────┼────────────────┘ │ +│ │ │ +│ Refinement Needed? ───► Loop back if yes │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ FINAL OUTPUT │ +│ (Analysis, recommendations, risk scores) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ END-LOOP EVALUATION │ +│ (Multidimensional Scorecard) │ +│ │ +│ Legal Accuracy (30%) │ Business Practicality (25%) │ +│ Clarity (20%) │ Completeness (15%) │ +│ Calibration (10%) │ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### System Components + +#### 1. CreatorLegalReview Model (`app/models/creator_legal_review.rb`) + +The core data model that stores: +- Review metadata (title, domain, status) +- Creator context (follower count, platform, niche) +- Document text and metadata +- AI analysis results +- Risk scores and evaluation scores +- Audit trail (conversation log, processing time) + +#### 2. CreatorLegalReviewService (`app/services/creator_legal_review_service.rb`) + +The AI analysis service that: +- Performs initial analysis using Claude API +- Runs mid-loop evaluation with expert personas +- Generates domain-specific outputs +- Calculates composite quality scores +- Determines escalation requirements + +#### 3. Admin Controller (`app/controllers/admin/creator_legal_reviews_controller.rb`) + +Handles the web interface for: +- Creating new reviews +- Uploading and parsing documents (PDF, DOCX, TXT) +- Triggering AI analysis +- Managing review workflow (approve, escalate, add notes) +- Viewing analytics + +#### 4. Background Job (`app/jobs/analyze_creator_legal_review_job.rb`) + +Processes AI analysis asynchronously to prevent request timeouts. + +## Setup + +### Prerequisites + +1. Ruby 2.6.6+ (Rails 6.1) +2. PostgreSQL database +3. Redis (for Sidekiq background jobs) +4. Anthropic API key for Claude + +### Installation + +1. Add the required gems (already added in Gemfile): +```ruby +gem "pdf-reader", "~> 2.11" # For parsing PDF contracts +gem "docx", "~> 0.8" # For parsing DOCX contracts +``` + +2. Run migrations: +```bash +rails db:migrate +``` + +3. Set environment variables: +```bash +export ANTHROPIC_API_KEY=your_api_key_here +``` + +4. Start Sidekiq for background processing: +```bash +bundle exec sidekiq +``` + +## Usage + +### Creating a Review + +1. Navigate to `/admin/creator_legal_reviews` +2. Click "New Review" +3. Fill in: + - **Title**: Descriptive name for the review + - **Domain Type**: Select from the 5 domains + - **Creator Information**: Name, email, context (JSON) + - **Document**: Upload file or paste text + +### Running Analysis + +1. From the review detail page, click "Start Analysis" +2. Analysis runs in the background (typically 1-3 minutes) +3. Refresh to see results including: + - Plain English summary + - Risk scores by category + - Overall risk assessment + - Recommended action + - Negotiation talking points + +### Review Workflow + +| Status | Description | Actions Available | +|--------|-------------|-------------------| +| Pending | Review created, awaiting analysis | Start Analysis, Edit, Delete | +| Analyzing | AI analysis in progress | Wait | +| Reviewed | Analysis complete | Approve, Escalate, Add Notes | +| Escalated | Flagged for human lawyer review | Add Notes | +| Completed | Review finalized | View Only | + +### Risk Levels + +| Level | Score Range | Emoji | Recommended Action | +|-------|-------------|-------|-------------------| +| Low | 0-3 | 🟢 | Proceed with confidence | +| Moderate | 3-5 | 🟡 | Review recommended terms | +| Medium-High | 5-7 | 🟠 | Negotiate key terms | +| High | 7-10 | 🔴 | Escalate or walk away | + +### Quality Thresholds + +The system evaluates output quality on 5 dimensions: + +| Dimension | Weight | Description | +|-----------|--------|-------------| +| Legal Accuracy | 30% | Terms correctly interpreted, risks properly identified | +| Business Practicality | 25% | Advice is actionable, considers relationship dynamics | +| Clarity | 20% | Creator can understand and act on output | +| Completeness | 15% | All material terms addressed | +| Calibration | 10% | Risk scores match actual risk level | + +**Quality Status:** +- **Production Ready**: Composite score ≥ 8.0 +- **Acceptable with Review**: Score 7.0 - 7.9 +- **Needs Improvement**: Score 6.0 - 6.9 +- **Not Acceptable**: Score < 6.0 + +## Domain-Specific Features + +### Brand Deals + +Analyzes: +- Compensation (base fee, payment timing, kill fee) +- Deliverables (content type, quantity, platforms) +- IP & Usage Rights (ownership, license scope, duration) +- Exclusivity (category, duration, geography) +- Termination (exit conditions, morals clause) + +Red Flags: +- Perpetual, worldwide usage rights +- Broad category exclusivity > 6 months +- Unilateral termination rights +- Payment "upon approval" with no timeline + +### MCN Negotiations + +Analyzes: +- Revenue structure (actual creator take after YouTube's cut) +- Term and exit conditions +- Services promised vs. contractually guaranteed +- Channel ownership post-termination + +Red Flags: +- MCN takes % of non-YouTube revenue +- Term > 3 years with no exit +- Channel ownership unclear +- Exit penalties + +### Business Formation + +Provides: +- Entity type recommendation (LLC, S-Corp, etc.) +- State selection guidance +- Formation checklist +- Tax implications overview + +Recommendations based on: +- Annual revenue +- Number of owners +- Employee status +- Product type (digital vs. physical) + +### Merchandise + +Analyzes: +- Trademark status and filing needs +- Manufacturer agreement terms +- Product liability assessment +- Labeling compliance requirements + +Considerations: +- POD vs. inventory tradeoffs +- Insurance requirements +- CPSIA compliance (children's products) + +### Team Hires + +Analyzes: +- Worker classification (1099 vs. W-2) +- Classification risk factors +- Agreement essentials (scope, IP, termination) + +Classification factors: +- Behavioral control +- Financial control +- Relationship type + +## API Integration + +### Claude API Configuration + +The service uses Claude 3 Opus via the Anthropic API: + +```ruby +HTTParty.post( + 'https://api.anthropic.com/v1/messages', + headers: { + 'Content-Type' => 'application/json', + 'x-api-key' => ENV['ANTHROPIC_API_KEY'], + 'anthropic-version' => '2023-06-01' + }, + body: { model: 'claude-3-opus-20240229', ... } +) +``` + +### Extending Prompts + +Domain-specific prompts are defined in `CreatorLegalReviewService`: + +```ruby +def domain_templates + { + brand_deal: brand_deal_template, + mcn_negotiation: mcn_negotiation_template, + # ... + } +end +``` + +## Testing + +### Running E2E Tests + +```bash +cd e2e +npm install +npm test +``` + +### Test Suites + +| Suite | Command | Description | +|-------|---------|-------------| +| All Tests | `npm test` | Full test suite | +| Brand Deals | `npm run test:brand-deals` | Brand deal domain tests | +| MCN | `npm run test:mcn` | MCN negotiation tests | +| Business | `npm run test:business` | Business formation tests | +| Merchandise | `npm run test:merchandise` | Merchandise domain tests | +| Team Hire | `npm run test:team-hire` | Team hire domain tests | +| JTBD | `npm run test:jtbd` | Jobs-to-be-done scenarios | + +### Benchmark Test Suite + +Located in `lib/creator_legal_review/benchmark_test_suite.rb`: + +```ruby +CreatorLegalReview::BenchmarkTestSuite.all_test_cases +# Returns 31 test cases across all domains + +CreatorLegalReview::BenchmarkTestSuite.validate_result(test_case, result) +# Validates analysis against expected outputs +``` + +## Escalation Rules + +Reviews are automatically escalated when: +1. Overall risk score ≥ 8.0 +2. Composite quality score < 7.0 +3. Domain-specific escalation triggers are detected: + - Brand Deals: Novel terms, deal value > $50K, cross-border + - MCN: Breach implications, existing MCN exit + - Business Formation: Multi-member LLC, complex ownership + - Merchandise: Trademark filing, product liability claims + - Team Hires: Equity/profit sharing, worker disputes + +## Analytics Dashboard + +Access at `/admin/creator_legal_reviews/analytics`: + +- Total reviews count +- Reviews by domain breakdown +- Reviews by status distribution +- Average risk by domain +- Escalation rate +- Quality score distribution +- Recent reviews list + +## Security Considerations + +1. **Data Protection**: Contract text is stored in the database; ensure appropriate access controls +2. **API Keys**: Store ANTHROPIC_API_KEY securely (not in version control) +3. **Authentication**: Admin routes require `global_admin` user role +4. **Audit Trail**: All AI conversations are logged for transparency + +## Future Enhancements + +1. **Document OCR**: Support for scanned PDF contracts +2. **Webhook Notifications**: Alert when reviews need attention +3. **Custom Benchmarks**: Allow users to define domain-specific test cases +4. **Multi-language Support**: Analyze contracts in other languages +5. **Historical Analysis**: Track changes across contract versions +6. **Integration APIs**: Allow external systems to submit reviews + +## Troubleshooting + +### Analysis Not Starting +- Check that `ANTHROPIC_API_KEY` is set +- Verify Sidekiq is running +- Check Sidekiq logs for errors + +### Poor Quality Scores +- Ensure document text is complete and well-formatted +- Provide creator context for better analysis +- Review AI conversation log for insights + +### Escalation Not Triggering +- Check escalation triggers in domain config +- Verify risk scores are being calculated +- Review the `should_escalate?` method logic + +## Support + +For issues or feature requests, please open a GitHub issue or contact the development team. diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..aad4176b7 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,22 @@ +{ + "name": "creator-legal-review-e2e", + "version": "1.0.0", + "description": "End-to-end tests for Creator Legal Review Agent", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:ui": "playwright test --ui", + "test:report": "playwright show-report", + "test:brand-deals": "playwright test --grep 'Brand Deal'", + "test:mcn": "playwright test --grep 'MCN'", + "test:business": "playwright test --grep 'Business Formation'", + "test:merchandise": "playwright test --grep 'Merchandise'", + "test:team-hire": "playwright test --grep 'Team Hire'", + "test:jtbd": "playwright test --grep 'JTBD'" + }, + "devDependencies": { + "@playwright/test": "^1.40.0", + "@types/node": "^20.10.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..591feafd6 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,66 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for Creator Legal Review Agent E2E tests + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Opt out of parallel tests on CI */ + workers: process.env.CI ? 1 : undefined, + + /* Reporter to use */ + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/results.json' }], + ], + + /* Shared settings for all the projects below */ + use: { + /* Base URL to use in actions like `await page.goto('/')` */ + baseURL: process.env.BASE_URL || 'http://localhost:3000', + + /* Collect trace when retrying the failed test */ + trace: 'on-first-retry', + + /* Screenshot on failure */ + screenshot: 'only-on-failure', + + /* Video recording */ + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'rails server -p 3000', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/e2e/tests/creator-legal-review.spec.ts b/e2e/tests/creator-legal-review.spec.ts new file mode 100644 index 000000000..13bb536dc --- /dev/null +++ b/e2e/tests/creator-legal-review.spec.ts @@ -0,0 +1,560 @@ +import { test, expect, Page } from '@playwright/test'; + +/** + * Creator Legal Review Agent - End-to-End Test Suite + * + * This test suite covers the complete user journey for the Creator Legal Review Agent + * across all five domains: Brand Deals, MCN Negotiations, Business Formation, + * Merchandise, and Team Hires. + * + * Test Structure: + * 1. Authentication & Navigation + * 2. Review Creation (per domain) + * 3. AI Analysis Workflow + * 4. Risk Assessment Validation + * 5. Review Management (approve/escalate/complete) + * 6. Analytics Dashboard + */ + +// Test data for each domain +const testData = { + brandDeal: { + title: 'Nike Brand Deal Q1 2024', + domain: 'brand_deal', + creatorName: 'John Creator', + creatorEmail: 'john@creator.com', + documentText: ` + INFLUENCER MARKETING AGREEMENT + + This Agreement is entered into by Nike Inc ("Brand") and John Creator ("Influencer"). + + 1. DELIVERABLES: Influencer agrees to create and publish: + - Three (3) Instagram feed posts + - Five (5) Instagram stories + + 2. COMPENSATION: Brand shall pay Influencer a flat fee of $5,000 USD. + Payment shall be made within Net-60 days of content approval. + + 3. USAGE RIGHTS: Influencer grants Brand a perpetual, worldwide license + to use, reproduce, and modify the Content. + + 4. EXCLUSIVITY: 12 months post-campaign for all fitness brands. + `, + }, + mcnNegotiation: { + title: 'MCN Partnership Review', + domain: 'mcn_negotiation', + creatorName: 'Sarah YouTuber', + creatorEmail: 'sarah@youtube.com', + documentText: ` + MCN PARTNERSHIP AGREEMENT + + Revenue Split: Network receives 30% of Creator's YouTube AdSense revenue. + Term: 3 years with automatic 2-year renewal. + Services: Marketing support and brand deal assistance. + Exit: Creator may exit with 90-day notice after initial term. + `, + }, + businessFormation: { + title: 'LLC Formation Consultation', + domain: 'business_formation', + creatorName: 'Alex Influencer', + creatorEmail: 'alex@creator.biz', + documentText: ` + Business Formation Questionnaire Response: + + Annual Revenue: $150,000 + State of Residence: California + Number of Owners: 1 (Solo creator) + Current Entity: Sole Proprietorship + Employees: 2 contractors (editor, manager) + Products: Digital courses and merchandise + `, + }, + merchandise: { + title: 'Merch Line Legal Review', + domain: 'merchandise', + creatorName: 'Fashion Creator', + creatorEmail: 'fashion@creator.com', + documentText: ` + Merchandise Partnership Agreement + + Product: Custom apparel line (t-shirts, hoodies) + Production: Print-on-demand via Printful + Brand Name: "Creator Style" (trademark status unknown) + Distribution: US and Canada initially + Revenue: 40% to creator after production costs + `, + }, + teamHire: { + title: 'Video Editor Contract Review', + domain: 'team_hire', + creatorName: 'Content Creator', + creatorEmail: 'content@creator.io', + documentText: ` + Contractor Agreement + + Role: Video Editor + Hours: 20-30 hours per week + Rate: $25/hour + Equipment: Contractor uses own equipment + Other Clients: Contractor works with other creators + Duration: Project-based, ongoing + `, + }, +}; + +// Helper functions +async function loginAsAdmin(page: Page) { + await page.goto('/sign_in'); + await page.fill('input[name="user[email]"]', 'admin@test.com'); + await page.fill('input[name="user[password]"]', 'password123'); + await page.click('input[type="submit"]'); + await expect(page).toHaveURL(/sysadmin|admin/); +} + +async function navigateToLegalReviews(page: Page) { + await page.goto('/admin/creator_legal_reviews'); + await expect(page.locator('h1')).toContainText('Creator Legal Reviews'); +} + +async function createReview(page: Page, data: typeof testData.brandDeal) { + await page.goto('/admin/creator_legal_reviews/new'); + + // Fill in the form + await page.fill('input[name="creator_legal_review[title]"]', data.title); + await page.selectOption( + 'select[name="creator_legal_review[domain_type]"]', + data.domain + ); + await page.fill( + 'input[name="creator_legal_review[creator_name]"]', + data.creatorName + ); + await page.fill( + 'input[name="creator_legal_review[creator_email]"]', + data.creatorEmail + ); + await page.fill( + 'textarea[name="creator_legal_review[document_text]"]', + data.documentText + ); + + // Submit the form + await page.click('input[type="submit"]'); + + // Verify redirect to show page + await expect(page.locator('h1')).toContainText(data.title); +} + +// ============================================================================ +// TEST SUITES +// ============================================================================ + +test.describe('Creator Legal Review Agent - Authentication', () => { + test('should redirect unauthenticated users to login', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews'); + await expect(page).toHaveURL(/sign_in/); + }); + + test('should allow admin users to access legal reviews', async ({ page }) => { + await loginAsAdmin(page); + await navigateToLegalReviews(page); + await expect(page.locator('h1')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Brand Deal Domain', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should create a new brand deal review', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // Verify the review was created + await expect(page.locator('.card-header')).toContainText('Review Status'); + await expect(page.locator('.badge')).toContainText('pending'); + }); + + test('should display domain-specific information for brand deals', async ({ + page, + }) => { + await createReview(page, testData.brandDeal); + + // Verify brand deal specific elements + await expect(page.locator('text=Brand Deal')).toBeVisible(); + }); + + test('should initiate AI analysis for brand deal', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // Click analyze button + const analyzeButton = page.locator('text=Start Analysis'); + if (await analyzeButton.isVisible()) { + await analyzeButton.click(); + + // Verify analysis started + await expect(page.locator('.alert')).toContainText(/Analysis started/i); + } + }); + + test('should display risk scores after analysis', async ({ page }) => { + // This test assumes an analyzed review exists + await navigateToLegalReviews(page); + + // Look for a reviewed item + const reviewedRow = page.locator('tr:has-text("reviewed")').first(); + if (await reviewedRow.isVisible()) { + await reviewedRow.locator('a:has-text("View")').click(); + + // Check for risk score display + await expect( + page.locator('text=/Overall Risk|Risk Scores/') + ).toBeVisible(); + } + }); +}); + +test.describe('Creator Legal Review Agent - MCN Negotiation Domain', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should create MCN negotiation review', async ({ page }) => { + await createReview(page, testData.mcnNegotiation); + + await expect(page.locator('.badge')).toContainText('MCN'); + }); + + test('should identify revenue split concerns', async ({ page }) => { + await createReview(page, testData.mcnNegotiation); + + // The document mentions 30% MCN take - this should be flagged + await expect(page.locator('text=MCN Negotiation')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Business Formation Domain', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should create business formation consultation', async ({ page }) => { + await createReview(page, testData.businessFormation); + + await expect(page.locator('text=Business Formation')).toBeVisible(); + }); + + test('should recommend appropriate entity type', async ({ page }) => { + await createReview(page, testData.businessFormation); + + // With $150K revenue, should consider S-Corp + await expect(page.locator('.card')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Merchandise Domain', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should create merchandise review', async ({ page }) => { + await createReview(page, testData.merchandise); + + await expect(page.locator('text=Merchandise')).toBeVisible(); + }); + + test('should flag trademark considerations', async ({ page }) => { + await createReview(page, testData.merchandise); + + // Trademark status is unknown - should be flagged + await expect(page.locator('.card')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Team Hire Domain', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should create team hire review', async ({ page }) => { + await createReview(page, testData.teamHire); + + await expect(page.locator('text=Team Hire')).toBeVisible(); + }); + + test('should analyze worker classification', async ({ page }) => { + await createReview(page, testData.teamHire); + + // Should identify as likely contractor based on factors + await expect(page.locator('.card')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Review Management', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should allow escalating a review', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // Click escalate button + const escalateButton = page.locator('text=Escalate'); + if (await escalateButton.isVisible()) { + await escalateButton.click(); + + // Verify escalation + await expect(page.locator('.badge')).toContainText(/escalated/i); + } + }); + + test('should allow adding reviewer notes', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // Add notes + await page.fill('textarea[name="notes"]', 'This needs human review'); + await page.click('text=Add Notes'); + + // Verify notes saved + await expect(page.locator('text=This needs human review')).toBeVisible(); + }); + + test('should allow approving a reviewed item', async ({ page }) => { + // Navigate to an existing reviewed item + await navigateToLegalReviews(page); + + const reviewedRow = page.locator('tr:has-text("reviewed")').first(); + if (await reviewedRow.isVisible()) { + await reviewedRow.locator('a:has-text("View")').click(); + + const approveButton = page.locator('text=Approve'); + if (await approveButton.isVisible()) { + await approveButton.click(); + + await expect(page.locator('.badge')).toContainText(/completed/i); + } + } + }); +}); + +test.describe('Creator Legal Review Agent - Analytics Dashboard', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should display analytics dashboard', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews/analytics'); + + await expect(page.locator('h1')).toContainText('Analytics Dashboard'); + }); + + test('should show reviews by domain breakdown', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews/analytics'); + + await expect(page.locator('text=Reviews by Domain')).toBeVisible(); + }); + + test('should show quality score distribution', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews/analytics'); + + await expect(page.locator('text=Quality Score Distribution')).toBeVisible(); + }); + + test('should show escalation rate', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews/analytics'); + + await expect(page.locator('text=Escalation Rate')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Filtering & Search', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should filter by domain type', async ({ page }) => { + await navigateToLegalReviews(page); + + await page.selectOption('select[name="domain"]', 'brand_deal'); + await page.click('text=Filter'); + + // All visible items should be brand deals + const domainBadges = page.locator('tbody .badge-secondary'); + const count = await domainBadges.count(); + for (let i = 0; i < count; i++) { + await expect(domainBadges.nth(i)).toContainText('Brand Deal'); + } + }); + + test('should filter by status', async ({ page }) => { + await navigateToLegalReviews(page); + + await page.selectOption('select[name="status"]', 'pending'); + await page.click('text=Filter'); + + // All visible items should be pending + const statusBadges = page.locator('tbody td:nth-child(4) .badge'); + const count = await statusBadges.count(); + for (let i = 0; i < count; i++) { + await expect(statusBadges.nth(i)).toContainText('Pending'); + } + }); + + test('should filter high risk items', async ({ page }) => { + await navigateToLegalReviews(page); + + await page.check('input[name="high_risk"]'); + await page.click('text=Filter'); + + // Should only show high risk items (score >= 7) + await expect(page.locator('h1')).toBeVisible(); + }); + + test('should filter items needing human review', async ({ page }) => { + await navigateToLegalReviews(page); + + await page.check('input[name="needs_review"]'); + await page.click('text=Filter'); + + // Should highlight items needing review + await expect(page.locator('h1')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Document Handling', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should accept pasted document text', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews/new'); + + const longDocument = 'This is a test contract. '.repeat(100); + await page.fill( + 'textarea[name="creator_legal_review[document_text]"]', + longDocument + ); + + await expect( + page.locator('textarea[name="creator_legal_review[document_text]"]') + ).toHaveValue(longDocument); + }); + + test('should show document in review detail', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // Toggle document section + const toggleButton = page.locator('button:has-text("Toggle Document")'); + if (await toggleButton.isVisible()) { + await toggleButton.click(); + + // Document text should be visible + await expect( + page.locator('text=INFLUENCER MARKETING AGREEMENT') + ).toBeVisible(); + } + }); +}); + +test.describe('Creator Legal Review Agent - Error Handling', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + test('should validate required fields on create', async ({ page }) => { + await page.goto('/admin/creator_legal_reviews/new'); + + // Try to submit without required fields + await page.click('input[type="submit"]'); + + // Should show validation errors (HTML5 validation or server-side) + // This depends on implementation - checking for either + const titleInput = page.locator( + 'input[name="creator_legal_review[title]"]' + ); + const isInvalid = + (await titleInput.evaluate( + (el) => (el as HTMLInputElement).validationMessage + )) !== ''; + expect(isInvalid || (await page.locator('.alert-danger').isVisible())).toBe( + true + ); + }); + + test('should handle analysis failure gracefully', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // If analysis fails (e.g., no API key), should show appropriate message + // This test validates the UI handles errors gracefully + await expect(page.locator('.card')).toBeVisible(); + }); +}); + +test.describe('Creator Legal Review Agent - Jobs to Be Done (JTBD) Scenarios', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + }); + + /** + * JTBD 1: Brand Deal Review + * "When I receive a brand deal contract, I want to quickly understand + * if the terms are fair and identify anything I should negotiate, + * so I can sign confidently without leaving money on the table." + */ + test('JTBD: Brand deal - quick understanding of terms', async ({ page }) => { + await createReview(page, testData.brandDeal); + + // Time to understanding should be fast (UI loads quickly) + await expect(page.locator('.card-header')).toBeVisible(); + + // Key information should be prominently displayed + await expect(page.locator('text=Review Status')).toBeVisible(); + }); + + /** + * JTBD 2: MCN Evaluation + * "When an MCN approaches me with a partnership offer, I want to + * understand the real financial impact and what I'm actually getting." + */ + test('JTBD: MCN evaluation - financial impact clarity', async ({ page }) => { + await createReview(page, testData.mcnNegotiation); + + // Should clearly show the MCN domain + await expect(page.locator('text=MCN Negotiation')).toBeVisible(); + }); + + /** + * JTBD 3: Business Formation + * "When I'm earning enough to worry about liability and taxes, I want + * to understand what business entity I need." + */ + test('JTBD: Business formation - entity recommendation', async ({ page }) => { + await createReview(page, testData.businessFormation); + + await expect(page.locator('text=Business Formation')).toBeVisible(); + }); + + /** + * JTBD 4: Merchandise Launch + * "When I want to launch merchandise, I want to know what legal + * protections I need." + */ + test('JTBD: Merchandise - legal protection guidance', async ({ page }) => { + await createReview(page, testData.merchandise); + + await expect(page.locator('text=Merchandise')).toBeVisible(); + }); + + /** + * JTBD 5: Team Hiring + * "When I need to hire help, I want to know the right way to + * structure the relationship." + */ + test('JTBD: Team hire - relationship structure', async ({ page }) => { + await createReview(page, testData.teamHire); + + await expect(page.locator('text=Team Hire')).toBeVisible(); + }); +}); diff --git a/lib/creator_legal_review/benchmark_test_suite.rb b/lib/creator_legal_review/benchmark_test_suite.rb new file mode 100644 index 000000000..b473bd11b --- /dev/null +++ b/lib/creator_legal_review/benchmark_test_suite.rb @@ -0,0 +1,400 @@ +module CreatorLegalReview + # Comprehensive Benchmark Test Suite for Creator Legal Review Agent + # Based on the Domain-Driven Development specification + # + # This module provides test cases for validating the AI analysis quality + # across all five domains: Brand Deals, MCN Negotiations, Business Formation, + # Merchandise, and Team Hires. + module BenchmarkTestSuite + BRAND_DEAL_TEST_CASES = [ + { + id: 'BD-001', + scenario: 'Standard sponsorship with predatory terms', + key_challenge: 'Perpetual rights, broad exclusivity', + expected_risk: 'high', + input: { + contract_text: <<~CONTRACT, + INFLUENCER MARKETING AGREEMENT + + This Agreement is entered into by Brand Corp ("Brand") and Creator ("Influencer"). + + 1. DELIVERABLES: Influencer agrees to create and publish: + - Three (3) Instagram feed posts + - Five (5) Instagram stories + - Content must be posted within 14 days of brief approval + + 2. COMPENSATION: Brand shall pay Influencer a flat fee of $5,000 USD. + Payment shall be made within Net-60 days of content approval. + + 3. USAGE RIGHTS: Influencer grants Brand a perpetual, worldwide, royalty-free, + sublicensable license to use, reproduce, modify, adapt, publish, translate, + create derivative works from, distribute, and display the Content in any + and all media formats and channels now known or later developed. + + 4. EXCLUSIVITY: During the Campaign Period and for twelve (12) months following, + Influencer shall not promote, endorse, or create content for any competing + products in the fitness and wellness category. + + 5. TERMINATION: Brand may terminate this Agreement at any time for any reason. + Upon termination, Influencer shall not be entitled to any unpaid compensation. + CONTRACT + creator_context: { + follower_count: 250000, + platform: 'Instagram', + niche: 'Fitness', + previous_deals: 5 + } + }, + expected_output: { + risk_scores: { + compensation: { min: 5, max: 7 }, # Below market, Net-60 is slow + usage_rights: { min: 8, max: 10 }, # Perpetual is predatory + exclusivity: { min: 7, max: 10 }, # 12 months post-campaign excessive + termination: { min: 8, max: 10 } # Unilateral, no kill fee + }, + overall_risk: { min: 7, max: 10 }, + recommended_action: 'negotiate', + must_identify: [ + 'perpetual license', + 'broad exclusivity', + 'unilateral termination', + 'no kill fee', + 'below market rate' + ] + }, + evaluation_rubric: { + legal_accuracy: 'All 5 material terms correctly identified', + business_practicality: 'Correctly identified as below market for 250K followers', + clarity: 'Plain English explanation of perpetual rights implications' + } + }, + { + id: 'BD-002', + scenario: 'Fair deal, minor improvements possible', + key_challenge: 'Usage scope slightly broad', + expected_risk: 'low', + input: { + contract_text: <<~CONTRACT, + BRAND PARTNERSHIP AGREEMENT + + 1. DELIVERABLES: Creator will produce two (2) YouTube videos. + 2. COMPENSATION: $15,000 USD, paid 50% upfront, 50% upon delivery. + 3. USAGE: Brand may use content on owned social channels for 6 months. + 4. EXCLUSIVITY: Category exclusivity during campaign (2 weeks) only. + 5. TERMINATION: Either party may terminate with 14 days notice. + Creator entitled to payment for work completed. + CONTRACT + creator_context: { + follower_count: 500000, + platform: 'YouTube', + niche: 'Tech', + previous_deals: 20 + } + }, + expected_output: { + overall_risk: { min: 1, max: 4 }, + recommended_action: 'sign_as_is' + } + }, + { + id: 'BD-003', + scenario: 'Complex multi-platform campaign', + key_challenge: 'Multiple deliverable types, phased payment', + expected_risk: 'medium' + }, + { + id: 'BD-004', + scenario: 'UGC whitelisting agreement', + key_challenge: 'Paid media rights, no base fee', + expected_risk: 'medium_high' + }, + { + id: 'BD-005', + scenario: 'Affiliate-only deal', + key_challenge: 'Commission structure, no guaranteed payment', + expected_risk: 'medium' + }, + { + id: 'BD-006', + scenario: 'Ambassador long-term contract', + key_challenge: '12-month commitment, equity component', + expected_risk: 'medium' + }, + { + id: 'BD-007', + scenario: 'International brand, foreign law', + key_challenge: 'Governing law in UK, US creator', + expected_risk: 'high' + }, + { + id: 'BD-008', + scenario: 'Startup equity deal', + key_challenge: 'Payment in stock, vesting schedule', + expected_risk: 'high' + } + ].freeze + + MCN_TEST_CASES = [ + { + id: 'MCN-001', + scenario: 'Major MCN, standard terms', + key_challenge: '70/30 split, 3-year term', + expected_risk: 'medium', + expected_recommendation: 'negotiate' + }, + { + id: 'MCN-002', + scenario: 'Small MCN, aggressive terms', + key_challenge: 'Takes % of all revenue, 5-year term', + expected_risk: 'high', + expected_recommendation: 'walk_away' + }, + { + id: 'MCN-003', + scenario: 'Fair deal, good reputation', + key_challenge: '80/20 split, services guaranteed', + expected_risk: 'low', + expected_recommendation: 'sign_as_is' + }, + { + id: 'MCN-004', + scenario: 'Escape existing bad MCN', + key_challenge: 'Trapped in unfavorable contract', + expected_risk: 'high', + expected_recommendation: 'escalate_to_lawyer' + }, + { + id: 'MCN-005', + scenario: 'MCN offering equity', + key_challenge: 'Revenue share + company stock', + expected_risk: 'high', + expected_recommendation: 'escalate_to_lawyer' + } + ].freeze + + BUSINESS_FORMATION_TEST_CASES = [ + { + id: 'BF-001', + scenario: 'New creator, $30K/year', + key_variables: 'Solo, no employees, digital only', + expected_recommendation: 'single_member_llc' + }, + { + id: 'BF-002', + scenario: 'Growing creator, $150K/year', + key_variables: 'Solo, uses contractors', + expected_recommendation: 's_corp_election' + }, + { + id: 'BF-003', + scenario: 'Creator duo, 50/50', + key_variables: 'Two owners, shared revenue', + expected_recommendation: 'multi_member_llc' + }, + { + id: 'BF-004', + scenario: 'Creator with merch', + key_variables: 'Physical products, inventory', + expected_recommendation: 'llc_plus_insurance' + }, + { + id: 'BF-005', + scenario: 'Multi-state creator', + key_variables: 'Lives in CA, travels for content', + expected_recommendation: 'consult_for_state_selection' + }, + { + id: 'BF-006', + scenario: 'Creator with employees', + key_variables: '3 W-2 employees', + expected_recommendation: 's_corp' + } + ].freeze + + MERCHANDISE_TEST_CASES = [ + { + id: 'MR-001', + scenario: 'T-shirt line, POD', + key_variables: 'Apparel, print-on-demand', + key_legal_issues: ['Trademark', 'Labeling'] + }, + { + id: 'MR-002', + scenario: 'Signature product collab', + key_variables: 'Licensing deal with brand', + key_legal_issues: ['IP ownership', 'Revenue share'] + }, + { + id: 'MR-003', + scenario: 'Supplements/vitamins', + key_variables: 'Consumable, FDA regulated', + key_legal_issues: ['Heavy compliance'] + }, + { + id: 'MR-004', + scenario: 'Kids products', + key_variables: "Children's items", + key_legal_issues: ['CPSIA', 'Testing'] + }, + { + id: 'MR-005', + scenario: 'Digital products', + key_variables: 'Courses, templates', + key_legal_issues: ['Terms of use', 'Refund policy'] + }, + { + id: 'MR-006', + scenario: 'International shipping', + key_variables: 'Physical goods, worldwide', + key_legal_issues: ['Import/export', 'Taxes'] + } + ].freeze + + TEAM_HIRE_TEST_CASES = [ + { + id: 'TH-001', + scenario: 'Freelance editor', + key_variables: 'Project-based, own equipment', + key_legal_issues: ['Contractor agreement', 'IP'], + expected_classification: 'independent_contractor' + }, + { + id: 'TH-002', + scenario: 'Full-time manager', + key_variables: '40 hrs/week, exclusive', + key_legal_issues: ['Employee classification'], + expected_classification: 'employee' + }, + { + id: 'TH-003', + scenario: 'Friend as assistant', + key_variables: 'Blurred lines, equity ask', + key_legal_issues: ['Classification', 'Equity'], + expected_recommendation: 'escalate_to_lawyer' + }, + { + id: 'TH-004', + scenario: 'Overseas contractor', + key_variables: 'International worker', + key_legal_issues: ['Tax treaties', 'IP'] + }, + { + id: 'TH-005', + scenario: 'Converting contractor to employee', + key_variables: 'Long-term contractor', + key_legal_issues: ['Back taxes', 'Reclassification'] + }, + { + id: 'TH-006', + scenario: 'Firing a friend', + key_variables: 'Need to terminate', + key_legal_issues: ['Relationship', 'Legal'] + } + ].freeze + + # Acceptance thresholds for quality scores + ACCEPTANCE_THRESHOLDS = { + production_ready: 8.0, + acceptable_with_review: 7.0, + needs_improvement: 6.0 + }.freeze + + # Evaluation dimension weights + EVALUATION_WEIGHTS = { + legal_accuracy: 0.30, + business_practicality: 0.25, + clarity: 0.20, + completeness: 0.15, + calibration: 0.10 + }.freeze + + class << self + def all_test_cases + { + brand_deals: BRAND_DEAL_TEST_CASES, + mcn_negotiations: MCN_TEST_CASES, + business_formation: BUSINESS_FORMATION_TEST_CASES, + merchandise: MERCHANDISE_TEST_CASES, + team_hires: TEAM_HIRE_TEST_CASES + } + end + + def test_cases_for_domain(domain) + case domain.to_sym + when :brand_deal then BRAND_DEAL_TEST_CASES + when :mcn_negotiation then MCN_TEST_CASES + when :business_formation then BUSINESS_FORMATION_TEST_CASES + when :merchandise then MERCHANDISE_TEST_CASES + when :team_hire then TEAM_HIRE_TEST_CASES + else [] + end + end + + def total_test_count + all_test_cases.values.flatten.count + end + + def validate_result(test_case, result) + validations = [] + + # Validate risk score range + if test_case[:expected_output]&.dig(:overall_risk) + range = test_case[:expected_output][:overall_risk] + actual = result[:overall_risk_score].to_f + validations << { + check: 'overall_risk_in_range', + passed: actual >= range[:min] && actual <= range[:max], + expected: "#{range[:min]}-#{range[:max]}", + actual: actual + } + end + + # Validate recommended action + if test_case[:expected_output]&.dig(:recommended_action) + expected = test_case[:expected_output][:recommended_action] + actual = result[:recommended_action] + validations << { + check: 'recommended_action', + passed: actual == expected, + expected: expected, + actual: actual + } + end + + # Validate must-identify items + if test_case[:expected_output]&.dig(:must_identify) + must_identify = test_case[:expected_output][:must_identify] + identified = result[:extracted_terms].to_s.downcase + result[:analysis_result].to_s.downcase + must_identify.each do |item| + validations << { + check: "identifies_#{item.parameterize.underscore}", + passed: identified.include?(item.downcase), + expected: item, + actual: identified.include?(item.downcase) ? 'found' : 'not found' + } + end + end + + { + test_case_id: test_case[:id], + validations: validations, + passed: validations.all? { |v| v[:passed] }, + pass_rate: validations.count { |v| v[:passed] }.to_f / validations.count + } + end + + def calculate_composite_score(evaluation_scores) + EVALUATION_WEIGHTS.sum do |dimension, weight| + (evaluation_scores[dimension.to_s].to_f * weight) + end.round(2) + end + + def quality_status(composite_score) + return 'production_ready' if composite_score >= ACCEPTANCE_THRESHOLDS[:production_ready] + return 'acceptable_with_review' if composite_score >= ACCEPTANCE_THRESHOLDS[:acceptable_with_review] + return 'needs_improvement' if composite_score >= ACCEPTANCE_THRESHOLDS[:needs_improvement] + 'not_acceptable' + end + end + end +end diff --git a/test/models/creator_legal_review_test.rb b/test/models/creator_legal_review_test.rb new file mode 100644 index 000000000..2a88bce47 --- /dev/null +++ b/test/models/creator_legal_review_test.rb @@ -0,0 +1,304 @@ +require "test_helper" + +class CreatorLegalReviewTest < ActiveSupport::TestCase + def setup + @review = CreatorLegalReview.new( + title: "Test Brand Deal Review", + domain_type: "brand_deal", + document_text: "Sample contract text for testing" + ) + end + + # Validation Tests + test "should be valid with required attributes" do + assert @review.valid? + end + + test "should require title" do + @review.title = nil + assert_not @review.valid? + assert_includes @review.errors[:title], "can't be blank" + end + + test "should require domain_type" do + @review.domain_type = nil + assert_not @review.valid? + end + + test "should only accept valid domain types" do + valid_domains = %w[brand_deal mcn_negotiation business_formation merchandise team_hire] + + valid_domains.each do |domain| + @review.domain_type = domain + assert @review.valid?, "#{domain} should be valid" + end + + @review.domain_type = "invalid_domain" + assert_not @review.valid? + end + + test "should only accept valid statuses" do + valid_statuses = %w[pending analyzing reviewed escalated completed] + + valid_statuses.each do |status| + @review.status = status + assert @review.valid?, "#{status} should be valid" + end + + @review.status = "invalid_status" + assert_not @review.valid? + end + + test "should validate risk score range" do + @review.overall_risk_score = -1 + assert_not @review.valid? + + @review.overall_risk_score = 11 + assert_not @review.valid? + + @review.overall_risk_score = 5.5 + assert @review.valid? + end + + test "should validate email format if provided" do + @review.creator_email = "invalid_email" + assert_not @review.valid? + + @review.creator_email = "valid@email.com" + assert @review.valid? + + @review.creator_email = nil + assert @review.valid? # Email is optional + end + + # Domain Configuration Tests + test "should return correct domain config" do + assert_equal "Brand Deal", @review.domain_config[:name] + assert_equal "medium", @review.domain_config[:legal_complexity] + end + + test "all domains should have configuration" do + %w[brand_deal mcn_negotiation business_formation merchandise team_hire].each do |domain| + @review.domain_type = domain + assert_not_nil @review.domain_config + assert_not_nil @review.domain_config[:name] + assert_not_nil @review.domain_config[:key_touchpoints] + end + end + + # Risk Level Tests + test "should return correct risk level for low risk" do + @review.overall_risk_score = 2 + assert_equal "Low Risk", @review.risk_label + assert_equal "🟢", @review.risk_emoji + end + + test "should return correct risk level for moderate risk" do + @review.overall_risk_score = 4 + assert_equal "Moderate Risk", @review.risk_label + assert_equal "🟡", @review.risk_emoji + end + + test "should return correct risk level for medium-high risk" do + @review.overall_risk_score = 6 + assert_equal "Medium-High Risk", @review.risk_label + assert_equal "🟠", @review.risk_emoji + end + + test "should return correct risk level for high risk" do + @review.overall_risk_score = 8 + assert_equal "High Risk", @review.risk_label + assert_equal "🔴", @review.risk_emoji + end + + test "should handle nil risk score" do + @review.overall_risk_score = nil + assert_equal "Not assessed", @review.risk_label + assert_equal "⚪", @review.risk_emoji + end + + # Status Transition Tests + test "should mark as analyzing" do + @review.save! + @review.mark_as_analyzing! + assert_equal "analyzing", @review.status + end + + test "should mark as reviewed" do + @review.save! + @review.mark_as_reviewed!(reviewer_id: 1, notes: "Test notes") + assert_equal "reviewed", @review.status + assert_equal 1, @review.reviewed_by_user_id + assert_equal "Test notes", @review.reviewer_notes + assert_not_nil @review.reviewed_at + end + + test "should mark as escalated with reason" do + @review.save! + @review.mark_as_escalated!("High risk terms detected") + assert_equal "escalated", @review.status + assert @review.needs_human_review? + assert_equal "High risk terms detected", @review.escalation_reason + end + + test "should mark as completed" do + @review.save! + @review.mark_as_completed! + assert_equal "completed", @review.status + end + + # Escalation Logic Tests + test "should escalate when risk score is 8 or above" do + @review.overall_risk_score = 8.0 + assert @review.should_escalate? + end + + test "should not escalate when risk score is below 8" do + @review.overall_risk_score = 7.9 + assert_not @review.should_escalate? + end + + test "should escalate when needs human review is true" do + @review.needs_human_review = true + assert @review.should_escalate? + end + + # Quality Score Tests + test "should calculate composite score correctly" do + @review.save! + @review.evaluation_scores = { + 'legal_accuracy' => 8.0, + 'business_practicality' => 7.0, + 'clarity' => 9.0, + 'completeness' => 8.0, + 'calibration' => 7.0 + } + @review.save! + + score = @review.calculate_composite_score + + # Expected: (8.0 * 0.30) + (7.0 * 0.25) + (9.0 * 0.20) + (8.0 * 0.15) + (7.0 * 0.10) + # = 2.4 + 1.75 + 1.8 + 1.2 + 0.7 = 7.85 + assert_in_delta 7.85, score, 0.01 + end + + test "should identify production ready status" do + @review.composite_score = 8.5 + assert @review.production_ready? + assert_equal "Production ready", @review.quality_status + end + + test "should identify acceptable with review status" do + @review.composite_score = 7.5 + assert @review.acceptable_with_review? + assert_equal "Acceptable with human review", @review.quality_status + end + + test "should identify needs improvement status" do + @review.composite_score = 6.5 + assert @review.needs_improvement? + assert_equal "Needs improvement", @review.quality_status + end + + test "should identify not acceptable status" do + @review.composite_score = 5.5 + assert @review.not_acceptable? + assert_equal "Not acceptable", @review.quality_status + end + + # Scope Tests + test "pending scope returns only pending reviews" do + @review.status = "pending" + @review.save! + + reviewed = CreatorLegalReview.create!( + title: "Reviewed", + domain_type: "brand_deal", + status: "reviewed" + ) + + pending_reviews = CreatorLegalReview.pending + assert_includes pending_reviews, @review + assert_not_includes pending_reviews, reviewed + end + + test "high_risk scope returns reviews with score >= 7" do + @review.overall_risk_score = 8.0 + @review.save! + + low_risk = CreatorLegalReview.create!( + title: "Low Risk", + domain_type: "brand_deal", + overall_risk_score: 3.0 + ) + + high_risk_reviews = CreatorLegalReview.high_risk + assert_includes high_risk_reviews, @review + assert_not_includes high_risk_reviews, low_risk + end + + test "by_domain scope filters correctly" do + @review.domain_type = "brand_deal" + @review.save! + + mcn_review = CreatorLegalReview.create!( + title: "MCN Review", + domain_type: "mcn_negotiation" + ) + + brand_deals = CreatorLegalReview.by_domain("brand_deal") + assert_includes brand_deals, @review + assert_not_includes brand_deals, mcn_review + end + + # Analytics Tests + test "should calculate average risk by domain" do + CreatorLegalReview.create!( + title: "BD 1", + domain_type: "brand_deal", + overall_risk_score: 6.0 + ) + CreatorLegalReview.create!( + title: "BD 2", + domain_type: "brand_deal", + overall_risk_score: 8.0 + ) + + averages = CreatorLegalReview.average_risk_by_domain + assert_in_delta 7.0, averages["brand_deal"], 0.01 + end + + test "should calculate escalation rate" do + 3.times do |i| + CreatorLegalReview.create!( + title: "Review #{i}", + domain_type: "brand_deal", + status: "reviewed" + ) + end + CreatorLegalReview.create!( + title: "Escalated", + domain_type: "brand_deal", + status: "escalated" + ) + + rate = CreatorLegalReview.escalation_rate + assert_equal 25.0, rate + end + + # Slug Generation Tests + test "should generate unique slug on create" do + @review.save! + assert_not_nil @review.slug + assert_match(/\A[a-f0-9]{20}\z/, @review.slug) + end + + test "slugs should be unique" do + @review.save! + second_review = CreatorLegalReview.create!( + title: "Second Review", + domain_type: "brand_deal" + ) + assert_not_equal @review.slug, second_review.slug + end +end