diff --git a/README.md b/README.md index 6780c89..8f6cfe2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,17 @@ Each stage is a queued job. The pipeline is event-driven. Stages are defined in ## Features +### AI Content Quality Scoring +**New in v0.10.0.** Automated multi-dimensional content quality analysis across five dimensions: +- **Readability** — Flesch-Kincaid metrics, sentence and word complexity +- **SEO** — Keyword density, heading structure, meta optimization +- **Brand Consistency** — LLM-powered brand voice and tone analysis +- **Factual Accuracy** — Cross-referenced claim validation +- **Engagement Prediction** — AI-predicted engagement score + +Features: real-time score ring in the editor sidebar, quality dashboard with Chart.js trend visualization, space leaderboard, configurable pipeline quality gates, auto-score on publish, and a `quality.scored` webhook event. + + ### Content Generation Pipeline Submit a brief → AI agents generate, illustrate, optimize, and quality-gate content → auto-publish or human review. diff --git a/app/Events/Quality/ContentQualityScored.php b/app/Events/Quality/ContentQualityScored.php new file mode 100644 index 0000000..46fdfdb --- /dev/null +++ b/app/Events/Quality/ContentQualityScored.php @@ -0,0 +1,17 @@ + '', + 'spaceName' => '', + 'initialTrends' => [], + 'initialLeaderboard' => [], + 'initialDistribution' => [], + ]); + } + + $from = Carbon::now()->subDays(30); + $to = Carbon::now(); + + $trends = $this->aggregator->getSpaceTrends($space, $from, $to); + $leaderboard = $this->aggregator->getSpaceLeaderboard($space, 10); + $distribution = $this->aggregator->getDimensionDistribution($space); + + return Inertia::render('Quality/Dashboard', [ + 'spaceId' => $space->id, + 'spaceName' => $space->name, + 'initialTrends' => $trends, + 'initialLeaderboard' => $leaderboard->map(fn ($s) => [ + 'score_id' => $s->id, + 'content_id' => $s->content_id, + 'title' => $s->content->currentVersion?->title, + 'overall_score' => $s->overall_score, + 'scored_at' => $s->scored_at->toIso8601String(), + ]), + 'initialDistribution' => $distribution, + ]); + } +} diff --git a/app/Http/Controllers/Admin/QualitySettingsController.php b/app/Http/Controllers/Admin/QualitySettingsController.php new file mode 100644 index 0000000..a432308 --- /dev/null +++ b/app/Http/Controllers/Admin/QualitySettingsController.php @@ -0,0 +1,28 @@ +id)->first() + : null; + + return Inertia::render('Settings/Quality', [ + 'spaceId' => $space !== null ? $space->id : '', + 'spaceName' => $space !== null ? $space->name : '', + 'config' => $config, + ]); + } +} diff --git a/app/Http/Controllers/Api/ContentQualityController.php b/app/Http/Controllers/Api/ContentQualityController.php new file mode 100644 index 0000000..88eccf8 --- /dev/null +++ b/app/Http/Controllers/Api/ContentQualityController.php @@ -0,0 +1,228 @@ +validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + 'content_id' => ['sometimes', 'string', 'exists:contents,id'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]); + + $user = $request->user(); + $this->authz->authorize($user, 'content.view', $validated['space_id']); + + $query = ContentQualityScore::with('items') + ->where('space_id', $validated['space_id']) + ->latest('scored_at'); + + if (isset($validated['content_id'])) { + $query->where('content_id', $validated['content_id']); + } + + $perPage = (int) ($validated['per_page'] ?? 20); + + return ContentQualityScoreResource::collection($query->paginate($perPage)); + } + + /** + * GET /api/v1/quality/scores/{score} + * Get a single quality score with its items. + */ + public function show(Request $request, ContentQualityScore $score): ContentQualityScoreResource + { + $user = $request->user(); + $this->authz->authorize($user, 'content.view', $score->space_id); + + $score->load('items'); + + return new ContentQualityScoreResource($score); + } + + /** + * POST /api/v1/quality/score + * Trigger a quality scoring job for a content item. + */ + public function score(Request $request): JsonResponse + { + $validated = $request->validate([ + 'content_id' => ['required', 'string', 'exists:contents,id'], + ]); + + /** @var Content $content */ + $content = Content::findOrFail($validated['content_id']); + + $user = $request->user(); + $this->authz->authorize($user, 'content.view', $content->space_id); + + ScoreContentQualityJob::dispatch($content); + + return response()->json([ + 'message' => 'Quality scoring job queued.', + 'content_id' => $content->id, + ], 202); + } + + /** + * GET /api/v1/quality/trends?space_id=&from=&to= + * Aggregate daily trend data for a space. + */ + public function trends(Request $request): JsonResponse + { + $validated = $request->validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + 'from' => ['sometimes', 'date'], + 'to' => ['sometimes', 'date'], + ]); + + $user = $request->user(); + $this->authz->authorize($user, 'content.view', $validated['space_id']); + + /** @var Space $space */ + $space = Space::findOrFail($validated['space_id']); + + $from = isset($validated['from']) ? Carbon::parse($validated['from']) : now()->subDays(30); + $to = isset($validated['to']) ? Carbon::parse($validated['to']) : now(); + + $trends = $this->trendAggregator->getSpaceTrends($space, $from, $to); + $leaderboard = $this->trendAggregator->getSpaceLeaderboard($space, 10); + $distribution = $this->trendAggregator->getDimensionDistribution($space); + + return response()->json([ + 'data' => [ + 'trends' => $trends, + 'leaderboard' => $leaderboard->map(fn (ContentQualityScore $s) => [ + 'score_id' => $s->id, + 'content_id' => $s->content_id, + 'title' => $s->content->currentVersion?->title, + 'overall_score' => $s->overall_score, + 'scored_at' => $s->scored_at->toIso8601String(), + ]), + 'distribution' => $distribution, + 'period' => [ + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + ], + ], + ]); + } + + /** + * GET /api/v1/quality/config?space_id= + * Get quality config for a space (or default if not configured). + */ + public function getConfig(Request $request): ContentQualityConfigResource + { + $validated = $request->validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + ]); + + $user = $request->user(); + $this->authz->authorize($user, 'content.view', $validated['space_id']); + + $config = ContentQualityConfig::firstOrCreate( + ['space_id' => $validated['space_id']], + [ + 'dimension_weights' => [ + 'readability' => 0.25, + 'seo' => 0.25, + 'brand_consistency' => 0.20, + 'factual_accuracy' => 0.15, + 'engagement_prediction' => 0.15, + ], + 'thresholds' => [ + 'poor' => 40, + 'fair' => 60, + 'good' => 75, + 'excellent' => 90, + ], + 'enabled_dimensions' => ['readability', 'seo', 'brand_consistency', 'factual_accuracy', 'engagement_prediction'], + 'auto_score_on_publish' => true, + 'pipeline_gate_enabled' => false, + 'pipeline_gate_min_score' => 70.0, + ] + ); + + return new ContentQualityConfigResource($config); + } + + /** + * PUT /api/v1/quality/config + * Update quality config for a space. + */ + public function updateConfig(Request $request): ContentQualityConfigResource + { + $validated = $request->validate([ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + 'dimension_weights' => ['sometimes', 'array'], + 'dimension_weights.*' => ['numeric', 'min:0', 'max:1'], + 'thresholds' => ['sometimes', 'array'], + 'thresholds.*' => ['numeric', 'min:0', 'max:100'], + 'enabled_dimensions' => ['sometimes', 'array'], + 'enabled_dimensions.*' => [ + 'string', + Rule::in(['readability', 'seo', 'brand_consistency', 'factual_accuracy', 'engagement_prediction']), + ], + 'auto_score_on_publish' => ['sometimes', 'boolean'], + 'pipeline_gate_enabled' => ['sometimes', 'boolean'], + 'pipeline_gate_min_score' => ['sometimes', 'numeric', 'min:0', 'max:100'], + ]); + + $user = $request->user(); + $this->authz->authorize($user, 'settings.manage', $validated['space_id']); + + $defaults = [ + 'dimension_weights' => [ + 'readability' => 0.25, + 'seo' => 0.25, + 'brand_consistency' => 0.20, + 'factual_accuracy' => 0.15, + 'engagement_prediction' => 0.15, + ], + 'thresholds' => ['poor' => 40, 'fair' => 60, 'good' => 75, 'excellent' => 90], + 'enabled_dimensions' => ['readability', 'seo', 'brand_consistency', 'factual_accuracy', 'engagement_prediction'], + 'auto_score_on_publish' => true, + 'pipeline_gate_enabled' => false, + 'pipeline_gate_min_score' => 70.0, + ]; + + $config = ContentQualityConfig::firstOrNew( + ['space_id' => $validated['space_id']], + array_merge($defaults, ['space_id' => $validated['space_id']]), + ); + + $updates = array_filter($validated, fn ($v, $k) => $k !== 'space_id', ARRAY_FILTER_USE_BOTH); + $config->fill($updates); + $config->save(); + + return new ContentQualityConfigResource($config); + } +} diff --git a/app/Http/Resources/ContentQualityConfigResource.php b/app/Http/Resources/ContentQualityConfigResource.php new file mode 100644 index 0000000..1183772 --- /dev/null +++ b/app/Http/Resources/ContentQualityConfigResource.php @@ -0,0 +1,27 @@ + $this->id, + 'space_id' => $this->space_id, + 'dimension_weights' => $this->dimension_weights, + 'thresholds' => $this->thresholds, + 'enabled_dimensions' => $this->enabled_dimensions, + 'auto_score_on_publish' => $this->auto_score_on_publish, + 'pipeline_gate_enabled' => $this->pipeline_gate_enabled, + 'pipeline_gate_min_score' => $this->pipeline_gate_min_score, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/ContentQualityScoreItemResource.php b/app/Http/Resources/ContentQualityScoreItemResource.php new file mode 100644 index 0000000..9396c70 --- /dev/null +++ b/app/Http/Resources/ContentQualityScoreItemResource.php @@ -0,0 +1,27 @@ + $this->id, + 'dimension' => $this->dimension, + 'category' => $this->category, + 'rule_key' => $this->rule_key, + 'label' => $this->label, + 'severity' => $this->severity, + 'score_impact' => $this->score_impact, + 'message' => $this->message, + 'suggestion' => $this->suggestion, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Http/Resources/ContentQualityScoreResource.php b/app/Http/Resources/ContentQualityScoreResource.php new file mode 100644 index 0000000..493d3fc --- /dev/null +++ b/app/Http/Resources/ContentQualityScoreResource.php @@ -0,0 +1,33 @@ + $this->id, + 'space_id' => $this->space_id, + 'content_id' => $this->content_id, + 'content_version_id' => $this->content_version_id, + 'overall_score' => $this->overall_score, + 'dimensions' => [ + 'readability' => $this->readability_score, + 'seo' => $this->seo_score, + 'brand' => $this->brand_score, + 'factual' => $this->factual_score, + 'engagement' => $this->engagement_score, + ], + 'scoring_model' => $this->scoring_model, + 'scoring_duration_ms' => $this->scoring_duration_ms, + 'scored_at' => $this->scored_at->toIso8601String(), + 'items' => $this->whenLoaded('items', fn () => ContentQualityScoreItemResource::collection($this->items)), + ]; + } +} diff --git a/app/Jobs/QualityGateStageJob.php b/app/Jobs/QualityGateStageJob.php new file mode 100644 index 0000000..4355a42 --- /dev/null +++ b/app/Jobs/QualityGateStageJob.php @@ -0,0 +1,96 @@ +onQueue('ai-pipeline'); + } + + public function handle(ContentQualityService $qualityService, PipelineExecutor $executor): void + { + $content = $this->run->content; + + if ($content === null) { + Log::warning('QualityGateStageJob: no content on pipeline run', ['run_id' => $this->run->id]); + $executor->advance($this->run, ['success' => true, 'skipped' => 'no_content']); + + return; + } + + // Load space quality config + $config = ContentQualityConfig::where('space_id', $content->space_id)->first(); + + // Determine minimum score: stage config overrides global config + $minScore = (float) ($this->stage['min_score'] + ?? ($config?->pipeline_gate_enabled ? $config->pipeline_gate_min_score : null) + ?? 70.0); + + // Score the content (may use cached score) + $score = $qualityService->score($content, $config ?? null); + + Log::info('QualityGateStageJob: scored content', [ + 'run_id' => $this->run->id, + 'content_id' => $content->id, + 'overall_score' => $score->overall_score, + 'min_score' => $minScore, + ]); + + if ($score->overall_score < $minScore) { + // Pause pipeline — quality gate failed + $this->run->update([ + 'status' => 'paused_for_review', + ]); + $this->run->addStageResult($this->stage['name'], [ + 'success' => false, + 'quality_gate_failed' => true, + 'overall_score' => $score->overall_score, + 'min_score' => $minScore, + 'score_id' => $score->id, + ]); + + Log::info('QualityGateStageJob: pipeline paused — quality below threshold', [ + 'run_id' => $this->run->id, + 'overall_score' => $score->overall_score, + 'min_score' => $minScore, + ]); + + return; + } + + $executor->advance($this->run, [ + 'success' => true, + 'quality_gate_passed' => true, + 'overall_score' => $score->overall_score, + 'min_score' => $minScore, + 'score_id' => $score->id, + ]); + } +} diff --git a/app/Jobs/ScoreContentQualityJob.php b/app/Jobs/ScoreContentQualityJob.php new file mode 100644 index 0000000..76a0bfe --- /dev/null +++ b/app/Jobs/ScoreContentQualityJob.php @@ -0,0 +1,44 @@ +onQueue('quality'); + } + + public function handle(ContentQualityService $service): void + { + $content = Content::findOrFail($this->contentId); + + $config = $this->configId + ? ContentQualityConfig::find($this->configId) + : null; + + $service->score($content, $config); + } +} diff --git a/app/Listeners/AutoScoreOnPublishListener.php b/app/Listeners/AutoScoreOnPublishListener.php new file mode 100644 index 0000000..47eeb7d --- /dev/null +++ b/app/Listeners/AutoScoreOnPublishListener.php @@ -0,0 +1,27 @@ +content; + + $config = ContentQualityConfig::where('space_id', $content->space_id)->first(); + + // Score if: no config (default on) OR config explicitly enables it + if ($config === null || $config->auto_score_on_publish) { + ScoreContentQualityJob::dispatch($content->id); + } + } +} diff --git a/app/Listeners/QualityScoredWebhookListener.php b/app/Listeners/QualityScoredWebhookListener.php new file mode 100644 index 0000000..f72afff --- /dev/null +++ b/app/Listeners/QualityScoredWebhookListener.php @@ -0,0 +1,34 @@ +score; + + $this->dispatcher->dispatch( + 'quality.scored', + $score->space_id, + [ + 'score_id' => $score->id, + 'content_id' => $score->content_id, + 'space_id' => $score->space_id, + 'overall_score' => $score->overall_score, + 'readability_score' => $score->readability_score, + 'seo_score' => $score->seo_score, + 'brand_score' => $score->brand_score, + 'factual_score' => $score->factual_score, + 'engagement_score' => $score->engagement_score, + 'scored_at' => $score->scored_at->toIso8601String(), + ] + ); + } +} diff --git a/app/Models/CompetitorAlert.php b/app/Models/CompetitorAlert.php new file mode 100644 index 0000000..47efd75 --- /dev/null +++ b/app/Models/CompetitorAlert.php @@ -0,0 +1,48 @@ + 'array', + 'is_active' => 'boolean', + 'notify_channels' => 'array', + ]; + + public function events(): HasMany + { + return $this->hasMany(CompetitorAlertEvent::class, 'alert_id'); + } +} diff --git a/app/Models/CompetitorAlertEvent.php b/app/Models/CompetitorAlertEvent.php new file mode 100644 index 0000000..0f1eb3d --- /dev/null +++ b/app/Models/CompetitorAlertEvent.php @@ -0,0 +1,45 @@ + 'array', + 'notified_at' => 'datetime', + ]; + + public function alert(): BelongsTo + { + return $this->belongsTo(CompetitorAlert::class, 'alert_id'); + } + + public function competitorContent(): BelongsTo + { + return $this->belongsTo(CompetitorContentItem::class, 'competitor_content_id'); + } +} diff --git a/app/Models/CompetitorContentItem.php b/app/Models/CompetitorContentItem.php new file mode 100644 index 0000000..35fa820 --- /dev/null +++ b/app/Models/CompetitorContentItem.php @@ -0,0 +1,68 @@ + 'datetime', + 'crawled_at' => 'datetime', + 'metadata' => 'array', + ]; + + public function source(): BelongsTo + { + return $this->belongsTo(CompetitorSource::class, 'source_id'); + } + + public function fingerprint(): MorphOne + { + return $this->morphOne(ContentFingerprint::class, 'fingerprintable'); + } + + public function differentiationAnalyses(): HasMany + { + return $this->hasMany(DifferentiationAnalysis::class, 'competitor_content_id'); + } + + public function alertEvents(): HasMany + { + return $this->hasMany(CompetitorAlertEvent::class, 'competitor_content_id'); + } +} diff --git a/app/Models/CompetitorSource.php b/app/Models/CompetitorSource.php new file mode 100644 index 0000000..a956453 --- /dev/null +++ b/app/Models/CompetitorSource.php @@ -0,0 +1,58 @@ + 'array', + 'is_active' => 'boolean', + 'crawl_interval_minutes' => 'integer', + 'last_crawled_at' => 'datetime', + 'error_count' => 'integer', + ]; + + public function contentItems(): HasMany + { + return $this->hasMany(CompetitorContentItem::class, 'source_id'); + } +} diff --git a/app/Models/ContentFingerprint.php b/app/Models/ContentFingerprint.php new file mode 100644 index 0000000..6a05be5 --- /dev/null +++ b/app/Models/ContentFingerprint.php @@ -0,0 +1,48 @@ + 'array', + 'entities' => 'array', + 'keywords' => 'array', + 'fingerprinted_at' => 'datetime', + ]; + + public function fingerprintable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/ContentQualityConfig.php b/app/Models/ContentQualityConfig.php new file mode 100644 index 0000000..e82bc87 --- /dev/null +++ b/app/Models/ContentQualityConfig.php @@ -0,0 +1,51 @@ + 'array', + 'thresholds' => 'array', + 'enabled_dimensions' => 'array', + 'auto_score_on_publish' => 'boolean', + 'pipeline_gate_enabled' => 'boolean', + 'pipeline_gate_min_score' => 'float', + ]; + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } +} diff --git a/app/Models/ContentQualityScore.php b/app/Models/ContentQualityScore.php new file mode 100644 index 0000000..18099b8 --- /dev/null +++ b/app/Models/ContentQualityScore.php @@ -0,0 +1,82 @@ + $items + */ +class ContentQualityScore extends Model +{ + use HasFactory; + use HasUlids; + + protected $fillable = [ + 'space_id', + 'content_id', + 'content_version_id', + 'overall_score', + 'readability_score', + 'seo_score', + 'brand_score', + 'factual_score', + 'engagement_score', + 'scoring_model', + 'scoring_duration_ms', + 'scored_at', + ]; + + protected $casts = [ + 'overall_score' => 'float', + 'readability_score' => 'float', + 'seo_score' => 'float', + 'brand_score' => 'float', + 'factual_score' => 'float', + 'engagement_score' => 'float', + 'scoring_duration_ms' => 'integer', + 'scored_at' => 'datetime', + ]; + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + public function content(): BelongsTo + { + return $this->belongsTo(Content::class); + } + + public function contentVersion(): BelongsTo + { + return $this->belongsTo(ContentVersion::class); + } + + public function items(): HasMany + { + return $this->hasMany(ContentQualityScoreItem::class, 'score_id'); + } +} diff --git a/app/Models/ContentQualityScoreItem.php b/app/Models/ContentQualityScoreItem.php new file mode 100644 index 0000000..5a44361 --- /dev/null +++ b/app/Models/ContentQualityScoreItem.php @@ -0,0 +1,53 @@ + 'float', + 'metadata' => 'array', + ]; + + public function score(): BelongsTo + { + return $this->belongsTo(ContentQualityScore::class, 'score_id'); + } +} diff --git a/app/Models/DifferentiationAnalysis.php b/app/Models/DifferentiationAnalysis.php new file mode 100644 index 0000000..5c9c22f --- /dev/null +++ b/app/Models/DifferentiationAnalysis.php @@ -0,0 +1,66 @@ + 'float', + 'differentiation_score' => 'float', + 'angles' => 'array', + 'gaps' => 'array', + 'recommendations' => 'array', + 'analyzed_at' => 'datetime', + ]; + + public function competitorContent(): BelongsTo + { + return $this->belongsTo(CompetitorContentItem::class, 'competitor_content_id'); + } + + public function content(): BelongsTo + { + return $this->belongsTo(Content::class, 'content_id'); + } + + public function brief(): BelongsTo + { + return $this->belongsTo(ContentBrief::class, 'brief_id'); + } +} diff --git a/app/Models/PipelineRun.php b/app/Models/PipelineRun.php index 398f81f..eb9aadf 100755 --- a/app/Models/PipelineRun.php +++ b/app/Models/PipelineRun.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -28,6 +29,7 @@ */ class PipelineRun extends Model { + use HasFactory; use HasUlids; protected $fillable = [ diff --git a/app/Pipelines/PipelineExecutor.php b/app/Pipelines/PipelineExecutor.php index 27e33a5..cd9862a 100644 --- a/app/Pipelines/PipelineExecutor.php +++ b/app/Pipelines/PipelineExecutor.php @@ -119,10 +119,17 @@ private function dispatchCoreStage(PipelineRun $run, array $stage): void 'ai_review' => config('numen.queues.review'), 'ai_illustrate' => config('numen.queues.generation'), 'auto_publish' => config('numen.queues.publishing'), + 'quality_gate' => 'ai-pipeline', 'human_gate' => null, // No job — pauses for human default => 'default', }; + if ($stage['type'] === 'quality_gate') { + \App\Jobs\QualityGateStageJob::dispatch($run, $stage)->onQueue($queue); + + return; + } + if ($stage['type'] === 'human_gate') { $run->update(['status' => 'paused_for_review']); // TODO: Notify humans (email, webhook, OpenClaw message) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 76cb0ae..fc34123 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -26,6 +26,13 @@ use App\Services\AI\Providers\OpenAIProvider; use App\Services\Authorization\PermissionRegistrar; use App\Services\AuthorizationService; +use App\Services\Quality\Analyzers\BrandConsistencyAnalyzer; +use App\Services\Quality\Analyzers\EngagementPredictionAnalyzer; +use App\Services\Quality\Analyzers\FactualAccuracyAnalyzer; +use App\Services\Quality\ContentQualityService; +use App\Services\Quality\QualityTrendAggregator; +use App\Services\Quality\ReadabilityAnalyzer; +use App\Services\Quality\SeoAnalyzer; use App\Services\Search\ConversationalDriver; use App\Services\Search\EmbeddingService; use App\Services\Search\InstantSearchDriver; @@ -112,6 +119,16 @@ public function register(): void $app->make(SearchAnalyticsRecorder::class), $app->make(SearchCapabilityDetector::class), )); + + // ── Quality Scoring layer ───────────────────────────────────────── + $this->app->singleton(ContentQualityService::class, fn ($app) => new ContentQualityService( + $app->make(ReadabilityAnalyzer::class), + $app->make(SeoAnalyzer::class), + $app->make(BrandConsistencyAnalyzer::class), + $app->make(FactualAccuracyAnalyzer::class), + $app->make(EngagementPredictionAnalyzer::class), + )); + $this->app->singleton(QualityTrendAggregator::class); } public function boot(): void diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 163ecfc..1666d96 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -14,7 +14,14 @@ class EventServiceProvider extends ServiceProvider * * @var array> */ - protected $listen = []; + protected $listen = [ + \App\Events\Content\ContentPublished::class => [ + \App\Listeners\AutoScoreOnPublishListener::class, + ], + \App\Events\Quality\ContentQualityScored::class => [ + \App\Listeners\QualityScoredWebhookListener::class, + ], + ]; public function boot(): void { diff --git a/app/Services/Quality/Analyzers/BrandConsistencyAnalyzer.php b/app/Services/Quality/Analyzers/BrandConsistencyAnalyzer.php new file mode 100644 index 0000000..1abd877 --- /dev/null +++ b/app/Services/Quality/Analyzers/BrandConsistencyAnalyzer.php @@ -0,0 +1,231 @@ +currentVersion ?? $content->draftVersion; + if ($version === null) { + return QualityDimensionResult::make(0, [ + ['type' => 'error', 'message' => 'No content version available.'], + ]); + } + + $text = $this->extractText($version); + if (trim($text) === '') { + return QualityDimensionResult::make(0, [ + ['type' => 'error', 'message' => 'Content body is empty.'], + ]); + } + + $persona = $this->resolvePersona($content); + + if ($persona === null) { + return $this->heuristicFallback($text, 'No persona assigned — using heuristic fallback.'); + } + + try { + return $this->analyzWithLLM($text, $persona->system_prompt, $persona->voice_guidelines); + } catch (AllProvidersFailedException $e) { + Log::warning('BrandConsistencyAnalyzer: LLM unavailable, using heuristic fallback.', [ + 'error' => $e->getMessage(), + ]); + + return $this->heuristicFallback($text, 'LLM unavailable — using heuristic fallback.'); + } catch (\Throwable $e) { + Log::warning('BrandConsistencyAnalyzer: unexpected error, using heuristic fallback.', [ + 'error' => $e->getMessage(), + ]); + + return $this->heuristicFallback($text, 'Analysis error — using heuristic fallback.'); + } + } + + /** @param array|null $voiceGuidelines */ + private function analyzWithLLM(string $text, string $systemPrompt, ?array $voiceGuidelines): QualityDimensionResult + { + $context = $this->buildPersonaContext($systemPrompt, $voiceGuidelines); + $prompts = config('quality-prompts.brand_consistency_prompt'); + + $userMessage = str_replace( + ['{{content}}', '{{context}}'], + [$text, $context], + (string) $prompts['user'], + ); + + $response = $this->llm->complete([ + 'model' => config('numen.quality.llm_model', 'claude-haiku-4-5-20251001'), + 'system' => (string) $prompts['system'], + 'messages' => [['role' => 'user', 'content' => $userMessage]], + 'max_tokens' => 1024, + 'temperature' => 0.2, + '_purpose' => 'quality_brand_consistency', + ]); + + /** @var array $data */ + $data = json_decode($response->content, true) ?? []; + + return $this->buildResultFromLLMData($data); + } + + /** @param array $data */ + private function buildResultFromLLMData(array $data): QualityDimensionResult + { + $score = isset($data['score']) ? (float) $data['score'] : 50.0; + $toneScore = isset($data['tone_consistency']) ? (float) $data['tone_consistency'] : $score; + $vocabScore = isset($data['vocabulary_alignment']) ? (float) $data['vocabulary_alignment'] : $score; + $voiceScore = isset($data['brand_voice_adherence']) ? (float) $data['brand_voice_adherence'] : $score; + + $items = []; + $deviations = isset($data['deviations']) && is_array($data['deviations']) ? $data['deviations'] : []; + + foreach ($deviations as $deviation) { + if (! is_array($deviation)) { + continue; + } + $item = [ + 'type' => (string) ($deviation['type'] ?? 'style'), + 'message' => (string) ($deviation['message'] ?? ''), + ]; + if (! empty($deviation['suggestion'])) { + $item['suggestion'] = (string) $deviation['suggestion']; + } + $items[] = $item; + } + + $metadata = [ + 'tone_consistency' => $toneScore, + 'vocabulary_alignment' => $vocabScore, + 'brand_voice_adherence' => $voiceScore, + 'summary' => (string) ($data['summary'] ?? ''), + 'source' => 'llm', + ]; + + return QualityDimensionResult::make($score, $items, $metadata); + } + + private function heuristicFallback(string $text, string $reason): QualityDimensionResult + { + $items = [ + [ + 'type' => 'info', + 'message' => $reason, + ], + ]; + $wordCount = str_word_count($text); + $sentenceCount = max(1, preg_match_all('/[.!?]+/', $text, $_matches)); + $avgWordsPerSentence = $wordCount / $sentenceCount; + + // Basic heuristics: penalise very long sentences and lack of structure + $score = 70.0; + + if ($avgWordsPerSentence > 35) { + $score -= 15; + $items[] = [ + 'type' => 'warning', + 'message' => 'Sentences are very long on average, which may hurt brand voice clarity.', + 'suggestion' => 'Aim for an average of 15–25 words per sentence.', + ]; + } elseif ($avgWordsPerSentence > 25) { + $score -= 7; + $items[] = [ + 'type' => 'warning', + 'message' => 'Sentences are slightly long on average.', + 'suggestion' => 'Consider breaking up complex sentences.', + ]; + } + + // Check for first-person consistency + $firstPerson = preg_match_all('/\bI\b|\bwe\b|\bour\b|\bmy\b/i', $text, $_m2); + $thirdPerson = preg_match_all('/\bthey\b|\btheir\b|\bhe\b|\bshe\b/i', $text, $_m3); + + if ($firstPerson > 0 && $thirdPerson > 0 && abs($firstPerson - $thirdPerson) > 3) { + $score -= 10; + $items[] = [ + 'type' => 'warning', + 'message' => 'Mixed first-person and third-person voice detected.', + 'suggestion' => 'Choose a consistent point of view throughout the content.', + ]; + } + + return QualityDimensionResult::make($score, $items, [ + 'source' => 'heuristic', + 'avg_words_per_sentence' => round($avgWordsPerSentence, 1), + ]); + } + + /** @param array|null $voiceGuidelines */ + private function buildPersonaContext(string $systemPrompt, ?array $voiceGuidelines): string + { + $parts = ["## Persona System Prompt\n{$systemPrompt}"]; + + if (! empty($voiceGuidelines)) { + $parts[] = "## Voice Guidelines\n".json_encode($voiceGuidelines, JSON_PRETTY_PRINT); + } + + return implode("\n\n", $parts); + } + + private function resolvePersona(Content $content): ?\App\Models\Persona + { + // Attempt to find persona via a recent pipeline run. + // ContentPipeline does not formally declare a persona relation yet; + // pipelines may reference a Persona via dynamic property in future iterations. + /** @var \App\Models\PipelineRun|null $latestRun */ + $latestRun = $content->pipelineRuns()->with('pipeline')->latest()->first(); + + if ($latestRun === null) { + return null; + } + + $pipeline = $latestRun->pipeline; + + /** @var object{persona?: \App\Models\Persona} $pipeline */ + return isset($pipeline->persona) ? $pipeline->persona : null; + } + + private function extractText(ContentVersion $version): string + { + return strip_tags((string) $version->body); + } +} diff --git a/app/Services/Quality/Analyzers/EngagementPredictionAnalyzer.php b/app/Services/Quality/Analyzers/EngagementPredictionAnalyzer.php new file mode 100644 index 0000000..e93033a --- /dev/null +++ b/app/Services/Quality/Analyzers/EngagementPredictionAnalyzer.php @@ -0,0 +1,255 @@ +currentVersion ?? $content->draftVersion; + if ($version === null) { + return QualityDimensionResult::make(0, [ + ['type' => 'error', 'message' => 'No content version available.'], + ]); + } + + $text = $this->extractText($version); + if (trim($text) === '') { + return QualityDimensionResult::make(0, [ + ['type' => 'error', 'message' => 'Content body is empty.'], + ]); + } + + $context = $this->buildContext($content); + + try { + return $this->analyzeWithLLM($text, $context); + } catch (AllProvidersFailedException $e) { + Log::warning('EngagementPredictionAnalyzer: LLM unavailable, using heuristic fallback.', [ + 'error' => $e->getMessage(), + ]); + + return $this->heuristicFallback($text, 'LLM unavailable — using heuristic fallback.'); + } catch (\Throwable $e) { + Log::warning('EngagementPredictionAnalyzer: unexpected error, using heuristic fallback.', [ + 'error' => $e->getMessage(), + ]); + + return $this->heuristicFallback($text, 'Analysis error — using heuristic fallback.'); + } + } + + private function analyzeWithLLM(string $text, string $context): QualityDimensionResult + { + $prompts = config('quality-prompts.engagement_prediction_prompt'); + + $userMessage = str_replace( + ['{{content}}', '{{context}}'], + [$text, $context], + (string) $prompts['user'], + ); + + $response = $this->llm->complete([ + 'model' => config('numen.quality.llm_model', 'claude-haiku-4-5-20251001'), + 'system' => (string) $prompts['system'], + 'messages' => [['role' => 'user', 'content' => $userMessage]], + 'max_tokens' => 1200, + 'temperature' => 0.3, + '_purpose' => 'quality_engagement_prediction', + ]); + + /** @var array $data */ + $data = json_decode($response->content, true) ?? []; + + return $this->buildResultFromLLMData($data); + } + + /** @param array $data */ + private function buildResultFromLLMData(array $data): QualityDimensionResult + { + $score = isset($data['score']) ? (float) $data['score'] : 50.0; + + $headlineScore = isset($data['headline_strength']) ? (float) $data['headline_strength'] : $score; + $hookScore = isset($data['hook_quality']) ? (float) $data['hook_quality'] : $score; + $emotionScore = isset($data['emotional_resonance']) ? (float) $data['emotional_resonance'] : $score; + $ctaScore = isset($data['cta_effectiveness']) ? (float) $data['cta_effectiveness'] : $score; + $shareScore = isset($data['shareability']) ? (float) $data['shareability'] : $score; + + $items = []; + $factors = isset($data['factors']) && is_array($data['factors']) ? $data['factors'] : []; + + foreach ($factors as $factor) { + if (! is_array($factor)) { + continue; + } + $factorScore = isset($factor['score']) ? (float) $factor['score'] : 50.0; + if ($factorScore < 60) { + $item = [ + 'type' => $factorScore < 40 ? 'error' : 'warning', + 'message' => sprintf( + '[%s] %s', + (string) ($factor['factor'] ?? 'Unknown'), + (string) ($factor['observation'] ?? ''), + ), + ]; + if (! empty($factor['suggestion'])) { + $item['suggestion'] = (string) $factor['suggestion']; + } + $items[] = $item; + } + } + + $metadata = [ + 'headline_strength' => $headlineScore, + 'hook_quality' => $hookScore, + 'emotional_resonance' => $emotionScore, + 'cta_effectiveness' => $ctaScore, + 'shareability' => $shareScore, + 'summary' => (string) ($data['summary'] ?? ''), + 'source' => 'llm', + ]; + + return QualityDimensionResult::make($score, $items, $metadata); + } + + private function heuristicFallback(string $text, string $reason): QualityDimensionResult + { + $items = [ + [ + 'type' => 'info', + 'message' => $reason, + ], + ]; + + $score = 55.0; + $metadata = ['source' => 'heuristic']; + + // Headline strength: first line length and power words + $lines = array_filter(explode("\n", $text)); + $headline = trim((string) (reset($lines) ?: '')); + $headlineWords = str_word_count($headline); + $headlineScore = 50.0; + + if ($headlineWords >= 6 && $headlineWords <= 12) { + $headlineScore = 70.0; + } elseif ($headlineWords < 4) { + $headlineScore = 35.0; + $items[] = [ + 'type' => 'warning', + 'message' => 'Headline is very short and may not attract readers.', + 'suggestion' => 'Aim for 6–12 words in your headline.', + ]; + } elseif ($headlineWords > 16) { + $headlineScore = 45.0; + $items[] = [ + 'type' => 'warning', + 'message' => 'Headline is long and may lose reader attention.', + 'suggestion' => 'Trim to 6–12 words for best engagement.', + ]; + } + + $powerWords = ['how', 'why', 'best', 'top', 'proven', 'secret', 'ultimate', 'easy', 'free', 'now', 'new']; + $headlineLower = strtolower($headline); + $powerWordHits = count(array_filter($powerWords, fn ($w) => str_contains($headlineLower, $w))); + if ($powerWordHits > 0) { + $headlineScore = min(100, $headlineScore + 10); + } + + // Hook quality: first 2 sentences + $sentences = preg_split('/(?<=[.!?])\s+/', $text) ?: []; + $hook = implode(' ', array_slice($sentences, 0, 2)); + $hookScore = 50.0; + if (str_contains($hook, '?') || preg_match('/\b(imagine|discover|did you know|are you)\b/i', $hook)) { + $hookScore = 72.0; + } + + // CTA detection + $hasCta = (bool) preg_match('/\b(click|sign up|subscribe|learn more|get started|download|try|contact|buy|shop|start|join)\b/i', $text); + $ctaScore = $hasCta ? 70.0 : 30.0; + if (! $hasCta) { + $items[] = [ + 'type' => 'warning', + 'message' => 'No clear call-to-action detected.', + 'suggestion' => 'Add a CTA to guide readers on what to do next.', + ]; + } + + // Shareability: emotional keywords + $emotionalWords = ['love', 'hate', 'amazing', 'shocking', 'incredible', 'inspiring', 'surprising', 'powerful', 'beautiful', 'terrible']; + $textLower = strtolower($text); + $emotionHits = count(array_filter($emotionalWords, fn ($w) => str_contains($textLower, $w))); + $emotionScore = min(100, 40 + ($emotionHits * 8)); + $shareScore = (($headlineScore + $hookScore + $emotionScore) / 3); + + $score = ($headlineScore * 0.20) + ($hookScore * 0.20) + ($emotionScore * 0.20) + ($ctaScore * 0.20) + ($shareScore * 0.20); + + $metadata['headline_strength'] = round($headlineScore, 1); + $metadata['hook_quality'] = round($hookScore, 1); + $metadata['emotional_resonance'] = round($emotionScore, 1); + $metadata['cta_effectiveness'] = round($ctaScore, 1); + $metadata['shareability'] = round($shareScore, 1); + + return QualityDimensionResult::make($score, $items, $metadata); + } + + private function buildContext(Content $content): string + { + $parts = []; + + if ($content->locale) { + $parts[] = 'Locale: '.$content->locale; + } + + if ($content->contentType !== null) { + $parts[] = 'Content type: '.$content->contentType->name; + } + + return empty($parts) ? 'No additional context.' : implode("\n", $parts); + } + + private function extractText(ContentVersion $version): string + { + return strip_tags((string) $version->body); + } +} diff --git a/app/Services/Quality/Analyzers/FactualAccuracyAnalyzer.php b/app/Services/Quality/Analyzers/FactualAccuracyAnalyzer.php new file mode 100644 index 0000000..0f02ed4 --- /dev/null +++ b/app/Services/Quality/Analyzers/FactualAccuracyAnalyzer.php @@ -0,0 +1,235 @@ +currentVersion ?? $content->draftVersion; + if ($version === null) { + return QualityDimensionResult::make(0, [ + ['type' => 'error', 'message' => 'No content version available.'], + ]); + } + + $text = $this->extractText($version); + if (trim($text) === '') { + return QualityDimensionResult::make(0, [ + ['type' => 'error', 'message' => 'Content body is empty.'], + ]); + } + + $graphContext = $this->buildGraphContext($content); + + try { + return $this->analyzeWithLLM($text, $graphContext); + } catch (AllProvidersFailedException $e) { + Log::warning('FactualAccuracyAnalyzer: LLM unavailable, using heuristic fallback.', [ + 'error' => $e->getMessage(), + ]); + + return $this->heuristicFallback($text, 'LLM unavailable — using heuristic fallback.'); + } catch (\Throwable $e) { + Log::warning('FactualAccuracyAnalyzer: unexpected error, using heuristic fallback.', [ + 'error' => $e->getMessage(), + ]); + + return $this->heuristicFallback($text, 'Analysis error — using heuristic fallback.'); + } + } + + private function analyzeWithLLM(string $text, string $graphContext): QualityDimensionResult + { + $prompts = config('quality-prompts.factual_accuracy_prompt'); + + $userMessage = str_replace( + ['{{content}}', '{{context}}'], + [$text, $graphContext], + (string) $prompts['user'], + ); + + $response = $this->llm->complete([ + 'model' => config('numen.quality.llm_model', 'claude-haiku-4-5-20251001'), + 'system' => (string) $prompts['system'], + 'messages' => [['role' => 'user', 'content' => $userMessage]], + 'max_tokens' => 1500, + 'temperature' => 0.1, + '_purpose' => 'quality_factual_accuracy', + ]); + + /** @var array $data */ + $data = json_decode($response->content, true) ?? []; + + return $this->buildResultFromLLMData($data); + } + + /** @param array $data */ + private function buildResultFromLLMData(array $data): QualityDimensionResult + { + $score = isset($data['score']) ? (float) $data['score'] : 50.0; + $hasCitations = isset($data['has_source_citations']) ? (bool) $data['has_source_citations'] : false; + $verifiableRatio = isset($data['verifiable_claims_ratio']) ? (float) $data['verifiable_claims_ratio'] : 0.5; + + $items = []; + $claims = isset($data['claims']) && is_array($data['claims']) ? $data['claims'] : []; + + foreach ($claims as $claim) { + if (! is_array($claim)) { + continue; + } + $isVerifiable = isset($claim['verifiable']) ? (bool) $claim['verifiable'] : true; + $issue = ! empty($claim['issue']) ? (string) $claim['issue'] : null; + + if (! $isVerifiable || $issue !== null) { + $item = [ + 'type' => $isVerifiable ? 'warning' : 'error', + 'message' => $issue ?? ('Unverifiable claim: '.(string) ($claim['claim'] ?? '')), + ]; + if (! empty($claim['suggestion'])) { + $item['suggestion'] = (string) $claim['suggestion']; + } + $items[] = $item; + } + } + + if (! $hasCitations && count($claims) > 0) { + $items[] = [ + 'type' => 'warning', + 'message' => 'No source citations found. Adding references improves credibility.', + 'suggestion' => 'Include links or references to authoritative sources.', + ]; + } + + $metadata = [ + 'verifiable_claims_ratio' => $verifiableRatio, + 'has_source_citations' => $hasCitations, + 'total_claims' => count($claims), + 'summary' => (string) ($data['summary'] ?? ''), + 'source' => 'llm', + ]; + + return QualityDimensionResult::make($score, $items, $metadata); + } + + private function heuristicFallback(string $text, string $reason): QualityDimensionResult + { + $items = [ + [ + 'type' => 'info', + 'message' => $reason, + ], + ]; + + $score = 60.0; + + // Check for source citations (URLs, "according to", "source:", etc.) + $hasCitations = (bool) preg_match('/https?:\/\/|according to|source:|cited|reference/i', $text); + if (! $hasCitations) { + $score -= 10; + $items[] = [ + 'type' => 'warning', + 'message' => 'No source citations or references detected.', + 'suggestion' => 'Add links or references to authoritative sources.', + ]; + } + + // Check for numbers/dates (common factual claims) + $numberMatches = preg_match_all('/\b\d{4}\b|\d+%|\$[\d,]+|\b\d+\s*(million|billion|thousand)\b/i', $text, $_m); + if ($numberMatches > 5) { + $items[] = [ + 'type' => 'info', + 'message' => "Detected {$numberMatches} numeric claims. Consider verifying each with a source.", + ]; + if (! $hasCitations) { + $score -= 10; + } + } + + // Check for named entities (capitalized multi-word phrases) + $entityMatches = preg_match_all('/\b[A-Z][a-z]+ [A-Z][a-z]+\b/', $text, $_m2); + if ($entityMatches > 3 && ! $hasCitations) { + $items[] = [ + 'type' => 'warning', + 'message' => "Detected {$entityMatches} potential named entities without citations.", + 'suggestion' => 'Verify named entities and add references where appropriate.', + ]; + } + + return QualityDimensionResult::make($score, $items, [ + 'source' => 'heuristic', + 'has_source_citations' => $hasCitations, + 'numeric_claims_found' => $numberMatches, + 'named_entities_found' => $entityMatches, + ]); + } + + private function buildGraphContext(Content $content): string + { + try { + /** @var ContentGraphNode|null $node */ + $node = ContentGraphNode::where('content_id', $content->id)->first(); + + if ($node === null) { + return 'No Knowledge Graph data available for this content.'; + } + + $labels = $node->entity_labels ?? []; + $meta = $node->node_metadata ?? []; + + $parts = ['## Knowledge Graph Entities']; + + if (! empty($labels)) { + $parts[] = 'Entity labels: '.implode(', ', $labels); + } + + if (! empty($meta)) { + $parts[] = 'Node metadata: '.json_encode($meta, JSON_PRETTY_PRINT); + } + + return implode("\n", $parts); + } catch (\Throwable) { + return 'Knowledge Graph unavailable.'; + } + } + + private function extractText(ContentVersion $version): string + { + return strip_tags((string) $version->body); + } +} diff --git a/app/Services/Quality/ContentQualityService.php b/app/Services/Quality/ContentQualityService.php new file mode 100644 index 0000000..d7e5d39 --- /dev/null +++ b/app/Services/Quality/ContentQualityService.php @@ -0,0 +1,167 @@ + */ + private array $analyzers; + + public function __construct( + ReadabilityAnalyzer $readabilityAnalyzer, + SeoAnalyzer $seoAnalyzer, + BrandConsistencyAnalyzer $brandConsistencyAnalyzer, + FactualAccuracyAnalyzer $factualAccuracyAnalyzer, + EngagementPredictionAnalyzer $engagementPredictionAnalyzer, + ) { + $this->analyzers = [ + 'readability' => $readabilityAnalyzer, + 'seo' => $seoAnalyzer, + 'brand_consistency' => $brandConsistencyAnalyzer, + 'factual_accuracy' => $factualAccuracyAnalyzer, + 'engagement_prediction' => $engagementPredictionAnalyzer, + ]; + } + + /** Score a piece of content and persist the result. */ + public function score(Content $content, ?ContentQualityConfig $config = null): ContentQualityScore + { + $cacheKey = "quality:content:{$content->id}"; + + // Return cached result if available + $cached = Cache::get($cacheKey); + if ($cached instanceof ContentQualityScore) { + return $cached; + } + + $startMs = (int) round(microtime(true) * 1000); + + // Determine enabled dimensions and weights + $enabledDimensions = $config !== null ? $config->enabled_dimensions : array_keys($this->analyzers); + $dimensionWeights = $config !== null ? $config->dimension_weights : $this->defaultWeights(); + + // Run enabled analyzers + /** @var array $results */ + $results = []; + foreach ($this->analyzers as $dimension => $analyzer) { + if (! in_array($dimension, $enabledDimensions, true)) { + continue; + } + $results[$dimension] = $analyzer->analyze($content); + } + + // Calculate weighted overall score + $overallScore = $this->calculateWeightedScore($results, $dimensionWeights); + + $durationMs = (int) round(microtime(true) * 1000) - $startMs; + + // Persist score record + $qualityScore = ContentQualityScore::create([ + 'space_id' => $content->space_id, + 'content_id' => $content->id, + 'content_version_id' => $content->current_version_id, + 'overall_score' => $overallScore, + 'readability_score' => isset($results['readability']) ? $results['readability']->getScore() : null, + 'seo_score' => isset($results['seo']) ? $results['seo']->getScore() : null, + 'brand_score' => isset($results['brand_consistency']) ? $results['brand_consistency']->getScore() : null, + 'factual_score' => isset($results['factual_accuracy']) ? $results['factual_accuracy']->getScore() : null, + 'engagement_score' => isset($results['engagement_prediction']) ? $results['engagement_prediction']->getScore() : null, + 'scoring_model' => 'content-quality-v1', + 'scoring_duration_ms' => $durationMs, + 'scored_at' => now(), + ]); + + // Persist individual score items (findings) + foreach ($results as $dimension => $result) { + foreach ($result->getItems() as $item) { + ContentQualityScoreItem::create([ + 'score_id' => $qualityScore->id, + 'dimension' => $dimension, + 'category' => $item['type'] ?? 'general', + 'rule_key' => $item['rule_key'] ?? $item['type'] ?? 'general', + 'label' => $item['label'] ?? $item['message'], + 'severity' => $item['severity'] ?? 'info', + 'score_impact' => $item['score_impact'] ?? 0.0, + 'message' => $item['message'], + 'suggestion' => $item['suggestion'] ?? null, + 'metadata' => $item['meta'] ?? null, + ]); + } + } + + // Fire event + Event::dispatch(new ContentQualityScored($qualityScore)); + + // Cache for 1 hour + Cache::put($cacheKey, $qualityScore, 3600); + + return $qualityScore; + } + + /** Invalidate the cache for a specific content item. */ + public function invalidate(Content $content): void + { + Cache::forget("quality:content:{$content->id}"); + } + + /** + * Calculate weighted average score across dimensions. + * + * @param array $results + * @param array $weights + */ + private function calculateWeightedScore(array $results, array $weights): float + { + if (empty($results)) { + return 0.0; + } + + $totalWeight = 0.0; + $weightedSum = 0.0; + + foreach ($results as $dimension => $result) { + $weight = (float) ($weights[$dimension] ?? 0.0); + if ($weight <= 0.0) { + continue; + } + $weightedSum += $result->getScore() * $weight; + $totalWeight += $weight; + } + + if ($totalWeight <= 0.0) { + // Equal-weight fallback + $scores = array_map(fn (QualityDimensionResult $r) => $r->getScore(), $results); + + return array_sum($scores) / count($scores); + } + + return round($weightedSum / $totalWeight, 2); + } + + /** + * Default dimension weights (equal across all five dimensions). + * + * @return array + */ + private function defaultWeights(): array + { + return [ + 'readability' => 0.20, + 'seo' => 0.20, + 'brand_consistency' => 0.20, + 'factual_accuracy' => 0.20, + 'engagement_prediction' => 0.20, + ]; + } +} diff --git a/app/Services/Quality/Contracts/QualityAnalyzerContract.php b/app/Services/Quality/Contracts/QualityAnalyzerContract.php new file mode 100644 index 0000000..7e744bd --- /dev/null +++ b/app/Services/Quality/Contracts/QualityAnalyzerContract.php @@ -0,0 +1,24 @@ +}> $items + * @param array $metadata + */ + public function __construct( + private readonly float $score, + private readonly array $items = [], + private readonly array $metadata = [], + ) {} + + /** + * @param array}> $items + * @param array $metadata + */ + public static function make(float $score, array $items = [], array $metadata = []): self + { + return new self( + score: max(0.0, min(100.0, $score)), + items: $items, + metadata: $metadata, + ); + } + + public function getScore(): float + { + return $this->score; + } + + /** @return array}> */ + public function getItems(): array + { + return $this->items; + } + + /** @return array */ + public function getMetadata(): array + { + return $this->metadata; + } + + public function countByType(string $type): int + { + return count(array_filter($this->items, fn (array $i) => $i['type'] === $type)); + } +} diff --git a/app/Services/Quality/QualityTrendAggregator.php b/app/Services/Quality/QualityTrendAggregator.php new file mode 100644 index 0000000..158d49d --- /dev/null +++ b/app/Services/Quality/QualityTrendAggregator.php @@ -0,0 +1,120 @@ +> + */ + public function getSpaceTrends(Space $space, Carbon $from, Carbon $to): array + { + /** @var \Illuminate\Support\Collection $rows */ + $rows = DB::table('content_quality_scores') + ->select([ + DB::raw('DATE(scored_at) as score_date'), + DB::raw('AVG(overall_score) as avg_overall'), + DB::raw('AVG(readability_score) as avg_readability'), + DB::raw('AVG(seo_score) as avg_seo'), + DB::raw('AVG(brand_score) as avg_brand'), + DB::raw('AVG(factual_score) as avg_factual'), + DB::raw('AVG(engagement_score) as avg_engagement'), + DB::raw('COUNT(*) as total_scored'), + ]) + ->where('space_id', $space->id) + ->whereBetween('scored_at', [$from->startOfDay(), $to->endOfDay()]) + ->groupBy(DB::raw('DATE(scored_at)')) + ->orderBy('score_date') + ->get(); + + $trends = []; + foreach ($rows as $row) { + $trends[(string) $row->score_date] = [ + 'overall' => $row->avg_overall !== null ? round((float) $row->avg_overall, 2) : null, + 'readability' => $row->avg_readability !== null ? round((float) $row->avg_readability, 2) : null, + 'seo' => $row->avg_seo !== null ? round((float) $row->avg_seo, 2) : null, + 'brand' => $row->avg_brand !== null ? round((float) $row->avg_brand, 2) : null, + 'factual' => $row->avg_factual !== null ? round((float) $row->avg_factual, 2) : null, + 'engagement' => $row->avg_engagement !== null ? round((float) $row->avg_engagement, 2) : null, + 'total' => (int) $row->total_scored, + ]; + } + + return $trends; + } + + /** + * Return top-scoring content in a space, ordered by overall_score descending. + * Only the most recent score per content item is considered. + * + * @return Collection + */ + public function getSpaceLeaderboard(Space $space, int $limit = 10): Collection + { + // Subquery: get the latest score_id per content_id + $latestIds = DB::table('content_quality_scores as cqs_inner') + ->select(DB::raw('MAX(id) as latest_id')) + ->where('space_id', $space->id) + ->groupBy('content_id'); + + return ContentQualityScore::with('content') + ->whereIn('id', $latestIds->pluck('latest_id')) + ->orderByDesc('overall_score') + ->limit($limit) + ->get(); + } + + /** + * Return histogram distribution data for each dimension within a space. + * Buckets: 0-10, 10-20, ..., 90-100. + * + * @return array> + */ + public function getDimensionDistribution(Space $space): array + { + $dimensions = [ + 'overall' => 'overall_score', + 'readability' => 'readability_score', + 'seo' => 'seo_score', + 'brand' => 'brand_score', + 'factual' => 'factual_score', + 'engagement' => 'engagement_score', + ]; + + $distribution = []; + + foreach ($dimensions as $label => $column) { + // Initialise all buckets + $buckets = []; + for ($i = 0; $i < 10; $i++) { + $buckets[($i * 10).'-'.(($i + 1) * 10)] = 0; + } + + $scores = DB::table('content_quality_scores') + ->select($column) + ->where('space_id', $space->id) + ->whereNotNull($column) + ->pluck($column); + + foreach ($scores as $score) { + $bucketIndex = min((int) floor((float) $score / 10), 9); + $key = ($bucketIndex * 10).'-'.(($bucketIndex + 1) * 10); + $buckets[$key]++; + } + + $distribution[$label] = $buckets; + } + + return $distribution; + } +} diff --git a/app/Services/Quality/ReadabilityAnalyzer.php b/app/Services/Quality/ReadabilityAnalyzer.php new file mode 100644 index 0000000..06ffda3 --- /dev/null +++ b/app/Services/Quality/ReadabilityAnalyzer.php @@ -0,0 +1,277 @@ +currentVersion ?? $content->draftVersion; + if ($version === null) { + return QualityDimensionResult::make(0, [['type' => 'error', 'message' => 'No content version available.']]); + } + $text = $this->extractPlainText((string) $version->body); + if (trim($text) === '') { + return QualityDimensionResult::make(0, [['type' => 'error', 'message' => 'Content body is empty.']]); + } + $items = []; + $words = $this->tokenizeWords($text); + $sentences = $this->splitSentences($text); + $paragraphs = $this->splitParagraphs($text); + $wordCount = count($words); + $sentenceCount = max(1, count($sentences)); + $syllableCount = array_sum(array_map([$this, 'countSyllables'], $words)); + $flesch = $this->fleschReadingEase($wordCount, $sentenceCount, $syllableCount); + $fleschScore = $this->scoreFleschReading($flesch, $items); + $sentScore = $this->scoreSentenceLengths($sentences, $items); + $paraScore = $this->scoreParagraphStructure($paragraphs, $items); + $passScore = $this->scorePassiveVoice($sentences, $items); + $total = ($fleschScore * 0.35) + ($sentScore * 0.25) + ($paraScore * 0.25) + ($passScore * 0.15); + $metadata = [ + 'word_count' => $wordCount, + 'sentence_count' => $sentenceCount, + 'paragraph_count' => count($paragraphs), + 'syllable_count' => $syllableCount, + 'flesch_score' => round($flesch, 1), + 'avg_sentence_len' => $sentenceCount > 0 ? round($wordCount / $sentenceCount, 1) : 0, + ]; + + return QualityDimensionResult::make(round($total, 2), $items, $metadata); + } + + public function getDimension(): string + { + return self::DIMENSION; + } + + public function getWeight(): float + { + return self::WEIGHT; + } + + private function extractPlainText(string $html): string + { + $text = strip_tags($html); + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = (string) preg_replace('/\s+/', ' ', $text); + + return trim($text); + } + + /** @return string[] */ + private function tokenizeWords(string $text): array + { + preg_match_all('/\b[a-zA-Z\x27]+\b/', $text, $matches); + + return $matches[0]; + } + + /** @return string[] */ + private function splitSentences(string $text): array + { + $raw = preg_split('/(?<=[.!?])\s+/', trim($text), -1, PREG_SPLIT_NO_EMPTY); + + return is_array($raw) ? array_values(array_filter($raw, fn ($s) => trim($s) !== '')) : []; + } + + /** @return string[] */ + private function splitParagraphs(string $text): array + { + $raw = preg_split('/\n{2,}/', $text, -1, PREG_SPLIT_NO_EMPTY); + + return is_array($raw) ? array_values(array_filter($raw, fn ($p) => trim($p) !== '')) : []; + } + + private function fleschReadingEase(int $words, int $sentences, int $syllables): float + { + if ($words === 0 || $sentences === 0) { + return 0.0; + } + + return 206.835 - (1.015 * ($words / $sentences)) - (84.6 * ($syllables / $words)); + } + + private function countSyllables(string $word): int + { + $word = strtolower($word); + $word = rtrim($word, 'e'); + if ($word === '') { + return 1; + } + $count = preg_match_all('/[aeiouy]+/', $word); + + return max(1, (int) $count); + } + + /** @param array}> $items */ + private function scoreFleschReading(float $flesch, array &$items): float + { + $r = round($flesch, 1); + if ($flesch >= self::FLESCH_VERY_EASY) { + $items[] = ['type' => 'info', 'message' => "Flesch: {$r} — very easy."]; + + return 100.0; + } + if ($flesch >= self::FLESCH_EASY) { + $items[] = ['type' => 'info', 'message' => "Flesch: {$r} — easy."]; + + return 90.0; + } + if ($flesch >= self::FLESCH_STANDARD) { + $items[] = ['type' => 'info', 'message' => "Flesch: {$r} — standard readability."]; + + return 75.0; + } + if ($flesch >= self::FLESCH_FAIRLY_HARD) { + $items[] = ['type' => 'warning', 'message' => "Flesch: {$r} — fairly difficult.", 'suggestion' => 'Simplify sentences.']; + + return 50.0; + } + if ($flesch >= self::FLESCH_HARD) { + $items[] = ['type' => 'warning', 'message' => "Flesch: {$r} — difficult.", 'suggestion' => 'Use shorter sentences and words.']; + + return 25.0; + } + $items[] = ['type' => 'error', 'message' => "Flesch: {$r} — very difficult.", 'suggestion' => 'Significantly simplify language.']; + + return 0.0; + } + + /** + * @param string[] $sentences + * @param array}> $items + */ + private function scoreSentenceLengths(array $sentences, array &$items): float + { + if (count($sentences) === 0) { + return 100.0; + } + $longCount = 0; + $veryLongCount = 0; + foreach ($sentences as $sentence) { + $wc = count($this->tokenizeWords($sentence)); + if ($wc > self::SENTENCE_VERY_LONG) { + $veryLongCount++; + } elseif ($wc > self::SENTENCE_LONG) { + $longCount++; + } + } + $total = count($sentences); + $longRatio = ($longCount + $veryLongCount) / $total; + if ($veryLongCount > 0) { + $items[] = ['type' => 'warning', 'message' => "{$veryLongCount} sentence(s) exceed ".self::SENTENCE_VERY_LONG.' words.', 'suggestion' => 'Split very long sentences.', 'meta' => ['very_long_sentences' => $veryLongCount]]; + } + if ($longCount > 0) { + $items[] = ['type' => 'info', 'message' => "{$longCount} long sentence(s) detected.", 'meta' => ['long_sentences' => $longCount]]; + } + if ($longRatio === 0.0) { + return 100.0; + } + if ($longRatio <= 0.10) { + return 85.0; + } + if ($longRatio <= 0.20) { + return 65.0; + } + if ($longRatio <= 0.35) { + return 40.0; + } + + return 15.0; + } + + /** + * @param string[] $paragraphs + * @param array}> $items + */ + private function scoreParagraphStructure(array $paragraphs, array &$items): float + { + if (count($paragraphs) === 0) { + $items[] = ['type' => 'warning', 'message' => 'No paragraphs detected.', 'suggestion' => 'Structure content into paragraphs.']; + + return 30.0; + } + if (count($paragraphs) === 1) { + $items[] = ['type' => 'warning', 'message' => 'Content is one block of text.', 'suggestion' => 'Break into multiple paragraphs.']; + + return 50.0; + } + $longParas = 0; + foreach ($paragraphs as $para) { + $sc = count($this->splitSentences($para)); + if ($sc > self::PARA_LONG) { + $longParas++; + } elseif ($sc > self::PARA_OPTIMAL_MAX) { + $items[] = ['type' => 'info', 'message' => 'A paragraph exceeds '.self::PARA_OPTIMAL_MAX.' sentences.']; + } + } + if ($longParas > 0) { + $items[] = ['type' => 'warning', 'message' => "{$longParas} paragraph(s) exceed ".self::PARA_LONG.' sentences.', 'suggestion' => 'Split long paragraphs.', 'meta' => ['long_paragraphs' => $longParas]]; + + return 60.0; + } + + return 100.0; + } + + /** + * @param string[] $sentences + * @param array}> $items + */ + private function scorePassiveVoice(array $sentences, array &$items): float + { + if (count($sentences) === 0) { + return 100.0; + } + $pattern = '/\b(am|is|are|was|were|be|been|being)\s+\w+ed\b/i'; + $passiveCount = 0; + foreach ($sentences as $sentence) { + if (preg_match($pattern, $sentence)) { + $passiveCount++; + } + } + $ratio = $passiveCount / count($sentences); + if ($ratio >= self::PASSIVE_ERROR) { + $pct = round($ratio * 100); + $items[] = ['type' => 'error', 'message' => "High passive voice: ~{$pct}%.", 'suggestion' => 'Rewrite in active voice.', 'meta' => ['passive_ratio' => round($ratio, 2)]]; + + return max(0.0, 100.0 - ($ratio * 200)); + } + if ($ratio >= self::PASSIVE_WARN) { + $pct = round($ratio * 100); + $items[] = ['type' => 'warning', 'message' => "Moderate passive voice: ~{$pct}%.", 'suggestion' => 'Prefer active voice.', 'meta' => ['passive_ratio' => round($ratio, 2)]]; + + return 75.0; + } + + return 100.0; + } +} diff --git a/app/Services/Quality/SeoAnalyzer.php b/app/Services/Quality/SeoAnalyzer.php new file mode 100644 index 0000000..46d6d56 --- /dev/null +++ b/app/Services/Quality/SeoAnalyzer.php @@ -0,0 +1,291 @@ +currentVersion ?? $content->draftVersion; + if ($version === null) { + return QualityDimensionResult::make(0, [['type' => 'error', 'message' => 'No content version available.']]); + } + + $items = []; + $title = (string) $version->title; + $metaDesc = (string) ($version->meta_description ?? ''); + $body = (string) $version->body; + $seoData = is_array($version->seo_data) ? $version->seo_data : []; + + $titleScore = $this->scoreTitle($title, $seoData, $items); + $metaScore = $this->scoreMetaDescription($metaDesc, $items); + $headingScore = $this->scoreHeadings($body, $items); + $kwScore = $this->scoreKeywordDensity($body, $title, $items); + $linkScore = $this->scoreLinks($body, $items); + $imgScore = $this->scoreImageAltText($body, $items); + + $total = ($titleScore * 0.20) + ($metaScore * 0.15) + ($headingScore * 0.20) + + ($kwScore * 0.15) + ($linkScore * 0.15) + ($imgScore * 0.15); + + $metadata = [ + 'title_length' => mb_strlen($title), + 'meta_desc_length' => mb_strlen($metaDesc), + 'title_score' => $titleScore, + 'meta_score' => $metaScore, + 'heading_score' => $headingScore, + 'keyword_score' => $kwScore, + 'link_score' => $linkScore, + 'image_alt_score' => $imgScore, + ]; + + return QualityDimensionResult::make(round($total, 2), $items, $metadata); + } + + public function getDimension(): string + { + return self::DIMENSION; + } + + public function getWeight(): float + { + return self::WEIGHT; + } + + /** + * @param array $seoData + * @param array}> $items + */ + private function scoreTitle(string $title, array $seoData, array &$items): float + { + $seoTitle = isset($seoData['title']) && is_string($seoData['title']) ? $seoData['title'] : $title; + $len = mb_strlen(trim($seoTitle)); + if ($len === 0) { + $items[] = ['type' => 'error', 'message' => 'No title found.', 'suggestion' => 'Add a descriptive title between 50-60 characters.']; + + return 0.0; + } + if ($len >= self::TITLE_MIN_OPTIMAL && $len <= self::TITLE_MAX_OPTIMAL) { + $items[] = ['type' => 'info', 'message' => "Title length is optimal ({$len} chars)."]; + + return 100.0; + } + if ($len < self::TITLE_MIN_OPTIMAL) { + $items[] = ['type' => 'warning', 'message' => "Title is short ({$len} chars). Aim for 50-60 chars.", 'suggestion' => 'Expand title to be more descriptive.', 'meta' => ['title_length' => $len]]; + + return $len < 20 ? 20.0 : 60.0; + } + if ($len <= self::TITLE_MAX_WARN) { + $items[] = ['type' => 'warning', 'message' => "Title is slightly long ({$len} chars). Aim for 50-60 chars.", 'suggestion' => 'Shorten title to avoid SERP truncation.', 'meta' => ['title_length' => $len]]; + + return 70.0; + } + $items[] = ['type' => 'error', 'message' => "Title is too long ({$len} chars). Will be truncated in SERPs.", 'suggestion' => 'Shorten title to 50-60 characters.', 'meta' => ['title_length' => $len]]; + + return 30.0; + } + + /** @param array}> $items */ + private function scoreMetaDescription(string $meta, array &$items): float + { + $len = mb_strlen(trim($meta)); + if ($len === 0) { + $items[] = ['type' => 'error', 'message' => 'Meta description is missing.', 'suggestion' => 'Add a meta description between 150-160 characters.']; + + return 0.0; + } + if ($len >= self::META_MIN_OPTIMAL && $len <= self::META_MAX_OPTIMAL) { + $items[] = ['type' => 'info', 'message' => "Meta description length is optimal ({$len} chars)."]; + + return 100.0; + } + if ($len < self::META_MIN_OPTIMAL) { + $items[] = ['type' => 'warning', 'message' => "Meta description is short ({$len} chars). Aim for 150-160 chars.", 'suggestion' => 'Expand meta description to better summarise the content.', 'meta' => ['meta_length' => $len]]; + + return $len < 50 ? 20.0 : 55.0; + } + if ($len <= self::META_MAX_WARN) { + $items[] = ['type' => 'warning', 'message' => "Meta description is slightly long ({$len} chars).", 'suggestion' => 'Trim to 150-160 chars to avoid SERP truncation.', 'meta' => ['meta_length' => $len]]; + + return 70.0; + } + $items[] = ['type' => 'error', 'message' => "Meta description is too long ({$len} chars).", 'suggestion' => 'Shorten to 150-160 characters.', 'meta' => ['meta_length' => $len]]; + + return 30.0; + } + + /** @param array}> $items */ + private function scoreHeadings(string $html, array &$items): float + { + preg_match_all('/]*>/i', $html, $matches); + $levels = array_map('intval', $matches[1]); + if (count($levels) === 0) { + $items[] = ['type' => 'error', 'message' => 'No headings found in content.', 'suggestion' => 'Add structured headings (H1, H2, H3) to improve SEO.']; + + return 0.0; + } + $h1Count = count(array_filter($levels, fn ($l) => $l === 1)); + $score = 100.0; + if ($h1Count === 0) { + $items[] = ['type' => 'error', 'message' => 'No H1 heading found.', 'suggestion' => 'Add exactly one H1 heading as the main topic.']; + $score -= 50; + } elseif ($h1Count > 1) { + $items[] = ['type' => 'warning', 'message' => "Multiple H1 headings found ({$h1Count}).", 'suggestion' => 'Use only one H1 per page.']; + $score -= 20; + } else { + $items[] = ['type' => 'info', 'message' => 'H1 heading is present.']; + } + $prevLevel = 0; + $skipJumps = 0; + foreach ($levels as $level) { + if ($prevLevel > 0 && $level > $prevLevel + 1) { + $skipJumps++; + } + $prevLevel = $level; + } + if ($skipJumps > 0) { + $items[] = ['type' => 'warning', 'message' => "Heading hierarchy skips {$skipJumps} level(s).", 'suggestion' => 'Ensure headings follow a logical hierarchy (H1 > H2 > H3).']; + $score -= ($skipJumps * 10); + } + + return max(0.0, $score); + } + + /** @param array}> $items */ + private function scoreKeywordDensity(string $html, string $title, array &$items): float + { + $text = strip_tags($html); + $text = mb_strtolower(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + preg_match_all('/\b[a-z]{3,}\b/', $text, $wordMatches); + $words = $wordMatches[0]; + $totalWords = count($words); + if ($totalWords === 0) { + $items[] = ['type' => 'warning', 'message' => 'No body text for keyword analysis.']; + + return 50.0; + } + $freq = array_count_values($words); + arsort($freq); + $stopwords = ['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'any', 'can', 'that', 'this', 'with', 'from', 'was', 'has', 'have', 'been', 'its', 'our', 'your']; + $candidates = array_diff_key($freq, array_flip($stopwords)); + if (count($candidates) === 0) { + $items[] = ['type' => 'info', 'message' => 'Keyword density check: only common words found.']; + + return 50.0; + } + $topKw = array_key_first($candidates); + $topFreq = $candidates[$topKw]; + $density = $topFreq / $totalWords; + $pct = round($density * 100, 2); + if ($density >= self::KW_DENSITY_MIN && $density <= self::KW_DENSITY_MAX) { + $items[] = ['type' => 'info', 'message' => "Top keyword '{$topKw}' has good density ({$pct}%).", 'meta' => ['keyword' => $topKw, 'density' => $pct]]; + + return 100.0; + } + if ($density < self::KW_DENSITY_MIN) { + $items[] = ['type' => 'warning', 'message' => "Low keyword density ({$pct}%). Aim for 0.5-3%.", 'suggestion' => 'Use your target keyword more naturally throughout the content.', 'meta' => ['keyword' => $topKw, 'density' => $pct]]; + + return 50.0; + } + $items[] = ['type' => 'error', 'message' => "Keyword stuffing detected ('{$topKw}': {$pct}%). Max recommended is 3%.", 'suggestion' => 'Reduce keyword repetition to avoid SEO penalties.', 'meta' => ['keyword' => $topKw, 'density' => $pct]]; + + return 20.0; + } + + /** @param array}> $items */ + private function scoreLinks(string $html, array &$items): float + { + preg_match_all('/]*href=["\x27](https?:[^"\x27]+)["\x27]/i', $html, $extMatches); + preg_match_all('/]*href=["\x27](\/[^"\x27]*)["\x27]/i', $html, $intMatches); + $extCount = count($extMatches[0]); + $intCount = count($intMatches[0]); + $total = $extCount + $intCount; + $score = 100.0; + if ($total === 0) { + $items[] = ['type' => 'warning', 'message' => 'No links found in content.', 'suggestion' => 'Add internal and external links.']; + + return 30.0; + } + if ($intCount === 0) { + $items[] = ['type' => 'warning', 'message' => 'No internal links found.', 'suggestion' => 'Add internal links to improve site structure.']; + $score -= 30; + } else { + $items[] = ['type' => 'info', 'message' => "{$intCount} internal link(s) found.", 'meta' => ['internal_links' => $intCount]]; + } + if ($extCount === 0) { + $items[] = ['type' => 'info', 'message' => 'No external links. Consider linking to authoritative sources.']; + $score -= 10; + } else { + $items[] = ['type' => 'info', 'message' => "{$extCount} external link(s) found.", 'meta' => ['external_links' => $extCount]]; + } + + return max(0.0, $score); + } + + /** @param array}> $items */ + private function scoreImageAltText(string $html, array &$items): float + { + preg_match_all('/]+>/i', $html, $allImgs); + $totalImgs = count($allImgs[0]); + if ($totalImgs === 0) { + $items[] = ['type' => 'info', 'message' => 'No images found in content.']; + + return 100.0; + } + preg_match_all('/]+alt=["\x27][^"\x27]+["\x27][^>]*>/i', $html, $altImgs); + $withAlt = count($altImgs[0]); + $coverage = $withAlt / $totalImgs; + $pct = round($coverage * 100); + $missing = $totalImgs - $withAlt; + if ($coverage >= 1.0) { + $items[] = ['type' => 'info', 'message' => "All {$totalImgs} image(s) have alt text."]; + + return 100.0; + } + if ($coverage >= 0.80) { + $items[] = ['type' => 'warning', 'message' => "{$missing} image(s) missing alt text ({$pct}% coverage).", 'suggestion' => 'Add descriptive alt text to all images.', 'meta' => ['missing_alt' => $missing, 'coverage_pct' => $pct]]; + + return 75.0; + } + if ($coverage >= 0.50) { + $items[] = ['type' => 'warning', 'message' => "{$missing} image(s) missing alt text ({$pct}% coverage).", 'suggestion' => 'Add alt text to all images for accessibility and SEO.', 'meta' => ['missing_alt' => $missing, 'coverage_pct' => $pct]]; + + return 50.0; + } + $items[] = ['type' => 'error', 'message' => "Most images lack alt text ({$pct}% coverage).", 'suggestion' => 'Add descriptive alt text to every image.', 'meta' => ['missing_alt' => $missing, 'coverage_pct' => $pct]]; + + return 20.0; + } +} diff --git a/app/Services/Webhooks/EventMapper.php b/app/Services/Webhooks/EventMapper.php index bf66e5b..6f50e2e 100644 --- a/app/Services/Webhooks/EventMapper.php +++ b/app/Services/Webhooks/EventMapper.php @@ -22,6 +22,7 @@ public function map(string $eventType, array $context): array 'pipeline' => $this->mapPipelineEvent($eventType, $context), 'media' => $this->mapMediaEvent($eventType, $context), 'user' => $this->mapUserEvent($eventType, $context), + 'quality' => $this->mapQualityEvent($eventType, $context), default => $context, }; @@ -77,4 +78,20 @@ private function mapUserEvent(string $eventType, array $context): array 'action' => $context['action'] ?? $eventType, ], fn ($v) => $v !== null); } + + private function mapQualityEvent(string $eventType, array $context): array + { + return array_filter([ + 'score_id' => $context['score_id'] ?? null, + 'content_id' => $context['content_id'] ?? null, + 'space_id' => $context['space_id'] ?? null, + 'overall_score' => $context['overall_score'] ?? null, + 'readability_score' => $context['readability_score'] ?? null, + 'seo_score' => $context['seo_score'] ?? null, + 'brand_score' => $context['brand_score'] ?? null, + 'factual_score' => $context['factual_score'] ?? null, + 'engagement_score' => $context['engagement_score'] ?? null, + 'scored_at' => $context['scored_at'] ?? null, + ], fn ($v) => $v !== null); + } } diff --git a/bootstrap/cache/services.php b/bootstrap/cache/services.php index dfab768..34486d7 100755 --- a/bootstrap/cache/services.php +++ b/bootstrap/cache/services.php @@ -48,6 +48,8 @@ 44 => 'Tighten\\Ziggy\\ZiggyServiceProvider', 45 => 'App\\Providers\\AppServiceProvider', 46 => 'App\\Providers\\I18nServiceProvider', + 47 => 'App\\Providers\\EventServiceProvider', + 48 => 'Nuwave\\Lighthouse\\Subscriptions\\SubscriptionServiceProvider', ), 'eager' => array ( @@ -83,6 +85,8 @@ 29 => 'Tighten\\Ziggy\\ZiggyServiceProvider', 30 => 'App\\Providers\\AppServiceProvider', 31 => 'App\\Providers\\I18nServiceProvider', + 32 => 'App\\Providers\\EventServiceProvider', + 33 => 'Nuwave\\Lighthouse\\Subscriptions\\SubscriptionServiceProvider', ), 'deferred' => array ( diff --git a/config/quality-prompts.php b/config/quality-prompts.php new file mode 100644 index 0000000..e3b1c45 --- /dev/null +++ b/config/quality-prompts.php @@ -0,0 +1,114 @@ + [ + 'system' => <<<'PROMPT' +You are a brand voice quality auditor. Your job is to evaluate written content against a brand's persona guidelines and return a structured JSON assessment. + +Always respond with valid JSON only — no markdown fences, no preamble. +PROMPT, + 'user' => <<<'PROMPT' +Evaluate the following content for brand consistency against the provided persona guidelines. + +## Persona Guidelines +{{context}} + +## Content to Evaluate +{{content}} + +Return a JSON object with this exact structure: +{ + "score": , + "tone_consistency": , + "vocabulary_alignment": , + "brand_voice_adherence": , + "deviations": [ + { + "type": "tone|vocabulary|voice|style", + "message": "", + "suggestion": "" + } + ], + "summary": "" +} +PROMPT, + ], + + 'factual_accuracy_prompt' => [ + 'system' => <<<'PROMPT' +You are a factual accuracy analyst. Extract claims from content and assess their verifiability. Return structured JSON only — no markdown fences, no preamble. +PROMPT, + 'user' => <<<'PROMPT' +Analyze the following content for factual claims and accuracy. + +## Known Entities / Context +{{context}} + +## Content to Analyze +{{content}} + +Return a JSON object with this exact structure: +{ + "score": , + "verifiable_claims_ratio": , + "has_source_citations": , + "claims": [ + { + "claim": "", + "verifiable": , + "confidence": , + "issue": "", + "suggestion": "" + } + ], + "summary": "" +} +PROMPT, + ], + + 'engagement_prediction_prompt' => [ + 'system' => <<<'PROMPT' +You are an engagement prediction specialist. Analyze content for its potential to drive reader engagement and sharing. Return structured JSON only — no markdown fences, no preamble. +PROMPT, + 'user' => <<<'PROMPT' +Predict the engagement potential of the following content. + +## Context +{{context}} + +## Content to Analyze +{{content}} + +Return a JSON object with this exact structure: +{ + "score": , + "headline_strength": , + "hook_quality": , + "emotional_resonance": , + "cta_effectiveness": , + "shareability": , + "factors": [ + { + "factor": "", + "score": , + "observation": "", + "suggestion": "" + } + ], + "summary": "" +} +PROMPT, + ], + +]; diff --git a/database/factories/CompetitorAlertEventFactory.php b/database/factories/CompetitorAlertEventFactory.php new file mode 100644 index 0000000..f6bce97 --- /dev/null +++ b/database/factories/CompetitorAlertEventFactory.php @@ -0,0 +1,28 @@ + CompetitorAlert::factory(), + 'competitor_content_id' => CompetitorContentItem::factory(), + 'trigger_data' => [], + 'notified_at' => null, + ]; + } + + public function notified(): static + { + return $this->state(['notified_at' => now()]); + } +} diff --git a/database/factories/CompetitorAlertFactory.php b/database/factories/CompetitorAlertFactory.php new file mode 100644 index 0000000..5ce1666 --- /dev/null +++ b/database/factories/CompetitorAlertFactory.php @@ -0,0 +1,29 @@ + Str::ulid()->toBase32(), + 'name' => $this->faker->words(3, true), + 'type' => $this->faker->randomElement(['new_competitor_content', 'high_similarity', 'topic_overlap']), + 'conditions' => [], + 'is_active' => true, + 'notify_channels' => ['email'], + ]; + } + + public function inactive(): static + { + return $this->state(['is_active' => false]); + } +} diff --git a/database/factories/CompetitorContentItemFactory.php b/database/factories/CompetitorContentItemFactory.php new file mode 100644 index 0000000..91576d8 --- /dev/null +++ b/database/factories/CompetitorContentItemFactory.php @@ -0,0 +1,27 @@ + CompetitorSource::factory(), + 'external_url' => $this->faker->unique()->url(), + 'title' => $this->faker->sentence(), + 'excerpt' => $this->faker->paragraph(), + 'body' => $this->faker->paragraphs(3, true), + 'published_at' => $this->faker->dateTimeBetween('-1 year', 'now'), + 'crawled_at' => now(), + 'content_hash' => md5($this->faker->text()), + 'metadata' => null, + ]; + } +} diff --git a/database/factories/CompetitorSourceFactory.php b/database/factories/CompetitorSourceFactory.php new file mode 100644 index 0000000..330c0cb --- /dev/null +++ b/database/factories/CompetitorSourceFactory.php @@ -0,0 +1,33 @@ + Str::ulid()->toBase32(), + 'name' => $this->faker->company(), + 'url' => $this->faker->url(), + 'feed_url' => $this->faker->optional()->url(), + 'crawler_type' => $this->faker->randomElement(['rss', 'sitemap', 'scrape', 'api']), + 'config' => null, + 'is_active' => true, + 'crawl_interval_minutes' => 60, + 'last_crawled_at' => null, + 'error_count' => 0, + ]; + } + + public function inactive(): static + { + return $this->state(['is_active' => false]); + } +} diff --git a/database/factories/ContentFingerprintFactory.php b/database/factories/ContentFingerprintFactory.php new file mode 100644 index 0000000..0364de8 --- /dev/null +++ b/database/factories/ContentFingerprintFactory.php @@ -0,0 +1,25 @@ + CompetitorContentItem::class, + 'fingerprintable_id' => CompetitorContentItem::factory(), + 'topics' => $this->faker->words(5), + 'entities' => $this->faker->words(3), + 'keywords' => $this->faker->words(8), + 'embedding_vector' => null, + 'fingerprinted_at' => now(), + ]; + } +} diff --git a/database/factories/ContentQualityConfigFactory.php b/database/factories/ContentQualityConfigFactory.php new file mode 100644 index 0000000..713a886 --- /dev/null +++ b/database/factories/ContentQualityConfigFactory.php @@ -0,0 +1,45 @@ + Space::factory(), + 'dimension_weights' => [ + 'readability' => 0.2, + 'seo' => 0.25, + 'brand' => 0.2, + 'factual' => 0.2, + 'engagement' => 0.15, + ], + 'thresholds' => [ + 'readability' => 60, + 'seo' => 60, + 'brand' => 60, + 'factual' => 60, + 'engagement' => 60, + ], + 'enabled_dimensions' => ['readability', 'seo', 'brand', 'factual', 'engagement'], + 'auto_score_on_publish' => true, + 'pipeline_gate_enabled' => false, + 'pipeline_gate_min_score' => 70.00, + ]; + } + + public function withGate(): static + { + return $this->state([ + 'pipeline_gate_enabled' => true, + 'pipeline_gate_min_score' => 75.00, + ]); + } +} diff --git a/database/factories/ContentQualityScoreFactory.php b/database/factories/ContentQualityScoreFactory.php new file mode 100644 index 0000000..c44a5d6 --- /dev/null +++ b/database/factories/ContentQualityScoreFactory.php @@ -0,0 +1,45 @@ + Space::factory(), + 'content_id' => Content::factory(), + 'content_version_id' => null, + 'overall_score' => $this->faker->randomFloat(2, 0, 100), + 'readability_score' => $this->faker->randomFloat(2, 0, 100), + 'seo_score' => $this->faker->randomFloat(2, 0, 100), + 'brand_score' => $this->faker->randomFloat(2, 0, 100), + 'factual_score' => $this->faker->randomFloat(2, 0, 100), + 'engagement_score' => $this->faker->randomFloat(2, 0, 100), + 'scoring_model' => 'gpt-4o', + 'scoring_duration_ms' => $this->faker->numberBetween(100, 5000), + 'scored_at' => now(), + ]; + } + + public function passing(): static + { + return $this->state([ + 'overall_score' => $this->faker->randomFloat(2, 70, 100), + ]); + } + + public function failing(): static + { + return $this->state([ + 'overall_score' => $this->faker->randomFloat(2, 0, 69.99), + ]); + } +} diff --git a/database/factories/ContentQualityScoreItemFactory.php b/database/factories/ContentQualityScoreItemFactory.php new file mode 100644 index 0000000..3fb2c56 --- /dev/null +++ b/database/factories/ContentQualityScoreItemFactory.php @@ -0,0 +1,45 @@ + ContentQualityScore::factory(), + 'dimension' => $this->faker->randomElement($dimensions), + 'category' => $this->faker->word(), + 'rule_key' => 'rule.'.$this->faker->word(), + 'label' => $this->faker->sentence(4), + 'severity' => $this->faker->randomElement(['info', 'warning', 'error']), + 'score_impact' => $this->faker->randomFloat(2, -20, 0), + 'message' => $this->faker->sentence(), + 'suggestion' => $this->faker->sentence(), + 'metadata' => null, + ]; + } + + public function error(): static + { + return $this->state(['severity' => 'error']); + } + + public function warning(): static + { + return $this->state(['severity' => 'warning']); + } + + public function info(): static + { + return $this->state(['severity' => 'info']); + } +} diff --git a/database/factories/DifferentiationAnalysisFactory.php b/database/factories/DifferentiationAnalysisFactory.php new file mode 100644 index 0000000..38640a0 --- /dev/null +++ b/database/factories/DifferentiationAnalysisFactory.php @@ -0,0 +1,29 @@ + Str::ulid()->toBase32(), + 'content_id' => null, + 'brief_id' => null, + 'competitor_content_id' => CompetitorContentItem::factory(), + 'similarity_score' => $this->faker->randomFloat(4, 0, 1), + 'differentiation_score' => $this->faker->randomFloat(4, 0, 1), + 'angles' => [], + 'gaps' => [], + 'recommendations' => [], + 'analyzed_at' => now(), + ]; + } +} diff --git a/database/factories/PipelineRunFactory.php b/database/factories/PipelineRunFactory.php new file mode 100644 index 0000000..7423083 --- /dev/null +++ b/database/factories/PipelineRunFactory.php @@ -0,0 +1,27 @@ + ContentPipeline::factory(), + 'content_id' => null, + 'content_brief_id' => null, + 'status' => 'running', + 'current_stage' => 'stage_1', + 'stage_results' => [], + 'context' => [], + 'started_at' => now(), + 'completed_at' => null, + ]; + } +} diff --git a/database/migrations/2026_03_15_400001_create_competitor_sources_table.php b/database/migrations/2026_03_15_400001_create_competitor_sources_table.php new file mode 100644 index 0000000..f84bb7b --- /dev/null +++ b/database/migrations/2026_03_15_400001_create_competitor_sources_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->string('name'); + $table->string('url'); + $table->string('feed_url')->nullable(); + $table->enum('crawler_type', ['rss', 'sitemap', 'scrape', 'api'])->default('rss'); + $table->json('config')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedInteger('crawl_interval_minutes')->default(60); + $table->timestamp('last_crawled_at')->nullable(); + $table->unsignedInteger('error_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('competitor_sources'); + } +}; diff --git a/database/migrations/2026_03_15_400002_create_competitor_content_items_table.php b/database/migrations/2026_03_15_400002_create_competitor_content_items_table.php new file mode 100644 index 0000000..c2fb2ae --- /dev/null +++ b/database/migrations/2026_03_15_400002_create_competitor_content_items_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->string('source_id', 26)->index(); + $table->string('external_url'); + $table->string('title')->nullable(); + $table->text('excerpt')->nullable(); + $table->longText('body')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamp('crawled_at')->nullable(); + $table->string('content_hash')->nullable()->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('competitor_content_items'); + } +}; diff --git a/database/migrations/2026_03_15_400003_create_content_fingerprints_table.php b/database/migrations/2026_03_15_400003_create_content_fingerprints_table.php new file mode 100644 index 0000000..85f9f21 --- /dev/null +++ b/database/migrations/2026_03_15_400003_create_content_fingerprints_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->string('fingerprintable_type'); + $table->string('fingerprintable_id', 26); + $table->json('topics')->nullable(); + $table->json('entities')->nullable(); + $table->json('keywords')->nullable(); + $table->text('embedding_vector')->nullable(); + $table->timestamp('fingerprinted_at')->nullable(); + $table->timestamps(); + + $table->index(['fingerprintable_type', 'fingerprintable_id'], 'fingerprints_morphable_index'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('content_fingerprints'); + } +}; diff --git a/database/migrations/2026_03_15_400004_create_differentiation_analyses_table.php b/database/migrations/2026_03_15_400004_create_differentiation_analyses_table.php new file mode 100644 index 0000000..e001894 --- /dev/null +++ b/database/migrations/2026_03_15_400004_create_differentiation_analyses_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->string('content_id', 26)->nullable()->index(); + $table->string('brief_id', 26)->nullable()->index(); + $table->string('competitor_content_id', 26)->index(); + $table->decimal('similarity_score', 5, 4)->default(0); + $table->decimal('differentiation_score', 5, 4)->default(0); + $table->json('angles')->nullable(); + $table->json('gaps')->nullable(); + $table->json('recommendations')->nullable(); + $table->timestamp('analyzed_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('differentiation_analyses'); + } +}; diff --git a/database/migrations/2026_03_15_400005_create_competitor_alerts_table.php b/database/migrations/2026_03_15_400005_create_competitor_alerts_table.php new file mode 100644 index 0000000..e89d2fe --- /dev/null +++ b/database/migrations/2026_03_15_400005_create_competitor_alerts_table.php @@ -0,0 +1,30 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->string('name'); + $table->enum('type', ['new_competitor_content', 'high_similarity', 'topic_overlap']); + $table->json('conditions')->nullable(); + $table->boolean('is_active')->default(true); + $table->json('notify_channels')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('competitor_alerts'); + } +}; diff --git a/database/migrations/2026_03_15_400006_create_competitor_alert_events_table.php b/database/migrations/2026_03_15_400006_create_competitor_alert_events_table.php new file mode 100644 index 0000000..3a2c3b7 --- /dev/null +++ b/database/migrations/2026_03_15_400006_create_competitor_alert_events_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->string('alert_id', 26)->index(); + $table->string('competitor_content_id', 26)->index(); + $table->json('trigger_data')->nullable(); + $table->timestamp('notified_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('competitor_alert_events'); + } +}; diff --git a/database/migrations/2026_03_15_600001_create_content_quality_scores_table.php b/database/migrations/2026_03_15_600001_create_content_quality_scores_table.php new file mode 100644 index 0000000..952d311 --- /dev/null +++ b/database/migrations/2026_03_15_600001_create_content_quality_scores_table.php @@ -0,0 +1,35 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->string('content_id', 26)->index(); + $table->string('content_version_id', 26)->nullable()->index(); + $table->decimal('overall_score', 5, 2); + $table->decimal('readability_score', 5, 2)->nullable(); + $table->decimal('seo_score', 5, 2)->nullable(); + $table->decimal('brand_score', 5, 2)->nullable(); + $table->decimal('factual_score', 5, 2)->nullable(); + $table->decimal('engagement_score', 5, 2)->nullable(); + $table->string('scoring_model')->nullable(); + $table->integer('scoring_duration_ms')->nullable(); + $table->timestamp('scored_at'); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('content_quality_scores'); + } +}; diff --git a/database/migrations/2026_03_15_600002_create_content_quality_score_items_table.php b/database/migrations/2026_03_15_600002_create_content_quality_score_items_table.php new file mode 100644 index 0000000..87dab93 --- /dev/null +++ b/database/migrations/2026_03_15_600002_create_content_quality_score_items_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->string('score_id', 26)->index(); + $table->string('dimension'); + $table->string('category'); + $table->string('rule_key'); + $table->string('label'); + $table->enum('severity', ['info', 'warning', 'error']); + $table->decimal('score_impact', 5, 2); + $table->text('message'); + $table->text('suggestion')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('content_quality_score_items'); + } +}; diff --git a/database/migrations/2026_03_15_600003_create_content_quality_configs_table.php b/database/migrations/2026_03_15_600003_create_content_quality_configs_table.php new file mode 100644 index 0000000..67d5e02 --- /dev/null +++ b/database/migrations/2026_03_15_600003_create_content_quality_configs_table.php @@ -0,0 +1,30 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->json('dimension_weights'); + $table->json('thresholds'); + $table->json('enabled_dimensions'); + $table->boolean('auto_score_on_publish')->default(true); + $table->boolean('pipeline_gate_enabled')->default(false); + $table->decimal('pipeline_gate_min_score', 5, 2)->default(70); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('content_quality_configs'); + } +}; diff --git a/docs/api/quality-api.md b/docs/api/quality-api.md new file mode 100644 index 0000000..48b9140 --- /dev/null +++ b/docs/api/quality-api.md @@ -0,0 +1,250 @@ +# Content Quality Scoring API Reference + +**Version:** 1.0.0 +**Base URL:** `/api/v1/quality` +**Added:** 2026-03-16 + +All endpoints require Sanctum authentication. Permission `content.view` is required +for read operations; `settings.manage` is required for config updates. + +--- + +## Endpoints + +### 1. GET /api/v1/quality/scores + +List quality scores for a space, optionally filtered by content item. + +**Query parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `space_id` | string (ULID) | ✅ | Space to filter by | +| `content_id` | string (ULID) | ❌ | Filter to specific content item | +| `per_page` | integer (1–100) | ❌ | Scores per page (default: 20) | + +**Response:** +```json +{ + "data": [ + { + "id": "01J...", + "space_id": "01J...", + "content_id": "01J...", + "content_version_id": "01J...", + "overall_score": 82.5, + "dimensions": { + "readability": 88.0, + "seo": 79.0, + "brand": 85.0, + "factual": 91.0, + "engagement": 70.0 + }, + "scoring_model": "content-quality-v1", + "scoring_duration_ms": 1240, + "scored_at": "2026-03-16T10:00:00Z", + "items": [] + } + ], + "links": {...}, + "meta": {...} +} +``` + +--- + +### 2. GET /api/v1/quality/scores/{score} + +Get a single quality score with its dimension items. + +**Response:** Single `ContentQualityScoreResource` with `items` array. + +--- + +### 3. POST /api/v1/quality/score + +Trigger an async quality scoring job for a content item. + +**Request body:** +```json +{ "content_id": "01J..." } +``` + +**Response (202):** +```json +{ "message": "Quality scoring job queued.", "content_id": "01J..." } +``` + +--- + +### 4. GET /api/v1/quality/trends + +Aggregate daily trend data, leaderboard, and dimension distributions for a space. + +**Query parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `space_id` | string (ULID) | ✅ | Space to query | +| `from` | date (YYYY-MM-DD) | ❌ | Start date (default: 30 days ago) | +| `to` | date (YYYY-MM-DD) | ❌ | End date (default: today) | + +**Response:** +```json +{ + "data": { + "trends": { + "2026-03-15": { + "overall": 81.3, + "readability": 85.2, + "seo": 78.4, + "brand": 82.0, + "factual": 90.1, + "engagement": 71.5, + "total": 12 + } + }, + "leaderboard": [ + { + "score_id": "01J...", + "content_id": "01J...", + "title": "10 Ways to Write Better Content", + "overall_score": 94.5, + "scored_at": "2026-03-16T09:00:00Z" + } + ], + "distribution": { + "overall": { "0-10": 0, "10-20": 1, "20-30": 2, ..., "90-100": 8 } + }, + "period": { "from": "2026-02-14", "to": "2026-03-16" } + } +} +``` + +--- + +### 5. GET /api/v1/quality/config + +Get quality configuration for a space. Creates a default config if none exists. + +**Query parameters:** `space_id` (required) + +**Response:** +```json +{ + "data": { + "id": "01J...", + "space_id": "01J...", + "dimension_weights": { + "readability": 0.25, + "seo": 0.25, + "brand_consistency": 0.20, + "factual_accuracy": 0.15, + "engagement_prediction": 0.15 + }, + "thresholds": { "poor": 40, "fair": 60, "good": 75, "excellent": 90 }, + "enabled_dimensions": ["readability", "seo", "brand_consistency", "factual_accuracy", "engagement_prediction"], + "auto_score_on_publish": true, + "pipeline_gate_enabled": false, + "pipeline_gate_min_score": 70.0, + "created_at": "2026-03-16T08:00:00Z", + "updated_at": "2026-03-16T08:00:00Z" + } +} +``` + +--- + +### 6. PUT /api/v1/quality/config + +Update quality configuration for a space. Requires `settings.manage` permission. + +**Request body:** +```json +{ + "space_id": "01J...", + "pipeline_gate_enabled": true, + "pipeline_gate_min_score": 75, + "auto_score_on_publish": true, + "enabled_dimensions": ["readability", "seo"], + "dimension_weights": { "readability": 0.5, "seo": 0.5 } +} +``` + +**Response:** Updated `ContentQualityConfigResource`. + +--- + +## Webhook Event + +### `quality.scored` + +Fired when a content item is successfully scored. + +**Payload:** +```json +{ + "id": "01J...", + "event": "quality.scored", + "timestamp": "2026-03-16T10:00:00Z", + "data": { + "score_id": "01J...", + "content_id": "01J...", + "space_id": "01J...", + "overall_score": 82.5, + "readability_score": 88.0, + "seo_score": 79.0, + "brand_score": 85.0, + "factual_score": 91.0, + "engagement_score": 70.0, + "scored_at": "2026-03-16T10:00:00Z" + } +} +``` + +--- + +## Pipeline Stage: `quality_gate` + +Add a `quality_gate` stage to any pipeline to enforce quality thresholds before publishing. + +**Pipeline definition example:** +```json +{ + "stages": [ + { "name": "ai_generate", "type": "ai_generate" }, + { + "name": "quality_check", + "type": "quality_gate", + "min_score": 75 + }, + { "name": "publish", "type": "auto_publish" } + ] +} +``` + +If `min_score` is omitted, the stage uses the space's `pipeline_gate_min_score` config (default: 70). + +If the score is below the threshold, the pipeline is paused with status `paused_for_review`. + +--- + +## Scoring Dimensions + +| Dimension | Key | Description | +|-----------|-----|-------------| +| Readability | `readability` | Flesch-Kincaid score, sentence length, word complexity | +| SEO | `seo` | Keyword density, meta tags, heading structure | +| Brand Consistency | `brand_consistency` | Tone, voice, and brand guideline adherence (LLM-based) | +| Factual Accuracy | `factual_accuracy` | Fact-check claims against knowledge base (LLM-based) | +| Engagement Prediction | `engagement_prediction` | Predicted engagement based on content patterns (LLM-based) | + +## Score Interpretation + +| Range | Label | Meaning | +|-------|-------|---------| +| 90–100 | Excellent | Ready to publish, high-quality | +| 75–89 | Good | Publish-ready, minor improvements possible | +| 60–74 | Fair | Consider improvements before publishing | +| 40–59 | Poor | Significant improvements needed | +| 0–39 | Critical | Major revision required | diff --git a/docs/blog/quality-scoring-launch.md b/docs/blog/quality-scoring-launch.md new file mode 100644 index 0000000..168ff12 --- /dev/null +++ b/docs/blog/quality-scoring-launch.md @@ -0,0 +1,91 @@ +# Introducing AI Content Quality Scoring + +_Published: 2026-03-16_ + +We're excited to announce **AI Content Quality Scoring** for Numen — a powerful new feature +that automatically evaluates every piece of content across five key dimensions, giving your +team actionable insights to ship higher-quality content, faster. + +## What Is Content Quality Scoring? + +Every content creator faces the same challenge: how do you know if your content is truly +ready to publish? Is it readable enough for your audience? Does it hit the right SEO signals? +Does it stay on-brand? Is it factually accurate? + +Until now, answering these questions required manual review — a time-consuming process that +slows down publishing pipelines. With AI Content Quality Scoring, Numen does this automatically. + +## Five Dimensions of Quality + +Our scoring engine analyzes content across five dimensions: + +1. **Readability** — Uses Flesch-Kincaid metrics to assess how easy your content is to read. + Complex sentences and difficult vocabulary are flagged with specific suggestions. + +2. **SEO** — Evaluates keyword density, heading structure, meta tags, and content length + against SEO best practices. + +3. **Brand Consistency** — Our LLM-powered analyzer checks your content against your brand + guidelines, ensuring consistent voice, tone, and messaging. + +4. **Factual Accuracy** — Cross-references claims against your knowledge base and common + facts, flagging potential inaccuracies for human review. + +5. **Engagement Prediction** — Predicts how engaging your content will be based on structure, + emotional resonance, and proven engagement patterns. + +## The Quality Dashboard + +The new `/admin/quality` dashboard gives you a bird's-eye view of content quality across +your space: + +- **Trend charts** showing how quality scores evolve over time +- **Space leaderboard** highlighting your top-performing content +- **Dimension breakdowns** showing where to focus improvement efforts +- **Distribution histograms** across all five dimensions + +## Pipeline Quality Gates + +Want to prevent low-quality content from being published automatically? Add a `quality_gate` +stage to your pipeline: + +```json +{ + "name": "quality_check", + "type": "quality_gate", + "min_score": 75 +} +``` + +If content doesn't meet the threshold, the pipeline pauses for human review. No more +accidentally publishing content that isn't ready. + +## Auto-Score on Publish + +Enable **Auto-score on publish** in Quality Settings, and every piece of content will be +automatically scored when it's published. Your quality metrics stay up-to-date without +any manual intervention. + +## Webhooks + +Integrate quality scores into your workflows with the new `quality.scored` webhook event. +Trigger Slack notifications, update your CMS, or feed scores into your analytics platform +whenever content is scored. + +## Getting Started + +1. Visit **Settings → Quality Scoring** to configure your dimensions and thresholds +2. Navigate to **Quality Dashboard** to see your space's quality trends +3. Open any content in the editor — the new **Quality Score Panel** in the sidebar shows + the latest score and lets you trigger a re-score with one click + +## What's Next + +We're already working on the next iteration of quality scoring, including: +- Custom rubrics defined in natural language +- Cross-locale quality comparison +- Automated improvement suggestions with one-click application + +--- + +_Numen is an open-source AI-powered CMS. [Contribute on GitHub →](https://github.com/byte5digital/numen)_ diff --git a/package-lock.json b/package-lock.json index f3814a5..c2d488b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "numen", "dependencies": { + "chart.js": "^4.5.1", "d3": "^7.9.0", "marked": "^17.0.4" }, @@ -594,6 +595,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -1544,6 +1551,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", diff --git a/package.json b/package.json index b4cae6b..9fa3264 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "vue": "^3.5" }, "dependencies": { + "chart.js": "^4.5.1", "d3": "^7.9.0", "marked": "^17.0.4" } diff --git a/phpunit.xml b/phpunit.xml index ed3ca7a..4774f11 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,7 @@ + diff --git a/resources/js/Components/Quality/DimensionBar.vue b/resources/js/Components/Quality/DimensionBar.vue new file mode 100644 index 0000000..c87bad6 --- /dev/null +++ b/resources/js/Components/Quality/DimensionBar.vue @@ -0,0 +1,47 @@ + + + diff --git a/resources/js/Components/Quality/QualityConfigPanel.vue b/resources/js/Components/Quality/QualityConfigPanel.vue new file mode 100644 index 0000000..007ae29 --- /dev/null +++ b/resources/js/Components/Quality/QualityConfigPanel.vue @@ -0,0 +1,201 @@ + + + diff --git a/resources/js/Components/Quality/QualityScorePanel.vue b/resources/js/Components/Quality/QualityScorePanel.vue new file mode 100644 index 0000000..0112d86 --- /dev/null +++ b/resources/js/Components/Quality/QualityScorePanel.vue @@ -0,0 +1,125 @@ + + + diff --git a/resources/js/Components/Quality/ScoreRing.vue b/resources/js/Components/Quality/ScoreRing.vue new file mode 100644 index 0000000..5365619 --- /dev/null +++ b/resources/js/Components/Quality/ScoreRing.vue @@ -0,0 +1,79 @@ + + + diff --git a/resources/js/Components/Quality/SpaceLeaderboard.vue b/resources/js/Components/Quality/SpaceLeaderboard.vue new file mode 100644 index 0000000..c291ca8 --- /dev/null +++ b/resources/js/Components/Quality/SpaceLeaderboard.vue @@ -0,0 +1,47 @@ + + + diff --git a/resources/js/Components/Quality/TrendChart.vue b/resources/js/Components/Quality/TrendChart.vue new file mode 100644 index 0000000..ed4fa02 --- /dev/null +++ b/resources/js/Components/Quality/TrendChart.vue @@ -0,0 +1,116 @@ + + + diff --git a/resources/js/Pages/Content/Show.vue b/resources/js/Pages/Content/Show.vue index 4769dfc..a9f16c5 100644 --- a/resources/js/Pages/Content/Show.vue +++ b/resources/js/Pages/Content/Show.vue @@ -10,6 +10,7 @@ import DiffViewer from '../../Components/Versioning/DiffViewer.vue'; import SchedulePublish from '../../Components/Versioning/SchedulePublish.vue'; import DraftEditor from '../../Components/Versioning/DraftEditor.vue'; import RelatedContentWidget from '../../Components/Graph/RelatedContentWidget.vue'; +import QualityScorePanel from '../../Components/Quality/QualityScorePanel.vue'; const props = defineProps({ content: { type: Object, required: true }, @@ -796,6 +797,9 @@ function selectedTermIds(vocabularyGroup) { :space-id="content.space_id ?? ''" /> + + + +import { ref, computed, onMounted } from 'vue'; +import axios from 'axios'; +import TrendChart from '../../Components/Quality/TrendChart.vue'; +import SpaceLeaderboard from '../../Components/Quality/SpaceLeaderboard.vue'; +import ScoreRing from '../../Components/Quality/ScoreRing.vue'; +import DimensionBar from '../../Components/Quality/DimensionBar.vue'; + +const props = defineProps({ + spaceId: { type: String, required: true }, + spaceName: { type: String, default: '' }, + initialTrends: { type: Object, default: () => ({}) }, + initialLeaderboard: { type: Array, default: () => [] }, + initialDistribution: { type: Object, default: () => ({}) }, +}); + +const trends = ref(props.initialTrends); +const leaderboard = ref(props.initialLeaderboard); +const distribution = ref(props.initialDistribution); +const loading = ref(false); +const period = ref(30); + +const latestDate = computed(() => { + const dates = Object.keys(trends.value).sort(); + return dates[dates.length - 1] ?? null; +}); + +const latestStats = computed(() => { + if (!latestDate.value) return null; + return trends.value[latestDate.value] ?? null; +}); + +const avgScore = computed(() => { + const vals = Object.values(trends.value).map(d => d.overall).filter(v => v !== null); + if (vals.length === 0) return null; + return vals.reduce((a, b) => a + b, 0) / vals.length; +}); + +const totalScored = computed(() => { + return Object.values(trends.value).reduce((sum, d) => sum + (d.total ?? 0), 0); +}); + +async function loadTrends() { + loading.value = true; + try { + const from = new Date(); + from.setDate(from.getDate() - period.value); + const res = await axios.get('/api/v1/quality/trends', { + params: { + space_id: props.spaceId, + from: from.toISOString().split('T')[0], + to: new Date().toISOString().split('T')[0], + }, + }); + trends.value = res.data.data.trends ?? {}; + leaderboard.value = res.data.data.leaderboard ?? []; + distribution.value = res.data.data.distribution ?? {}; + } catch (e) { + // fail silently + } finally { + loading.value = false; + } +} + +function onPeriodChange(days) { + period.value = days; + loadTrends(); +} + + + diff --git a/resources/js/Pages/Settings/Quality.vue b/resources/js/Pages/Settings/Quality.vue new file mode 100644 index 0000000..60949fb --- /dev/null +++ b/resources/js/Pages/Settings/Quality.vue @@ -0,0 +1,22 @@ + + + diff --git a/routes/api.php b/routes/api.php index 135ffb3..97c69e8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -365,6 +365,9 @@ use App\Http\Controllers\Api\Templates\PipelineTemplateRatingController; use App\Http\Controllers\Api\Templates\PipelineTemplateVersionController; +// Content Quality Scoring API +use App\Http\Controllers\Api\ContentQualityController; + Route::prefix('v1/spaces/{space}/pipeline-templates')->middleware(['auth:sanctum'])->group(function () { Route::get('/', [PipelineTemplateController::class, 'index'])->name('api.pipeline-templates.index'); Route::post('/', [PipelineTemplateController::class, 'store'])->name('api.pipeline-templates.store'); @@ -382,3 +385,12 @@ Route::get('/{template}/ratings', [PipelineTemplateRatingController::class, 'index'])->name('api.pipeline-templates.ratings.index')->withoutScopedBindings(); Route::post('/{template}/ratings', [PipelineTemplateRatingController::class, 'store'])->name('api.pipeline-templates.ratings.store')->withoutScopedBindings(); }); + +Route::prefix('v1/quality')->middleware('auth:sanctum')->group(function () { +Route::get('/scores', [ContentQualityController::class, 'index']); + Route::get('/scores/{score}', [ContentQualityController::class, 'show']); + Route::post('/score', [ContentQualityController::class, 'score']); + Route::get('/trends', [ContentQualityController::class, 'trends']); + Route::get('/config', [ContentQualityController::class, 'getConfig']); + Route::put('/config', [ContentQualityController::class, 'updateConfig']); +}); diff --git a/routes/web.php b/routes/web.php index fc450f9..d583844 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,8 @@ use App\Http\Controllers\Admin\PersonaAdminController; use App\Http\Controllers\Admin\PipelineAdminController; use App\Http\Controllers\Admin\ProfileController; +use App\Http\Controllers\Admin\QualityDashboardController; +use App\Http\Controllers\Admin\QualitySettingsController; use App\Http\Controllers\Admin\QueueMonitorController; use App\Http\Controllers\Admin\SearchWebController; use App\Http\Controllers\Admin\SettingsAdminController; @@ -107,6 +109,7 @@ Route::get('/personas', [PersonaAdminController::class, 'index'])->name('admin.personas'); Route::patch('/personas/{id}', [PersonaAdminController::class, 'update'])->name('admin.personas.update'); Route::get('/analytics', [AnalyticsController::class, 'index'])->name('admin.analytics'); + Route::get('/quality', [QualityDashboardController::class, 'index'])->name('admin.quality'); // Search Route::get('/search', [SearchWebController::class, 'index'])->name('admin.search'); @@ -120,6 +123,7 @@ Route::post('/settings/models', [SettingsAdminController::class, 'updateModels'])->name('admin.settings.models'); Route::post('/settings/images', [SettingsAdminController::class, 'updateImages'])->name('admin.settings.images'); Route::post('/settings/costs', [SettingsAdminController::class, 'updateCosts'])->name('admin.settings.costs'); + Route::get('/settings/quality', [QualitySettingsController::class, 'index'])->name('admin.settings.quality'); // Queue Monitor Route::get('/queue', [QueueMonitorController::class, 'index'])->name('admin.queue'); diff --git a/tests/Feature/ContentQualityApiTest.php b/tests/Feature/ContentQualityApiTest.php new file mode 100644 index 0000000..9a8ecda --- /dev/null +++ b/tests/Feature/ContentQualityApiTest.php @@ -0,0 +1,177 @@ +seed(RoleSeeder::class); + + $this->space = Space::factory()->create(); + $this->user = User::factory()->create(); + + // Assign editor role globally (covers content.view) + $editor = Role::where('slug', 'editor')->first(); + $this->user->roles()->attach($editor->id, ['space_id' => null]); + } + + // ── GET /api/v1/quality/scores ───────────────────────────────────────── + + public function test_unauthenticated_request_is_rejected(): void + { + $this->getJson('/api/v1/quality/scores?space_id='.$this->space->id) + ->assertUnauthorized(); + } + + public function test_list_scores_returns_empty_for_no_scores(): void + { + $this->actingAs($this->user) + ->getJson('/api/v1/quality/scores?space_id='.$this->space->id) + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + public function test_list_scores_returns_scores_for_space(): void + { + ContentQualityScore::factory()->count(3)->create(['space_id' => $this->space->id]); + // Score for another space should not appear + ContentQualityScore::factory()->create(); + + $this->actingAs($this->user) + ->getJson('/api/v1/quality/scores?space_id='.$this->space->id) + ->assertOk() + ->assertJsonCount(3, 'data'); + } + + public function test_list_scores_filtered_by_content_id(): void + { + $content = Content::factory()->create(['space_id' => $this->space->id]); + ContentQualityScore::factory()->count(2)->create([ + 'space_id' => $this->space->id, + 'content_id' => $content->id, + ]); + ContentQualityScore::factory()->create(['space_id' => $this->space->id]); + + $this->actingAs($this->user) + ->getJson('/api/v1/quality/scores?space_id='.$this->space->id.'&content_id='.$content->id) + ->assertOk() + ->assertJsonCount(2, 'data'); + } + + // ── GET /api/v1/quality/scores/{score} ──────────────────────────────── + + public function test_show_score_returns_score_with_items(): void + { + $score = ContentQualityScore::factory() + ->has(\App\Models\ContentQualityScoreItem::factory()->count(2), 'items') + ->create(['space_id' => $this->space->id]); + + $this->actingAs($this->user) + ->getJson('/api/v1/quality/scores/'.$score->id) + ->assertOk() + ->assertJsonPath('data.id', $score->id) + ->assertJsonCount(2, 'data.items'); + } + + // ── POST /api/v1/quality/score ───────────────────────────────────────── + + public function test_trigger_score_dispatches_job(): void + { + \Illuminate\Support\Facades\Queue::fake(); + + $content = Content::factory()->create(['space_id' => $this->space->id]); + + $this->actingAs($this->user) + ->postJson('/api/v1/quality/score', ['content_id' => $content->id]) + ->assertStatus(202) + ->assertJsonPath('content_id', $content->id); + + \Illuminate\Support\Facades\Queue::assertPushed(\App\Jobs\ScoreContentQualityJob::class); + } + + // ── GET /api/v1/quality/trends ───────────────────────────────────────── + + public function test_trends_returns_data_for_space(): void + { + ContentQualityScore::factory()->count(2)->create(['space_id' => $this->space->id]); + + $this->actingAs($this->user) + ->getJson('/api/v1/quality/trends?space_id='.$this->space->id) + ->assertOk() + ->assertJsonStructure(['data' => ['trends', 'leaderboard', 'distribution', 'period']]); + } + + // ── GET /api/v1/quality/config ───────────────────────────────────────── + + public function test_get_config_creates_default_config_if_none(): void + { + $this->actingAs($this->user) + ->getJson('/api/v1/quality/config?space_id='.$this->space->id) + ->assertSuccessful() + ->assertJsonPath('data.space_id', $this->space->id) + ->assertJsonPath('data.auto_score_on_publish', true) + ->assertJsonPath('data.pipeline_gate_enabled', false); + + $this->assertDatabaseHas('content_quality_configs', ['space_id' => $this->space->id]); + } + + public function test_get_config_returns_existing_config(): void + { + $config = ContentQualityConfig::factory()->create(['space_id' => $this->space->id]); + + $this->actingAs($this->user) + ->getJson('/api/v1/quality/config?space_id='.$this->space->id) + ->assertOk() + ->assertJsonPath('data.id', $config->id); + } + + // ── PUT /api/v1/quality/config ───────────────────────────────────────── + + public function test_update_config_persists_changes(): void + { + // Assign admin role to allow settings.manage + $admin = Role::where('slug', 'admin')->first(); + $this->user->roles()->attach($admin->id, ['space_id' => null]); + + $this->actingAs($this->user) + ->putJson('/api/v1/quality/config', [ + 'space_id' => $this->space->id, + 'pipeline_gate_enabled' => true, + 'pipeline_gate_min_score' => 75, + ]) + ->assertSuccessful() + ->assertJsonPath('data.pipeline_gate_enabled', true) + ->assertJsonPath('data.pipeline_gate_min_score', 75); + } + + public function test_update_config_validates_enabled_dimensions(): void + { + $admin = Role::where('slug', 'admin')->first(); + $this->user->roles()->attach($admin->id, ['space_id' => null]); + + $this->actingAs($this->user) + ->putJson('/api/v1/quality/config', [ + 'space_id' => $this->space->id, + 'enabled_dimensions' => ['invalid_dimension'], + ]) + ->assertUnprocessable(); + } +} diff --git a/tests/Feature/ContentQualityScoringTest.php b/tests/Feature/ContentQualityScoringTest.php new file mode 100644 index 0000000..61f3486 --- /dev/null +++ b/tests/Feature/ContentQualityScoringTest.php @@ -0,0 +1,99 @@ +create(); + + $this->assertDatabaseHas('content_quality_scores', ['id' => $score->id]); + $this->assertNotEmpty($score->id); + $this->assertIsFloat($score->overall_score); + } + + public function test_can_create_content_quality_score_item(): void + { + $score = ContentQualityScore::factory()->create(); + $item = ContentQualityScoreItem::factory()->create(['score_id' => $score->id]); + + $this->assertDatabaseHas('content_quality_score_items', ['id' => $item->id]); + $this->assertEquals($score->id, $item->score_id); + $this->assertContains($item->severity, ['info', 'warning', 'error']); + } + + public function test_can_create_content_quality_config(): void + { + $config = ContentQualityConfig::factory()->create(); + + $this->assertDatabaseHas('content_quality_configs', ['id' => $config->id]); + $this->assertIsArray($config->dimension_weights); + $this->assertIsArray($config->thresholds); + $this->assertIsArray($config->enabled_dimensions); + $this->assertTrue($config->auto_score_on_publish); + $this->assertFalse($config->pipeline_gate_enabled); + $this->assertEquals(70.0, $config->pipeline_gate_min_score); + } + + public function test_score_has_items_relationship(): void + { + $score = ContentQualityScore::factory()->create(); + ContentQualityScoreItem::factory()->count(3)->create(['score_id' => $score->id]); + + $this->assertCount(3, $score->items); + } + + public function test_score_item_belongs_to_score(): void + { + $score = ContentQualityScore::factory()->create(); + $item = ContentQualityScoreItem::factory()->create(['score_id' => $score->id]); + + $this->assertEquals($score->id, $item->score->id); + } + + public function test_score_uses_ulid_primary_key(): void + { + $score = ContentQualityScore::factory()->create(); + + // ULIDs are 26 chars + $this->assertEquals(26, strlen($score->id)); + } + + public function test_config_uses_ulid_primary_key(): void + { + $config = ContentQualityConfig::factory()->create(); + + $this->assertEquals(26, strlen($config->id)); + } + + public function test_score_item_uses_ulid_primary_key(): void + { + $item = ContentQualityScoreItem::factory()->create(); + + $this->assertEquals(26, strlen($item->id)); + } + + public function test_config_with_gate_state(): void + { + $config = ContentQualityConfig::factory()->withGate()->create(); + + $this->assertTrue($config->pipeline_gate_enabled); + $this->assertEquals(75.0, $config->pipeline_gate_min_score); + } + + public function test_passing_score_state(): void + { + $score = ContentQualityScore::factory()->passing()->create(); + + $this->assertGreaterThanOrEqual(70, $score->overall_score); + } +} diff --git a/tests/Feature/ContentQualityServiceTest.php b/tests/Feature/ContentQualityServiceTest.php new file mode 100644 index 0000000..2bb0c23 --- /dev/null +++ b/tests/Feature/ContentQualityServiceTest.php @@ -0,0 +1,268 @@ +create(); + $service = $this->app->make(ContentQualityService::class); + + $score = $service->score($content); + + $this->assertInstanceOf(ContentQualityScore::class, $score); + $this->assertDatabaseHas('content_quality_scores', [ + 'id' => $score->id, + 'content_id' => $content->id, + 'space_id' => $content->space_id, + ]); + $this->assertIsFloat($score->overall_score); + $this->assertGreaterThanOrEqual(0.0, $score->overall_score); + $this->assertLessThanOrEqual(100.0, $score->overall_score); + } + + public function test_score_creates_score_items(): void + { + Event::fake([ContentQualityScored::class]); + Cache::flush(); + + $content = Content::factory()->create(); + $service = $this->app->make(ContentQualityService::class); + + $score = $service->score($content); + + // Score items may or may not be created depending on analyzer output + // but the relationship must be queryable + $this->assertInstanceOf(\Illuminate\Database\Eloquent\Collection::class, $score->items); + } + + public function test_score_fires_content_quality_scored_event(): void + { + Event::fake([ContentQualityScored::class]); + Cache::flush(); + + $content = Content::factory()->create(); + $service = $this->app->make(ContentQualityService::class); + + $service->score($content); + + Event::assertDispatched(ContentQualityScored::class, function (ContentQualityScored $event) use ($content) { + return $event->score->content_id === $content->id; + }); + } + + // ────────────────────────────────────────────────────────────── + // Weighted scoring + // ────────────────────────────────────────────────────────────── + + public function test_weighted_scoring_uses_config_dimension_weights(): void + { + Event::fake([ContentQualityScored::class]); + Cache::flush(); + + $space = Space::factory()->create(); + $content = Content::factory()->for($space)->create(); + + // Config with readability heavily weighted + $config = ContentQualityConfig::factory()->create([ + 'space_id' => $space->id, + 'dimension_weights' => [ + 'readability' => 1.0, + 'seo' => 0.0, + 'brand_consistency' => 0.0, + 'factual_accuracy' => 0.0, + 'engagement_prediction' => 0.0, + ], + 'enabled_dimensions' => ['readability', 'seo', 'brand_consistency', 'factual_accuracy', 'engagement_prediction'], + ]); + + $service = $this->app->make(ContentQualityService::class); + $score = $service->score($content, $config); + + // Overall score should equal the readability score (weight=1.0, all others=0.0) + $this->assertEqualsWithDelta($score->readability_score ?? 0, $score->overall_score, 0.5); + } + + // ────────────────────────────────────────────────────────────── + // Cache behaviour + // ────────────────────────────────────────────────────────────── + + public function test_score_is_cached_after_first_call(): void + { + Event::fake([ContentQualityScored::class]); + Cache::flush(); + + $content = Content::factory()->create(); + $service = $this->app->make(ContentQualityService::class); + + $first = $service->score($content); + + $this->assertTrue(Cache::has("quality:content:{$content->id}")); + + $second = $service->score($content); + + // Same object returned from cache + $this->assertEquals($first->id, $second->id); + + // Event dispatched only once (cache hit skips re-scoring) + Event::assertDispatchedTimes(ContentQualityScored::class, 1); + } + + public function test_invalidate_clears_cache(): void + { + Event::fake([ContentQualityScored::class]); + Cache::flush(); + + $content = Content::factory()->create(); + $service = $this->app->make(ContentQualityService::class); + + $service->score($content); + $this->assertTrue(Cache::has("quality:content:{$content->id}")); + + $service->invalidate($content); + $this->assertFalse(Cache::has("quality:content:{$content->id}")); + } + + // ────────────────────────────────────────────────────────────── + // Job + // ────────────────────────────────────────────────────────────── + + public function test_job_can_be_dispatched(): void + { + Queue::fake(); + + $content = Content::factory()->create(); + + ScoreContentQualityJob::dispatch($content->id); + + Queue::assertPushed(ScoreContentQualityJob::class, function (ScoreContentQualityJob $job) use ($content) { + return $job->contentId === $content->id && $job->configId === null; + }); + } + + public function test_job_dispatched_on_quality_queue(): void + { + Queue::fake(); + + $content = Content::factory()->create(); + + ScoreContentQualityJob::dispatch($content->id); + + Queue::assertPushedOn('quality', ScoreContentQualityJob::class); + } + + public function test_job_executes_scoring_synchronously(): void + { + Event::fake([ContentQualityScored::class]); + Cache::flush(); + + $content = Content::factory()->create(); + + // Run synchronously (no queue) + (new ScoreContentQualityJob($content->id))->handle( + $this->app->make(ContentQualityService::class) + ); + + $this->assertDatabaseHas('content_quality_scores', ['content_id' => $content->id]); + } + + // ────────────────────────────────────────────────────────────── + // Trend aggregation + // ────────────────────────────────────────────────────────────── + + public function test_get_space_trends_returns_daily_averages(): void + { + $space = Space::factory()->create(); + + // Two scores on same day + ContentQualityScore::factory()->create([ + 'space_id' => $space->id, + 'overall_score' => 60.0, + 'scored_at' => Carbon::parse('2025-01-01 12:00:00'), + ]); + ContentQualityScore::factory()->create([ + 'space_id' => $space->id, + 'overall_score' => 80.0, + 'scored_at' => Carbon::parse('2025-01-01 14:00:00'), + ]); + + $aggregator = $this->app->make(QualityTrendAggregator::class); + $trends = $aggregator->getSpaceTrends( + $space, + Carbon::parse('2025-01-01'), + Carbon::parse('2025-01-01'), + ); + + $this->assertArrayHasKey('2025-01-01', $trends); + $this->assertEqualsWithDelta(70.0, $trends['2025-01-01']['overall'], 0.5); + $this->assertEquals(2, $trends['2025-01-01']['total']); + } + + public function test_get_space_leaderboard_returns_top_content(): void + { + $space = Space::factory()->create(); + + ContentQualityScore::factory()->create([ + 'space_id' => $space->id, + 'overall_score' => 90.0, + ]); + ContentQualityScore::factory()->create([ + 'space_id' => $space->id, + 'overall_score' => 50.0, + ]); + + $aggregator = $this->app->make(QualityTrendAggregator::class); + $leaderboard = $aggregator->getSpaceLeaderboard($space, 5); + + $this->assertCount(2, $leaderboard); + $this->assertGreaterThan( + $leaderboard->last()->overall_score, + $leaderboard->first()->overall_score, + ); + } + + public function test_get_dimension_distribution_returns_histogram(): void + { + $space = Space::factory()->create(); + + ContentQualityScore::factory()->create([ + 'space_id' => $space->id, + 'overall_score' => 55.0, + 'readability_score' => 55.0, + ]); + + $aggregator = $this->app->make(QualityTrendAggregator::class); + $distribution = $aggregator->getDimensionDistribution($space); + + $this->assertArrayHasKey('overall', $distribution); + $this->assertArrayHasKey('readability', $distribution); + + // Score of 55 falls in 50-60 bucket + $this->assertGreaterThanOrEqual(1, $distribution['overall']['50-60']); + } +} diff --git a/tests/Feature/QualityPipelineIntegrationTest.php b/tests/Feature/QualityPipelineIntegrationTest.php new file mode 100644 index 0000000..a1e52f4 --- /dev/null +++ b/tests/Feature/QualityPipelineIntegrationTest.php @@ -0,0 +1,199 @@ +space = Space::factory()->create(); + ContentType::create([ + 'space_id' => $this->space->id, + 'name' => 'Blog Post', + 'slug' => 'blog_post', + 'schema' => ['fields' => []], + ]); + } + + // ── Auto-score on publish ────────────────────────────────────────────── + + public function test_auto_score_listener_dispatches_job_when_config_enabled(): void + { + Queue::fake(); + + $content = Content::factory()->create(['space_id' => $this->space->id]); + ContentQualityConfig::factory()->create([ + 'space_id' => $this->space->id, + 'auto_score_on_publish' => true, + ]); + + $listener = app(AutoScoreOnPublishListener::class); + $listener->handle(new ContentPublished($content)); + + Queue::assertPushed(ScoreContentQualityJob::class, fn ($job) => $job->contentId === $content->id); + } + + public function test_auto_score_listener_does_not_dispatch_when_config_disabled(): void + { + Queue::fake(); + + $content = Content::factory()->create(['space_id' => $this->space->id]); + ContentQualityConfig::factory()->create([ + 'space_id' => $this->space->id, + 'auto_score_on_publish' => false, + ]); + + $listener = app(AutoScoreOnPublishListener::class); + $listener->handle(new ContentPublished($content)); + + Queue::assertNotPushed(ScoreContentQualityJob::class); + } + + public function test_auto_score_listener_dispatches_when_no_config(): void + { + Queue::fake(); + + $content = Content::factory()->create(['space_id' => $this->space->id]); + + $listener = app(AutoScoreOnPublishListener::class); + $listener->handle(new ContentPublished($content)); + + Queue::assertPushed(ScoreContentQualityJob::class); + } + + // ── QualityGateStageJob ──────────────────────────────────────────────── + + public function test_quality_gate_advances_pipeline_when_score_passes(): void + { + $content = Content::factory()->create(['space_id' => $this->space->id]); + + $run = PipelineRun::factory()->create([ + 'content_id' => $content->id, + 'status' => 'running', + ]); + + $passScore = ContentQualityScore::factory()->make([ + 'space_id' => $this->space->id, + 'content_id' => $content->id, + 'overall_score' => 85.0, + ]); + + $qualityService = Mockery::mock(ContentQualityService::class); + $qualityService->shouldReceive('score')->once()->andReturn($passScore); + + $executor = Mockery::mock(PipelineExecutor::class); + $executor->shouldReceive('advance')->once()->with( + Mockery::on(fn ($r) => $r->id === $run->id), + Mockery::on(fn ($result) => $result['quality_gate_passed'] === true) + ); + + $job = new QualityGateStageJob($run, ['name' => 'quality_gate', 'type' => 'quality_gate', 'min_score' => 70]); + $job->handle($qualityService, $executor); + } + + public function test_quality_gate_pauses_pipeline_when_score_fails(): void + { + $content = Content::factory()->create(['space_id' => $this->space->id]); + + $run = PipelineRun::factory()->create([ + 'content_id' => $content->id, + 'status' => 'running', + ]); + + $failScore = ContentQualityScore::factory()->make([ + 'space_id' => $this->space->id, + 'content_id' => $content->id, + 'overall_score' => 45.0, + ]); + + $qualityService = Mockery::mock(ContentQualityService::class); + $qualityService->shouldReceive('score')->once()->andReturn($failScore); + + $executor = Mockery::mock(PipelineExecutor::class); + $executor->shouldNotReceive('advance'); + + $job = new QualityGateStageJob($run, ['name' => 'quality_gate', 'type' => 'quality_gate', 'min_score' => 70]); + $job->handle($qualityService, $executor); + + $run->refresh(); + $this->assertEquals('paused_for_review', $run->status); + } + + public function test_quality_gate_uses_config_min_score_when_no_stage_override(): void + { + $content = Content::factory()->create(['space_id' => $this->space->id]); + ContentQualityConfig::factory()->withGate()->create([ + 'space_id' => $this->space->id, + ]); + + $run = PipelineRun::factory()->create([ + 'content_id' => $content->id, + 'status' => 'running', + ]); + + $score = ContentQualityScore::factory()->make([ + 'space_id' => $this->space->id, + 'content_id' => $content->id, + 'overall_score' => 80.0, + ]); + + $qualityService = Mockery::mock(ContentQualityService::class); + $qualityService->shouldReceive('score')->once()->andReturn($score); + + $executor = Mockery::mock(PipelineExecutor::class); + $executor->shouldReceive('advance')->once()->with( + Mockery::any(), + Mockery::on(fn ($result) => $result['quality_gate_passed'] === true) + ); + + // No min_score in stage config — uses config's pipeline_gate_min_score (75.0) + $job = new QualityGateStageJob($run, ['name' => 'quality_gate', 'type' => 'quality_gate']); + $job->handle($qualityService, $executor); + } + + // ── PipelineExecutor dispatches quality_gate stage ──────────────────── + + public function test_pipeline_executor_dispatches_quality_gate_job(): void + { + Queue::fake(); + + $run = PipelineRun::factory()->create([ + 'status' => 'running', + 'current_stage' => 'quality_gate', + ]); + + $stage = ['name' => 'quality_gate', 'type' => 'quality_gate']; + + $executor = app(PipelineExecutor::class); + + // Use reflection to call private dispatchCoreStage + $ref = new \ReflectionClass($executor); + $method = $ref->getMethod('dispatchCoreStage'); + $method->setAccessible(true); + $method->invoke($executor, $run, $stage); + + Queue::assertPushed(QualityGateStageJob::class); + } +} diff --git a/tests/Unit/CompetitorModelsTest.php b/tests/Unit/CompetitorModelsTest.php new file mode 100644 index 0000000..a15600e --- /dev/null +++ b/tests/Unit/CompetitorModelsTest.php @@ -0,0 +1,141 @@ +create(); + + $this->assertNotNull($source->id); + $this->assertDatabaseHas('competitor_sources', ['id' => $source->id]); + } + + public function test_competitor_source_has_many_content_items(): void + { + $source = CompetitorSource::factory()->create(); + CompetitorContentItem::factory()->count(3)->create(['source_id' => $source->id]); + + $this->assertCount(3, $source->contentItems); + } + + public function test_competitor_content_item_factory_creates(): void + { + $item = CompetitorContentItem::factory()->create(); + + $this->assertNotNull($item->id); + $this->assertDatabaseHas('competitor_content_items', ['id' => $item->id]); + } + + public function test_competitor_content_item_belongs_to_source(): void + { + $source = CompetitorSource::factory()->create(); + $item = CompetitorContentItem::factory()->create(['source_id' => $source->id]); + + $this->assertEquals($source->id, $item->source->id); + } + + public function test_content_fingerprint_factory_creates(): void + { + $fingerprint = ContentFingerprint::factory()->create(); + + $this->assertNotNull($fingerprint->id); + $this->assertDatabaseHas('content_fingerprints', ['id' => $fingerprint->id]); + } + + public function test_content_fingerprint_morphable_relation(): void + { + $item = CompetitorContentItem::factory()->create(); + $fingerprint = ContentFingerprint::factory()->create([ + 'fingerprintable_type' => CompetitorContentItem::class, + 'fingerprintable_id' => $item->id, + ]); + + $this->assertEquals($item->id, $fingerprint->fingerprintable->id); + $this->assertEquals($fingerprint->id, $item->fingerprint->id); + } + + public function test_differentiation_analysis_factory_creates(): void + { + $analysis = DifferentiationAnalysis::factory()->create(); + + $this->assertNotNull($analysis->id); + $this->assertDatabaseHas('differentiation_analyses', ['id' => $analysis->id]); + } + + public function test_differentiation_analysis_belongs_to_competitor_content(): void + { + $item = CompetitorContentItem::factory()->create(); + $analysis = DifferentiationAnalysis::factory()->create(['competitor_content_id' => $item->id]); + + $this->assertEquals($item->id, $analysis->competitorContent->id); + } + + public function test_competitor_alert_factory_creates(): void + { + $alert = CompetitorAlert::factory()->create(); + + $this->assertNotNull($alert->id); + $this->assertDatabaseHas('competitor_alerts', ['id' => $alert->id]); + } + + public function test_competitor_alert_has_many_events(): void + { + $alert = CompetitorAlert::factory()->create(); + CompetitorAlertEvent::factory()->count(2)->create(['alert_id' => $alert->id]); + + $this->assertCount(2, $alert->events); + } + + public function test_competitor_alert_event_factory_creates(): void + { + $event = CompetitorAlertEvent::factory()->create(); + + $this->assertNotNull($event->id); + $this->assertDatabaseHas('competitor_alert_events', ['id' => $event->id]); + } + + public function test_competitor_alert_event_belongs_to_alert(): void + { + $alert = CompetitorAlert::factory()->create(); + $event = CompetitorAlertEvent::factory()->create(['alert_id' => $alert->id]); + + $this->assertEquals($alert->id, $event->alert->id); + } + + public function test_competitor_alert_event_belongs_to_competitor_content(): void + { + $item = CompetitorContentItem::factory()->create(); + $event = CompetitorAlertEvent::factory()->create(['competitor_content_id' => $item->id]); + + $this->assertEquals($item->id, $event->competitorContent->id); + } + + public function test_competitor_source_soft_deletes(): void + { + $source = CompetitorSource::factory()->create(); + $source->delete(); + + $this->assertSoftDeleted('competitor_sources', ['id' => $source->id]); + } + + public function test_competitor_alert_soft_deletes(): void + { + $alert = CompetitorAlert::factory()->create(); + $alert->delete(); + + $this->assertSoftDeleted('competitor_alerts', ['id' => $alert->id]); + } +} diff --git a/tests/Unit/Quality/BrandConsistencyAnalyzerTest.php b/tests/Unit/Quality/BrandConsistencyAnalyzerTest.php new file mode 100644 index 0000000..59ef24b --- /dev/null +++ b/tests/Unit/Quality/BrandConsistencyAnalyzerTest.php @@ -0,0 +1,211 @@ +createMock(LLMManager::class); + + if ($jsonResponse !== null) { + $mock->method('complete')->willReturn(new LLMResponse( + content: $jsonResponse, + model: 'claude-haiku-4-5-20251001', + provider: 'anthropic', + inputTokens: 100, + outputTokens: 50, + costUsd: 0.001, + latencyMs: 200, + )); + } + + return $mock; + } + + private function makeContent(string $body): Content + { + $version = new class($body) extends ContentVersion + { + /** @phpstan-ignore-next-line */ + public function __construct(public string $body) {} + }; + + return new class($version) extends Content + { + public ?ContentVersion $currentVersion; + + /** @phpstan-ignore-next-line */ + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct(ContentVersion $v) + { + $this->currentVersion = $v; + } + + public function pipelineRuns(): \Illuminate\Database\Eloquent\Relations\HasMany + { + /** @phpstan-ignore-next-line */ + return new class extends \Illuminate\Database\Eloquent\Relations\HasMany + { + /** @phpstan-ignore-next-line */ + public function __construct() {} + + /** @phpstan-ignore-next-line */ + public function with($relations): static + { + return $this; + } + + /** @phpstan-ignore-next-line */ + public function latest(?string $column = null): static + { + return $this; + } + + /** @phpstan-ignore-next-line */ + public function first($columns = ['*']): ?object + { + return null; + } + }; + } + }; + } + + private function makeNoVersion(): Content + { + return new class extends Content + { + public ?ContentVersion $currentVersion = null; + + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct() {} + }; + } + + public function test_returns_error_when_no_version(): void + { + $analyzer = new BrandConsistencyAnalyzer($this->makeLLM()); + $result = $analyzer->analyze($this->makeNoVersion()); + + $this->assertSame(0.0, $result->getScore()); + $this->assertCount(1, $result->getItems()); + $this->assertSame('error', $result->getItems()[0]['type']); + } + + public function test_returns_heuristic_fallback_when_no_persona(): void + { + $llm = $this->createMock(LLMManager::class); + $llm->expects($this->never())->method('complete'); + + $analyzer = new BrandConsistencyAnalyzer($llm); + $content = $this->makeContent('We love helping our customers succeed every single day. Our team is dedicated to excellence.'); + $result = $analyzer->analyze($content); + + $this->assertGreaterThan(0, $result->getScore()); + $this->assertSame('heuristic', $result->getMetadata()['source']); + } + + public function test_parses_llm_response_correctly(): void + { + $json = json_encode([ + 'score' => 87, + 'tone_consistency' => 90, + 'vocabulary_alignment' => 85, + 'brand_voice_adherence' => 86, + 'deviations' => [ + [ + 'type' => 'tone', + 'message' => 'Slightly too casual in paragraph 2.', + 'suggestion' => 'Replace "gonna" with "going to".', + ], + ], + 'summary' => 'Content aligns well with brand voice.', + ]); + + $analyzer = new BrandConsistencyAnalyzer($this->makeLLM($json)); + + $ref = new ReflectionMethod(BrandConsistencyAnalyzer::class, 'buildResultFromLLMData'); + $ref->setAccessible(true); + + /** @var array $data */ + $data = json_decode((string) $json, true); + $result = $ref->invoke($analyzer, $data); + + $this->assertSame(87.0, $result->getScore()); + $this->assertCount(1, $result->getItems()); + $this->assertSame('llm', $result->getMetadata()['source']); + $this->assertSame(90.0, $result->getMetadata()['tone_consistency']); + } + + public function test_llm_response_without_deviations_is_clean(): void + { + $json = json_encode([ + 'score' => 95, + 'tone_consistency' => 96, + 'vocabulary_alignment' => 94, + 'brand_voice_adherence' => 95, + 'deviations' => [], + 'summary' => 'Perfect brand alignment.', + ]); + + $analyzer = new BrandConsistencyAnalyzer($this->makeLLM($json)); + + $ref = new ReflectionMethod(BrandConsistencyAnalyzer::class, 'buildResultFromLLMData'); + $ref->setAccessible(true); + + /** @var array $data */ + $data = json_decode((string) $json, true); + $result = $ref->invoke($analyzer, $data); + + $this->assertSame(95.0, $result->getScore()); + $this->assertEmpty($result->getItems()); + } + + public function test_heuristic_penalizes_mixed_voice(): void + { + $llm = $this->createMock(LLMManager::class); + $analyzer = new BrandConsistencyAnalyzer($llm); + + $ref = new ReflectionMethod(BrandConsistencyAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textMixedVoice = 'We are committed to helping our users. He said they should try it. I believe in our mission. Their team is great. Our company leads the way.'; + $result = $ref->invoke($analyzer, $textMixedVoice, 'Test fallback'); + + $this->assertLessThan(75, $result->getScore()); + } + + public function test_heuristic_info_item_contains_reason(): void + { + $llm = $this->createMock(LLMManager::class); + $analyzer = new BrandConsistencyAnalyzer($llm); + + $ref = new ReflectionMethod(BrandConsistencyAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $result = $ref->invoke($analyzer, 'Short clean text.', 'No persona assigned — using heuristic fallback.'); + + $messages = array_column($result->getItems(), 'message'); + $this->assertContains('No persona assigned — using heuristic fallback.', $messages); + } + + public function test_dimension_and_weight(): void + { + $analyzer = new BrandConsistencyAnalyzer($this->makeLLM()); + $this->assertSame('brand_consistency', $analyzer->getDimension()); + $this->assertSame(0.20, $analyzer->getWeight()); + } +} diff --git a/tests/Unit/Quality/EngagementPredictionAnalyzerTest.php b/tests/Unit/Quality/EngagementPredictionAnalyzerTest.php new file mode 100644 index 0000000..75f0551 --- /dev/null +++ b/tests/Unit/Quality/EngagementPredictionAnalyzerTest.php @@ -0,0 +1,173 @@ +createMock(LLMManager::class); + $mock->method('complete')->willReturn(new LLMResponse( + content: $jsonResponse, + model: 'claude-haiku-4-5-20251001', + provider: 'anthropic', + inputTokens: 120, + outputTokens: 180, + costUsd: 0.002, + latencyMs: 250, + )); + + return $mock; + } + + private function makeNoVersion(): Content + { + return new class extends Content + { + public ?ContentVersion $currentVersion = null; + + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct() {} + }; + } + + public function test_returns_error_when_no_version(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + $result = $analyzer->analyze($this->makeNoVersion()); + + $this->assertSame(0.0, $result->getScore()); + $this->assertSame('error', $result->getItems()[0]['type']); + } + + public function test_parses_llm_response_correctly(): void + { + $json = json_encode([ + 'score' => 82, + 'headline_strength' => 85, + 'hook_quality' => 80, + 'emotional_resonance' => 75, + 'cta_effectiveness' => 88, + 'shareability' => 82, + 'factors' => [ + [ + 'factor' => 'headline_strength', + 'score' => 85, + 'observation' => 'Strong headline with power words.', + 'suggestion' => null, + ], + [ + 'factor' => 'emotional_resonance', + 'score' => 45, + 'observation' => 'Limited emotional language.', + 'suggestion' => 'Add more emotional hooks.', + ], + ], + 'summary' => 'High engagement potential with strong headline.', + ]); + + $ref = new ReflectionMethod(EngagementPredictionAnalyzer::class, 'buildResultFromLLMData'); + $ref->setAccessible(true); + + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + + /** @var array $data */ + $data = json_decode((string) $json, true); + $result = $ref->invoke($analyzer, $data); + + $this->assertSame(82.0, $result->getScore()); + $this->assertSame(85.0, $result->getMetadata()['headline_strength']); + $this->assertSame('llm', $result->getMetadata()['source']); + // Only the low-scoring factor (45) should generate an item + $this->assertCount(1, $result->getItems()); + $this->assertStringContainsString('emotional_resonance', $result->getItems()[0]['message']); + } + + public function test_heuristic_detects_cta(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(EngagementPredictionAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textWithCta = 'Discover the top 10 secrets to success. Amazing results await you. Sign up today to get started!'; + $result = $ref->invoke($analyzer, $textWithCta, 'Test'); + + $this->assertSame('heuristic', $result->getMetadata()['source']); + $this->assertGreaterThan(50, $result->getMetadata()['cta_effectiveness']); + // Should not have a CTA warning + $messages = array_column($result->getItems(), 'message'); + $ctaWarning = array_filter($messages, fn ($m) => str_contains($m, 'call-to-action')); + $this->assertEmpty($ctaWarning); + } + + public function test_heuristic_warns_missing_cta(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(EngagementPredictionAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textNoCta = 'This is a very long article about nothing in particular. It contains many sentences but lacks any direction or purpose.'; + $result = $ref->invoke($analyzer, $textNoCta, 'Test'); + + $messages = array_column($result->getItems(), 'message'); + $ctaWarning = array_filter($messages, fn ($m) => str_contains($m, 'call-to-action')); + $this->assertNotEmpty($ctaWarning); + } + + public function test_falls_back_on_llm_exception(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(EngagementPredictionAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $result = $ref->invoke($analyzer, 'How to succeed in 2024. Amazing tips inside! Sign up now.', 'LLM unavailable — using heuristic fallback.'); + $this->assertSame('heuristic', $result->getMetadata()['source']); + $this->assertGreaterThan(0, $result->getScore()); + } + + public function test_heuristic_penalizes_short_headline(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(EngagementPredictionAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textShortHeadline = "Hi\nThis is the body with lots of content and words to make it seem like a real article."; + $result = $ref->invoke($analyzer, $textShortHeadline, 'Test'); + + $this->assertLessThan(50, $result->getMetadata()['headline_strength']); + } + + public function test_heuristic_rewards_optimal_headline(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(EngagementPredictionAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textGoodHeadline = "How to Build the Best Content Strategy\nThis is the body with details about content strategy."; + $result = $ref->invoke($analyzer, $textGoodHeadline, 'Test'); + + $this->assertGreaterThanOrEqual(70, $result->getMetadata()['headline_strength']); + } + + public function test_dimension_and_weight(): void + { + $analyzer = new EngagementPredictionAnalyzer($this->createMock(LLMManager::class)); + $this->assertSame('engagement_prediction', $analyzer->getDimension()); + $this->assertSame(0.20, $analyzer->getWeight()); + } +} diff --git a/tests/Unit/Quality/FactualAccuracyAnalyzerTest.php b/tests/Unit/Quality/FactualAccuracyAnalyzerTest.php new file mode 100644 index 0000000..a13d8bc --- /dev/null +++ b/tests/Unit/Quality/FactualAccuracyAnalyzerTest.php @@ -0,0 +1,177 @@ +createMock(LLMManager::class); + $mock->method('complete')->willReturn(new LLMResponse( + content: $jsonResponse, + model: 'claude-haiku-4-5-20251001', + provider: 'anthropic', + inputTokens: 150, + outputTokens: 200, + costUsd: 0.002, + latencyMs: 300, + )); + + return $mock; + } + + private function makeContent(string $body): Content + { + $version = new class($body) extends ContentVersion + { + /** @phpstan-ignore-next-line */ + public function __construct(public string $body) {} + }; + + return new class($version) extends Content + { + public ?ContentVersion $currentVersion; + + /** @phpstan-ignore-next-line */ + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct(ContentVersion $v) + { + $this->currentVersion = $v; + } + }; + } + + private function makeNoVersion(): Content + { + return new class extends Content + { + public ?ContentVersion $currentVersion = null; + + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct() {} + }; + } + + public function test_returns_error_when_no_version(): void + { + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + $result = $analyzer->analyze($this->makeNoVersion()); + + $this->assertSame(0.0, $result->getScore()); + $this->assertSame('error', $result->getItems()[0]['type']); + } + + public function test_parses_llm_response_correctly(): void + { + $json = json_encode([ + 'score' => 78, + 'verifiable_claims_ratio' => 0.75, + 'has_source_citations' => true, + 'claims' => [ + ['claim' => 'The company was founded in 2010.', 'verifiable' => true, 'confidence' => 0.9, 'issue' => null, 'suggestion' => null], + ['claim' => 'Revenue grew by 500%.', 'verifiable' => false, 'confidence' => 0.3, 'issue' => 'No source for this statistic.', 'suggestion' => 'Cite the source.'], + ], + 'summary' => 'Mostly accurate with one unsupported claim.', + ]); + + $ref = new ReflectionMethod(FactualAccuracyAnalyzer::class, 'buildResultFromLLMData'); + $ref->setAccessible(true); + + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + + /** @var array $data */ + $data = json_decode((string) $json, true); + $result = $ref->invoke($analyzer, $data); + + $this->assertSame(78.0, $result->getScore()); + $this->assertSame(0.75, $result->getMetadata()['verifiable_claims_ratio']); + $this->assertTrue($result->getMetadata()['has_source_citations']); + $this->assertSame('llm', $result->getMetadata()['source']); + // One unverifiable claim should generate an item + $this->assertCount(1, $result->getItems()); + $this->assertSame('error', $result->getItems()[0]['type']); + } + + public function test_adds_citation_warning_when_no_citations(): void + { + $json = json_encode([ + 'score' => 60, + 'verifiable_claims_ratio' => 0.5, + 'has_source_citations' => false, + 'claims' => [ + ['claim' => 'Example claim.', 'verifiable' => true, 'confidence' => 0.8, 'issue' => null, 'suggestion' => null], + ], + 'summary' => 'Missing citations.', + ]); + + $ref = new ReflectionMethod(FactualAccuracyAnalyzer::class, 'buildResultFromLLMData'); + $ref->setAccessible(true); + + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + + /** @var array $data */ + $data = json_decode((string) $json, true); + $result = $ref->invoke($analyzer, $data); + + $types = array_column($result->getItems(), 'type'); + $this->assertContains('warning', $types); + } + + public function test_heuristic_fallback_detects_citations(): void + { + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(FactualAccuracyAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textWithCitation = 'According to a 2023 study, 75% of users prefer mobile apps. See https://example.com/study'; + $result = $ref->invoke($analyzer, $textWithCitation, 'Test'); + + $this->assertTrue($result->getMetadata()['has_source_citations']); + $this->assertGreaterThanOrEqual(60, $result->getScore()); + } + + public function test_heuristic_fallback_penalizes_missing_citations(): void + { + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(FactualAccuracyAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $textWithoutCitation = 'Revenue grew by 300%. Profits increased by 500%. The market expanded by 200% in 2022. User growth hit 1000%. Customer satisfaction improved by 150%.'; + $result = $ref->invoke($analyzer, $textWithoutCitation, 'Test'); + + $this->assertFalse($result->getMetadata()['has_source_citations']); + $this->assertLessThan(60, $result->getScore()); + } + + public function test_falls_back_on_llm_exception(): void + { + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + + $ref = new ReflectionMethod(FactualAccuracyAnalyzer::class, 'heuristicFallback'); + $ref->setAccessible(true); + + $result = $ref->invoke($analyzer, 'Some content with facts.', 'LLM unavailable — using heuristic fallback.'); + $this->assertSame('heuristic', $result->getMetadata()['source']); + } + + public function test_dimension_and_weight(): void + { + $analyzer = new FactualAccuracyAnalyzer($this->createMock(LLMManager::class)); + $this->assertSame('factual_accuracy', $analyzer->getDimension()); + $this->assertSame(0.20, $analyzer->getWeight()); + } +} diff --git a/tests/Unit/Quality/QualityDimensionResultTest.php b/tests/Unit/Quality/QualityDimensionResultTest.php new file mode 100644 index 0000000..89fd837 --- /dev/null +++ b/tests/Unit/Quality/QualityDimensionResultTest.php @@ -0,0 +1,35 @@ +assertSame(100.0, QualityDimensionResult::make(150)->getScore()); + $this->assertSame(0.0, QualityDimensionResult::make(-10)->getScore()); + } + + public function test_stores_items_and_metadata(): void + { + $r = QualityDimensionResult::make(75.0, [['type' => 'info', 'message' => 'ok']], ['wc' => 100]); + $this->assertSame(75.0, $r->getScore()); + $this->assertCount(1, $r->getItems()); + $this->assertSame(100, $r->getMetadata()['wc']); + } + + public function test_count_by_type(): void + { + $r = QualityDimensionResult::make(50.0, [ + ['type' => 'info', 'message' => 'a'], + ['type' => 'warning', 'message' => 'b'], + ['type' => 'error', 'message' => 'c'], + ]); + $this->assertSame(1, $r->countByType('info')); + $this->assertSame(1, $r->countByType('warning')); + $this->assertSame(1, $r->countByType('error')); + } +} diff --git a/tests/Unit/Quality/ReadabilityAnalyzerTest.php b/tests/Unit/Quality/ReadabilityAnalyzerTest.php new file mode 100644 index 0000000..ed7d11d --- /dev/null +++ b/tests/Unit/Quality/ReadabilityAnalyzerTest.php @@ -0,0 +1,111 @@ +analyzer = new ReadabilityAnalyzer; + } + + private function makeContent(string $body): Content + { + $version = new class($body) extends ContentVersion + { + /** @phpstan-ignore-next-line */ + public function __construct(public string $body) {} + }; + + return new class($version) extends Content + { + public ?ContentVersion $currentVersion; + + /** @phpstan-ignore-next-line */ + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct(ContentVersion $v) + { + $this->currentVersion = $v; + } + }; + } + + private function makeNoVersion(): Content + { + return new class extends Content + { + public ?ContentVersion $currentVersion = null; + + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct() {} + }; + } + + public function test_returns_error_when_no_version(): void + { + $r = $this->analyzer->analyze($this->makeNoVersion()); + $this->assertSame(0.0, $r->getScore()); + $this->assertSame(1, $r->countByType('error')); + } + + public function test_returns_error_for_empty_body(): void + { + $r = $this->analyzer->analyze($this->makeContent('')); + $this->assertSame(0.0, $r->getScore()); + $this->assertSame(1, $r->countByType('error')); + } + + public function test_dimension_and_weight(): void + { + $this->assertSame('readability', $this->analyzer->getDimension()); + $this->assertEqualsWithDelta(0.20, $this->analyzer->getWeight(), 0.001); + } + + public function test_simple_text_scores_high(): void + { + $r = $this->analyzer->analyze($this->makeContent('The cat sat. It was fat. The cat ate a rat.')); + $this->assertGreaterThan(60.0, $r->getScore()); + $this->assertArrayHasKey('flesch_score', $r->getMetadata()); + } + + public function test_metadata_keys(): void + { + $r = $this->analyzer->analyze($this->makeContent('This is a test. It has two.')); + + foreach (['word_count', 'sentence_count', 'paragraph_count', 'flesch_score'] as $key) { + $this->assertArrayHasKey($key, $r->getMetadata()); + } + } + + public function test_passive_voice_triggers_warning(): void + { + $s = str_repeat('The report was reviewed by the team. ', 5).str_repeat('We write active sentences. ', 5); + $r = $this->analyzer->analyze($this->makeContent($s)); + $this->assertContains('warning', array_column($r->getItems(), 'type')); + } + + public function test_html_is_stripped(): void + { + $r = $this->analyzer->analyze($this->makeContent('

Quick brown fox.

Lazy dog jumps.

')); + $this->assertGreaterThan(0.0, $r->getScore()); + } + + public function test_score_in_range(): void + { + $r = $this->analyzer->analyze($this->makeContent('Short text.')); + $this->assertGreaterThanOrEqual(0.0, $r->getScore()); + $this->assertLessThanOrEqual(100.0, $r->getScore()); + } +} diff --git a/tests/Unit/Quality/SeoAnalyzerTest.php b/tests/Unit/Quality/SeoAnalyzerTest.php new file mode 100644 index 0000000..1ae6629 --- /dev/null +++ b/tests/Unit/Quality/SeoAnalyzerTest.php @@ -0,0 +1,135 @@ +analyzer = new SeoAnalyzer; + } + + /** @param array $sd */ + private function makeContent(string $title, string $meta, string $body, array $sd = []): Content + { + $version = new class($title, $meta, $body, $sd) extends ContentVersion + { + /** @param array $seoData */ + /** @phpstan-ignore-next-line */ + public function __construct( + public string $title, + public string $meta_description, + public string $body, + public array $seo_data = [], + ) {} + }; + + return new class($version) extends Content + { + public ?ContentVersion $currentVersion; + + /** @phpstan-ignore-next-line */ + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct(ContentVersion $v) + { + $this->currentVersion = $v; + } + }; + } + + private function makeNoVersion(): Content + { + return new class extends Content + { + public ?ContentVersion $currentVersion = null; + + public ?ContentVersion $draftVersion = null; + + /** @phpstan-ignore-next-line */ + public function __construct() {} + }; + } + + public function test_dimension_and_weight(): void + { + $this->assertSame('seo', $this->analyzer->getDimension()); + $this->assertEqualsWithDelta(0.25, $this->analyzer->getWeight(), 0.001); + } + + public function test_returns_error_when_no_version(): void + { + $result = $this->analyzer->analyze($this->makeNoVersion()); + $this->assertSame(0.0, $result->getScore()); + $this->assertSame(1, $result->countByType('error')); + } + + public function test_good_content_scores_reasonably(): void + { + $title = 'How To Build a Modern CMS With Laravel Easily'; + $meta = 'This is an optimal meta description with over one hundred chars here for the test purpose.'; + $body = '

Main heading

Sub

Content internal.

'; + $result = $this->analyzer->analyze($this->makeContent($title, $meta, $body)); + $this->assertGreaterThan(30.0, $result->getScore()); + } + + public function test_missing_title_gives_error(): void + { + $result = $this->analyzer->analyze($this->makeContent('', '', '

body

')); + $this->assertContains('error', array_column($result->getItems(), 'type')); + } + + public function test_missing_h1_gives_error(): void + { + $body = '

Sub heading only

No H1 here.

'; + $result = $this->analyzer->analyze($this->makeContent('Title here', '', $body)); + $this->assertContains('error', array_column($result->getItems(), 'type')); + } + + public function test_multiple_h1_gives_warning(): void + { + $body = '

First H1

Second H1

Content here.

'; + $result = $this->analyzer->analyze($this->makeContent('Title with chars', '', $body)); + $this->assertContains('warning', array_column($result->getItems(), 'type')); + } + + public function test_images_without_alt_flagged(): void + { + $body = '

Heading

'; + $result = $this->analyzer->analyze($this->makeContent('Title here', '', $body)); + $types = array_column($result->getItems(), 'type'); + $this->assertTrue(in_array('warning', $types) || in_array('error', $types)); + } + + public function test_images_with_alt_not_flagged_for_alt(): void + { + $body = '

Title

descdesc2'; + $result = $this->analyzer->analyze($this->makeContent('A good title here', '', $body)); + $altItems = array_filter($result->getItems(), fn ($i) => ($i['type'] ?? '') === 'image_alt_missing'); + $this->assertCount(0, $altItems); + } + + public function test_score_in_range(): void + { + $result = $this->analyzer->analyze($this->makeContent('', '', '')); + $this->assertGreaterThanOrEqual(0.0, $result->getScore()); + $this->assertLessThanOrEqual(100.0, $result->getScore()); + } + + public function test_metadata_has_expected_keys(): void + { + $result = $this->analyzer->analyze($this->makeContent('Title here', '', '

H1

')); + $meta = $result->getMetadata(); + $this->assertArrayHasKey('title_length', $meta); + $this->assertArrayHasKey('meta_desc_length', $meta); + } +}