Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 116 additions & 281 deletions CHANGELOG.md

Large diffs are not rendered by default.

35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -96,8 +107,28 @@ Manage webhook endpoints and event subscriptions directly from the admin panel (
- See [docs/graphql-api.md](docs/graphql-api.md) for the full guide


### Plugin & Extension System
First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package.
### AI Pipeline Templates & Preset Library
**New in v0.10.0.** Reusable AI pipeline templates for accelerated content creation workflows.

**Features:**
- **8 built-in templates:** Blog Post Pipeline, Social Media Campaign, Product Description, Email Newsletter, Press Release, Landing Page, Technical Documentation, Video Script
- **Template library API:** Browse, rate, and install templates from community library
- **Space-scoped templates:** Custom templates per content space with RBAC
- **One-click install wizard:** Auto-configures personas, stages, and variables from template schema
- **Template versioning:** Version management with changelog and rollback support
- **Template packs:** Plugin-registered template collections with metadata
- **Plugin hooks:** `registerTemplateCategory()` and `registerTemplatePack()` for extending the library
- **Template ratings:** Community feedback and quality metrics

**Endpoints:**
- `GET /api/v1/spaces/{space}/pipeline-templates` — List templates
- `POST /api/v1/spaces/{space}/pipeline-templates` — Create custom template
- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/publish` — Publish template
- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/versions` — Create new version
- `POST /api/v1/spaces/{space}/pipeline-templates/installs/{version}` — Install template
- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/ratings` — Rate template

See [docs/pipeline-templates.md](docs/pipeline-templates.md) for the complete feature guide.

### Plugin & Extension System
First-class plugin architecture. Extend pipelines, register custom LLM providers, add admin UI, and react to content events — all from a self-contained plugin package.
Expand Down
17 changes: 17 additions & 0 deletions app/Events/Quality/ContentQualityScored.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Events\Quality;

use App\Models\ContentQualityScore;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ContentQualityScored
{
use Dispatchable;
use SerializesModels;

public function __construct(
public readonly ContentQualityScore $score,
) {}
}
14 changes: 14 additions & 0 deletions app/GraphQL/Mutations/CreateCompetitorAlert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\CompetitorAlert;

class CreateCompetitorAlert
{
/** @param array{input: array<string, mixed>} $args */
public function __invoke(mixed $root, array $args): CompetitorAlert
{
return CompetitorAlert::create($args['input']);
}
}
14 changes: 14 additions & 0 deletions app/GraphQL/Mutations/CreateCompetitorSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\CompetitorSource;

class CreateCompetitorSource
{
/** @param array{input: array<string, mixed>} $args */
public function __invoke(mixed $root, array $args): CompetitorSource
{
return CompetitorSource::create($args['input']);
}
}
23 changes: 23 additions & 0 deletions app/GraphQL/Mutations/DeleteCompetitorAlert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\CompetitorAlert;

class DeleteCompetitorAlert
{
/** @param array{id: string} $args */
public function __invoke(mixed $root, array $args): ?CompetitorAlert
{
$alert = CompetitorAlert::find($args['id']);

if ($alert) {
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
abort_if($currentSpace && $alert->space_id !== $currentSpace->id, 403);

$alert->delete();
}

return $alert;
}
}
23 changes: 23 additions & 0 deletions app/GraphQL/Mutations/DeleteCompetitorSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\CompetitorSource;

class DeleteCompetitorSource
{
/** @param array{id: string} $args */
public function __invoke(mixed $root, array $args): ?CompetitorSource
{
$source = CompetitorSource::find($args['id']);

if ($source) {
$currentSpace = app()->bound('current_space') ? app('current_space') : null;
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);

$source->delete();
}

return $source;
}
}
22 changes: 22 additions & 0 deletions app/GraphQL/Mutations/TriggerCompetitorCrawl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\GraphQL\Mutations;

use App\Jobs\CrawlCompetitorSourceJob;
use App\Models\CompetitorSource;

class TriggerCompetitorCrawl
{
/** @param array{source_id: string} $args */
public function __invoke(mixed $root, array $args): bool
{
$source = CompetitorSource::findOrFail($args['source_id']);

$currentSpace = app()->bound('current_space') ? app('current_space') : null;
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);

CrawlCompetitorSourceJob::dispatch($source);

return true;
}
}
21 changes: 21 additions & 0 deletions app/GraphQL/Mutations/UpdateCompetitorSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\GraphQL\Mutations;

use App\Models\CompetitorSource;

class UpdateCompetitorSource
{
/** @param array{id: string, input: array<string, mixed>} $args */
public function __invoke(mixed $root, array $args): CompetitorSource
{
$source = CompetitorSource::findOrFail($args['id']);

$currentSpace = app()->bound('current_space') ? app('current_space') : null;
abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403);

$source->update($args['input']);

return $source->fresh() ?? $source;
}
}
27 changes: 27 additions & 0 deletions app/GraphQL/Queries/CompetitorContent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\GraphQL\Queries;

use App\Models\CompetitorContentItem;
use Illuminate\Pagination\LengthAwarePaginator;

class CompetitorContent
{
/** @param array{space_id: string, source_id?: string|null, first?: int, page?: int} $args */
public function __invoke(mixed $root, array $args): LengthAwarePaginator
{
$query = CompetitorContentItem::query()
->whereHas('source', fn ($q) => $q->where('space_id', $args['space_id']))
->with('source')
->orderByDesc('crawled_at');

if (! empty($args['source_id'])) {
$query->where('source_id', $args['source_id']);
}

$perPage = (int) ($args['first'] ?? 20);
$page = (int) ($args['page'] ?? 1);

return $query->paginate($perPage, ['*'], 'page', $page);
}
}
30 changes: 30 additions & 0 deletions app/GraphQL/Queries/DifferentiationAnalyses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\GraphQL\Queries;

use App\Models\DifferentiationAnalysis;
use Illuminate\Pagination\LengthAwarePaginator;

class DifferentiationAnalyses
{
/** @param array{space_id: string, content_id?: string|null, brief_id?: string|null, first?: int, page?: int} $args */
public function __invoke(mixed $root, array $args): LengthAwarePaginator
{
$query = DifferentiationAnalysis::where('space_id', $args['space_id'])
->with('competitorContent')
->orderByDesc('analyzed_at');

if (! empty($args['content_id'])) {
$query->where('content_id', $args['content_id']);
}

if (! empty($args['brief_id'])) {
$query->where('brief_id', $args['brief_id']);
}

$perPage = (int) ($args['first'] ?? 20);
$page = (int) ($args['page'] ?? 1);

return $query->paginate($perPage, ['*'], 'page', $page);
}
}
34 changes: 34 additions & 0 deletions app/GraphQL/Queries/DifferentiationSummary.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\GraphQL\Queries;

use App\Models\DifferentiationAnalysis;

class DifferentiationSummary
{
/** @param array{space_id: string} $args */
/** @return array<string, mixed> */
public function __invoke(mixed $root, array $args): array
{
/** @var object{total_analyses: int|string, avg_differentiation_score: float|string|null, avg_similarity_score: float|string|null, max_differentiation_score: float|string|null, min_differentiation_score: float|string|null, last_analyzed_at: string|null}|null $summary */
$summary = DifferentiationAnalysis::where('space_id', $args['space_id'])
->selectRaw('
COUNT(*) as total_analyses,
AVG(differentiation_score) as avg_differentiation_score,
AVG(similarity_score) as avg_similarity_score,
MAX(differentiation_score) as max_differentiation_score,
MIN(differentiation_score) as min_differentiation_score,
MAX(analyzed_at) as last_analyzed_at
')
->first();

return [
'total_analyses' => (int) ($summary->total_analyses ?? 0),
'avg_differentiation_score' => round((float) ($summary->avg_differentiation_score ?? 0.0), 4),
'avg_similarity_score' => round((float) ($summary->avg_similarity_score ?? 0.0), 4),
'max_differentiation_score' => round((float) ($summary->max_differentiation_score ?? 0.0), 4),
'min_differentiation_score' => round((float) ($summary->min_differentiation_score ?? 0.0), 4),
'last_analyzed_at' => $summary->last_analyzed_at ?? null,
];
}
}
53 changes: 53 additions & 0 deletions app/Http/Controllers/Admin/QualityDashboardController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Space;
use App\Services\Quality\QualityTrendAggregator;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class QualityDashboardController extends Controller
{
public function __construct(private readonly QualityTrendAggregator $aggregator) {}

public function index(Request $request): Response
{
/** @var Space|null $space */
$space = Space::first();

if ($space === null) {
return Inertia::render('Quality/Dashboard', [
'spaceId' => '',
'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,
]);
}
}
28 changes: 28 additions & 0 deletions app/Http/Controllers/Admin/QualitySettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\ContentQualityConfig;
use App\Models\Space;
use Inertia\Inertia;
use Inertia\Response;

class QualitySettingsController extends Controller
{
public function index(): Response
{
/** @var Space|null $space */
$space = Space::first();

$config = $space !== null
? ContentQualityConfig::where('space_id', $space->id)->first()
: null;

return Inertia::render('Settings/Quality', [
'spaceId' => $space !== null ? $space->id : '',
'spaceName' => $space !== null ? $space->name : '',
'config' => $config,
]);
}
}
32 changes: 32 additions & 0 deletions app/Http/Controllers/Admin/SearchWebController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use Inertia\Inertia;
use Inertia\Response;

class SearchWebController extends Controller
{
public function index(): Response
{
return Inertia::render('Admin/Search/Health');
}

public function synonyms(): Response
{
return Inertia::render('Admin/Search/Synonyms/Index');
}

public function promoted(): Response
{
return Inertia::render('Admin/Search/Promoted/Index');
}

public function analytics(): Response
{
return Inertia::render('Admin/Search/Analytics');
}
}
Loading
Loading