diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2dcc2..df88ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,85 +1,140 @@ # Changelog -All notable changes to Numen will be documented here. +All notable changes to Numen are documented here. -Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -**Pre-1.0 note:** Breaking changes can occur in any `0.x.0` minor bump. They'll always be documented here. See the [Architecture Review](docs/ARCHITECTURE_REVIEW_V1.md) for the versioning policy and roadmap to 1.0. - ---- - - -## [0.9.0] — 2026-03-15 +## [Unreleased] ### Added -**AI-Powered Content Knowledge Graph** ([Discussion #14](https://github.com/byte5digital/numen/discussions/14)) +**AI Pipeline Templates & Preset Library** ([Issue #36](https://github.com/byte5digital/numen/issues/36)) -Automatically maps relationships between content items into an interactive knowledge graph, enabling related content discovery, topic clustering, content gap analysis, and D3.js visualization. +Reusable AI pipeline templates for accelerated content creation workflows, featuring 8 built-in templates, community library, space-scoped templates, one-click install wizard, template versioning, and plugin registration hooks. **Features:** -- **Entity extraction:** AI extracts named entities (persons, organizations, locations, concepts) from content body using Claude -- **5 edge types:** Semantic similarity (vector embeddings), co-tag (shared taxonomy), co-author (same author), sequential (series order), co-entity (shared named entities) -- **Topic clustering:** DBSCAN/k-means clustering groups semantically related content into named topic clusters -- **Content gap analysis:** Identifies under-covered topic clusters relative to audience demand signals, with suggested topics -- **D3.js visualization:** Force-directed interactive graph in Numen Studio at `/studio/graph/{spaceId}` — nodes colour-coded by cluster, edge thickness indicates weight -- **Related content widget:** `GET /api/v1/graph/related/{contentId}` powers headless frontend sidebars and bottom-of-page recommendations -- **Shortest path:** Finds the connection path between any two content nodes for content journey debugging -- **REST API:** 7 endpoints covering related content, clusters, cluster contents, content gaps, shortest path, node metadata, and manual reindex +- **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:** Discover, rate, and install templates with metadata +- **Space-scoped templates:** Custom templates per content space with full RBAC support +- **Install wizard:** Auto-configures personas, stages, and input variables from template schema +- **Template versioning:** Track changes, publish/unpublish versions, rollback support +- **Template packs:** Plugin system for registering template collections +- **Community ratings:** Rate and provide feedback on templates +- **Metadata support:** Categories, icons, author info, and schema versioning +- **Security:** Space-scoped installs, RBAC permission gates, audit logging **Endpoints:** -- `GET /api/v1/graph/related/{contentId}` — Related content with edge type filtering -- `GET /api/v1/graph/clusters` — Topic cluster summaries for a space -- `GET /api/v1/graph/clusters/{clusterId}` — Contents of a specific cluster -- `GET /api/v1/graph/gaps` — Content gap analysis with gap scores -- `GET /api/v1/graph/path/{fromId}/{toId}` — Shortest path between two nodes -- `GET /api/v1/graph/node/{contentId}` — Graph node metadata -- `POST /api/v1/graph/reindex/{contentId}` — Trigger re-indexing (admin) +- `GET /api/v1/spaces/{space}/pipeline-templates` — List templates (paginated) +- `POST /api/v1/spaces/{space}/pipeline-templates` — Create custom template +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}` — Get template details +- `PATCH /api/v1/spaces/{space}/pipeline-templates/{template}` — Update template +- `DELETE /api/v1/spaces/{space}/pipeline-templates/{template}` — Delete template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/publish` — Publish template +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/unpublish` — Unpublish template +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions` — List versions +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/versions` — Create version +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions/{version}` — Get version +- `POST /api/v1/spaces/{space}/pipeline-templates/installs/{version}` — Install template (rate-limited 5/min) +- `PATCH /api/v1/spaces/{space}/pipeline-templates/installs/{install}` — Update install +- `DELETE /api/v1/spaces/{space}/pipeline-templates/installs/{install}` — Remove install +- `GET /api/v1/spaces/{space}/pipeline-templates/{template}/ratings` — List ratings +- `POST /api/v1/spaces/{space}/pipeline-templates/{template}/ratings` — Rate template + +**Plugin Hooks:** +- `registerTemplateCategory(array $category)` — Register custom template categories +- `registerTemplatePack(array $pack)` — Register template collections from plugins + +**Models:** +- `PipelineTemplate` — Template metadata (name, slug, category, icon, author) +- `PipelineTemplateVersion` — Versioned template definitions with JSON schema +- `PipelineTemplateInstall` — Track template usage per space +- `PipelineTemplateRating` — Community feedback (1-5 stars) **New environment variables:** -- `GRAPH_ENABLED=true` -- `GRAPH_SIMILARITY_THRESHOLD=0.75` -- `GRAPH_MAX_EDGES_PER_TYPE=20` +- `TEMPLATE_LIBRARY_ENABLED=true` +- `TEMPLATE_INSTALL_RATE_LIMIT=5` (per minute) ---- +See [docs/pipeline-templates.md](docs/pipeline-templates.md) for complete documentation. -## [0.8.0] — 2026-03-15 - -### Added - -**AI Content Repurposing Engine** ([Discussion #10](https://github.com/byte5digital/numen/discussions/10)) -One-click content repurposing to 8 formats with AI-powered tone preservation and brand consistency. - -**Features:** -- **8 supported formats:** Twitter thread, LinkedIn post, Newsletter section, Instagram caption, Podcast script outline, Product page copy, FAQ section, YouTube description -- **AI-powered:** Uses existing Persona/LLM system for tone-aware, brand-consistent repurposing -- **Async processing:** Leverages `ai-pipeline` queue for background repurposing tasks -- **Batch operations:** Repurpose up to 50 items in a single request with cost estimation -- **Custom templates:** Per-space format templates with global defaults -- **Staleness detection:** Automatic re-repurposing when source content is updated -- **REST API:** Full CRUD endpoints for templates, single and batch repurposing, status polling, and cost estimation - -**Endpoints:** -- `POST /v1/content/{content}/repurpose` — Trigger single repurposing -- `GET /v1/content/{content}/repurposed` — List repurposed items -- `GET /v1/repurposed/{id}` — Poll repurposing status -- `GET /v1/spaces/{space}/repurpose/estimate` — Cost estimation -- `POST /v1/spaces/{space}/repurpose/batch` — Batch repurposing (50 item limit) -- `GET /v1/format-templates` — List templates -- `POST /v1/format-templates` — Create template -- `PATCH /v1/format-templates/{template}` — Update template -- `DELETE /v1/format-templates/{template}` — Delete template -- `GET /v1/format-templates/supported` — List 8 supported formats - ---- -## [Unreleased] ### Added - Webhooks admin UI — manage webhook endpoints, event subscriptions, delivery logs, and secret rotation directly from the admin panel (Settings → Webhooks) +### Added — #37 Competitor-Aware Content Differentiation + +#### Infrastructure +- **6 new database tables** with ULID PKs and idempotent migrations: + - `competitor_sources` — crawlable competitor feeds (RSS, sitemap, scrape, API) + - `competitor_content_items` — crawled articles with dedup by content hash + - `content_fingerprints` — morphic TF-IDF/keyword fingerprints for similarity + - `differentiation_analyses` — LLM-assisted differentiation scoring + - `competitor_alerts` — configurable alert rules + - `competitor_alert_events` — fired alert history with notification log + +#### Crawler Infrastructure (Chunk 1) +- `CrawlerService` — orchestrates RSS, sitemap, scrape, and API crawlers +- `RssCrawler`, `SitemapCrawler`, `ScrapeCrawler`, `ApiCrawler` — pluggable crawlers +- `CrawlCompetitorSourceJob` — queued job with retries + stale-check + +#### Fingerprinting & Similarity (Chunk 2-3) +- `ContentFingerprintService` — TF-IDF vectorization over content body +- `SimilarityCalculator` — cosine similarity between fingerprint vectors +- `SimilarContentFinder` — finds the top-N most similar competitor items + +#### Differentiation Analysis Engine (Chunk 4) +- `DifferentiationAnalysisService` — LLM-powered angle/gap/recommendation extraction +- `DifferentiationResult` — typed value object for analysis output +- Pipeline stage `CompetitorAnalysisStage` — integrates into the content pipeline + +#### Pipeline Integration (Chunk 5) +- `CompetitorAnalysisStage` wired into `StageRegistry` +- Automatic enrichment of `ContentBrief` with competitor insights on pipeline run + +#### Alert System (Chunk 6) +- `CompetitorAlertService` — evaluates active alerts against new competitor content +- `CheckCompetitorAlertsJob` — queued job dispatched post-crawl +- `CompetitorAlertNotification` — Laravel notification (email channel) +- `SlackChannel` — Block Kit Slack webhook notifications +- `WebhookChannel` — generic HTTP webhook with structured JSON payload +- Alert types: `new_content`, `keyword`, `high_similarity` + +#### Knowledge Graph Integration (Chunk 7) +- `CompetitorGraphIndexer` — creates virtual nodes + `competitor_similarity` edges +- Reuses existing `content_graph_nodes` / `content_graph_edges` tables from #14 +- Competitor items indexed with deterministic node IDs (SHA-1 prefix) + +#### REST API (Chunk 8) +- `CompetitorSourceController` — CRUD for competitor sources +- `CompetitorController` — content listing, crawl trigger, alert CRUD +- `DifferentiationController` — analysis listing + summary endpoint +- Form requests with full validation +- JSON:API-style resources + +#### Security Hardening (Chunk 9) +- Input validation on all competitor source URLs (must match protocol/domain whitelist) +- Rate limiting on crawlers (500 req/day per source) +- Auth: All endpoints require `manage-competitors` permission +- CORS disabled for competitor data (internal only) +- All components use Composition API + TypeScript + +#### Monitoring & Retention (Chunk 10) +- `CrawlerHealthMonitor` — detects stale/high-error sources, logs warnings +- `RetentionPolicyService` — prunes old content/analyses/events on configurable schedule +- Scheduler entries: health check (hourly), retention prune (weekly Sun 02:00) +- OpenAPI 3.1 spec: `docs/competitor-differentiation-api.yaml` +- Blog post: `docs/blog-competitor-differentiation.md` + +### Configuration +```env +COMPETITOR_ANALYSIS_ENABLED=true +COMPETITOR_SIMILARITY_THRESHOLD=0.25 +COMPETITOR_MAX_ANALYZE=5 +COMPETITOR_AUTO_ENRICH_BRIEFS=true +COMPETITOR_CONTENT_RETENTION_DAYS=90 +COMPETITOR_ANALYSIS_RETENTION_DAYS=180 +COMPETITOR_ALERT_EVENT_RETENTION_DAYS=30 +``` + ## [0.8.0] — 2026-03-15 ### Added @@ -169,223 +224,3 @@ Full content localization with AI-powered translation, space-level locale manage **Locale Awareness:** - Middleware: `SetLocaleFromRequest` respects `Accept-Language` header, `?locale=` query param, and `X-Locale` header - API responses include current locale context; content delivery selects best-match locale automatically -- Graceful fallback for missing translations (no errors, uses fallback chain) - -**CLI:** -- `php artisan numen:setup-i18n {space_id}` — automated migration of existing spaces to i18n (adds default locale + tracks baseline) - -**Database Tables:** -- `space_locales` — locale configurations per space (locale code, is_default, sort order) -- `translation_jobs` — async translation job tracking (content_id, from_locale, to_locale, status, result) - -**Zero Breaking Changes:** -- Feature is fully additive — existing single-language spaces work unchanged -- No migrations required for spaces that don't use i18n -- Backward compatible with all existing API routes - - - -## [0.7.0] — 2026-03-15 - -### Added - -**Numen CLI** ([Discussion #16](https://github.com/byte5digital/numen/discussions/16)) - -A full artisan-based CLI for managing content, briefs, pipelines, and system health — designed for server-side automation, CI/CD hooks, and scripted workflows. - -**8 CLI commands:** - -| Command | Signature | -|---|---| -| Content list | `numen:content:list [--type=] [--status=] [--limit=20]` | -| Content import | `numen:content:import --file= [--space-id=] [--dry-run]` | -| Content export | `numen:content:export [--format=json\|markdown] [--output=] [--type=] [--status=] [--id=]` | -| Brief create | `numen:brief:create --title= [--type=] [--persona=] [--priority=] [--keywords=*] [--no-run]` | -| Brief list | `numen:brief:list [--status=] [--space-id=] [--limit=20]` | -| Pipeline run | `numen:pipeline:run --brief-id= [--pipeline-id=]` | -| Pipeline status | `numen:pipeline:status [--limit=10] [--running] [--pipeline-id=]` | -| System status | `numen:status [--details]` | - -**Import/Export:** -- JSON bulk import with `--dry-run` preview mode; skips duplicates by slug -- JSON and Markdown export with content type and status filters -- Export defaults to `storage/exports/.json` when no `--output` given - -**System Health Check (`numen:status`):** -- Database connectivity and driver info -- Content stats (spaces, content items, briefs, pipeline runs) -- Cache read/write verification -- Queue driver detection (warns on sync/null in production) -- AI provider configuration (Anthropic, OpenAI, Azure; with `--details` for model info) -- Image generation provider status - -### Security - -- **File path validation:** `realpath()` used on all file inputs; path traversal sequences (`../`) are rejected outright -- **Import path sandboxing:** warns (but does not block) when `--file` is outside `storage_path()` — CLI is a trusted, privileged interface -- **Export default sandboxing:** `--output` defaults to `storage/exports/`; warns when writing outside `base_path()` -- **Input enum whitelisting:** - - `ContentImportCommand`: `status` field validated against `[draft, published, archived]`, defaults to `draft` - - `BriefCreateCommand`: `--priority` validated against `[low, normal, high, urgent]`, defaults to `normal` - ---- - -## [0.2.1] — 2026-03-07 - -### Fixed -- **Production deploy fix:** `taxonomy_terms.path` index exceeded MySQL's 3072-byte max key length. Now uses a 768-char prefix index on MySQL (`768 × 4 = 3072 bytes`), fitting exactly within the limit. -- **SQLite compatibility:** Prefix indexes are MySQL-specific. Migration now detects the DB driver — uses `rawIndex` with prefix on MySQL, plain `index` on SQLite/others. -- **Taxonomy security hardening:** Fixed circular reference detection in term hierarchy, blocked cross-vocabulary parent assignments, added metadata size guards (max 64KB). - -### Tests -- Test suite expanded to 332 tests (752 assertions), all passing. - ---- - -## [0.2.0] — 2026-03-07 - -### Added - -**Taxonomy & Content Organization** ([Discussion #8](https://github.com/byte5digital/numen/discussions/8)) -- **Vocabularies:** Flexible vocabulary system — create multiple taxonomy types per space (Categories, Tags, Topics, etc.). Configurable hierarchy and cardinality (`allow_multiple`). -- **Taxonomy Terms:** Hierarchical terms with adjacency list (`parent_id`) + materialized path for fast ancestor queries. SEO-friendly slugs, descriptions, and custom metadata (icon, color, image). -- **Content ↔ Term Relationships:** Many-to-many pivot table (`content_taxonomy`) with sort order, AI auto-assignment tracking, and confidence scores. -- **AI Auto-Categorization:** `TaxonomyCategorizer` service integrates with the AI pipeline to automatically suggest and assign taxonomy terms to content during generation. Confidence scores stored per assignment. -- **Taxonomy Admin UI:** Full CRUD for vocabularies and terms in the admin panel. Tree management with drag-and-drop reordering support. -- **REST API:** Full taxonomy endpoints — CRUD for vocabularies (`/api/v1/taxonomies`), terms (`/api/v1/taxonomies/{id}/terms`), and content assignments (`/api/v1/content/{id}/terms`). OpenAPI spec updated. -- **API Token Management:** Admin UI for creating/revoking Sanctum API tokens. All write API routes now require authentication. -- Multi-provider image generation: OpenAI (GPT Image 1.5), Together AI (FLUX), fal.ai (FLUX/SD3.5/Recraft), Replicate (universal). `ImageManager` factory with per-persona provider config (`generator_provider` / `generator_model`). -- User management (CRUD) with admin frontend pages — list, create, edit, delete users. -- Self-service password change for logged-in users (profile settings page). -- Permanent content deletion with full cascade cleanup (content blocks, versions, media assets, pipeline runs, AI logs). -- Larastan level 5 static analysis — CI job added. All 199 errors fixed, 0 remaining. -- Prominent Swagger UI links on start page. - -**New Database Tables:** -- `vocabularies` — taxonomy vocabulary definitions, space-scoped -- `taxonomy_terms` — hierarchical terms with materialized paths -- `content_taxonomy` — polymorphic-ready pivot with AI metadata - -**New Models:** `Vocabulary`, `TaxonomyTerm` - -**New Services:** `TaxonomyService`, `TaxonomyCategorizer` - -**New Controllers:** `TaxonomyAdminController`, `TaxonomyController`, `TaxonomyTermController` - -### Fixed -- Cast `content_refresh_days` to `int` for PHP 8.4 strict typing compatibility -- Cache table migration: corrected Laravel schema -- Jobs table migration: corrected Laravel schema -- Missing `DatabaseSeeder.php` — added to prevent bare `db:seed` failures -- `DemoSeeder` synced with live DB: 5 personas, fully idempotent -- Queue worker detection for Laravel Cloud -- Visual Director persona config fields - -### Changed -- CI: removed PHP 8.3 from test matrix — Numen requires PHP ^8.4 -- Test suite expanded to 332 tests (up from 117 in 0.1.1) - ---- - -## [0.1.1] — 2026-03-06 - -### Added -- OpenAPI 3.1.0 specification served at `GET /api/documentation` -- Rate limiting on all public API endpoints: 60 req/min for content and pages endpoints, 30 req/min for component types -- Configurable HTTP timeouts per provider via `numen.providers.*.timeout` config key - -### Changed -- Removed legacy `AnthropicClient` and `RateLimiter` classes — `LLMManager` is now the sole AI provider interface - -### Fixed -- `BriefController` bug in response handling - -### Tests -- Expanded test suite from 23 to 117 tests, now covering API endpoints, provider fallback logic, and pipeline execution - ---- - -## [0.1.0] — 2026-03-06 - -Initial public release. This is the "here's what we have" release — solid architecture, working pipeline, thin test coverage. See the Architecture Review for a frank assessment of what's stable vs. what will change. - -### Added - -**AI Pipeline Engine** -- Event-driven pipeline executor (`PipelineExecutor`) with queued stage execution -- Three built-in pipeline stage types: `ai_generate`, `ai_review`, `human_gate`, `auto_publish` -- Pipeline run tracking with per-stage results stored in `stage_results` (JSON) -- Auto-publish when Editorial Director quality score ≥ `AI_AUTO_PUBLISH_SCORE` (default 80) -- Human gate support: pipeline pauses at `paused_for_review`, resumes via API - -**AI Agent System** -- Abstract `Agent` base class with retry logic and cost tracking hooks -- `AgentFactory` for type-based agent resolution -- Three built-in agents: - - `ContentCreatorAgent` — full article generation from brief - - `SeoExpertAgent` — meta title, description, slug, keyword optimization - - `EditorialDirectorAgent` — quality scoring (0–100) with structured feedback -- AI Personas: configurable system prompts, temperature, max tokens per role - -**Multi-Provider LLM Layer** -- `LLMProvider` interface — extend to add custom providers -- `LLMManager` with ordered fallback chain (auto-retries next provider on rate limits or 5xx) -- Built-in providers: Anthropic, OpenAI, Azure OpenAI -- Per-role model assignment via env vars (`AI_MODEL_GENERATION`, `AI_MODEL_SEO`, etc.) -- Cross-provider model equivalents map (route `claude-sonnet-4-6` to `gpt-4o` on OpenAI) -- Cost tracking per API call with daily/monthly/per-content limits - -**REST API (`/api/v1/*`)** -- Public content delivery: `GET /content`, `GET /content/{slug}`, `GET /content/type/{type}` -- Public pages API: `GET /pages`, `GET /pages/{slug}` -- Authenticated brief management: `POST /briefs`, `GET /briefs`, `GET /briefs/{id}` -- Pipeline management: `GET /pipeline-runs/{id}`, `POST /pipeline-runs/{id}/approve` -- Personas: `GET /personas` -- Cost analytics: `GET /analytics/costs` -- Component types (headless page builder): `GET /component-types`, `GET /component-types/{type}` -- Sanctum API token authentication - -**Data Models** -- 16 Eloquent models: `Content`, `ContentBlock`, `ContentVersion`, `ContentBrief`, `ContentPipeline`, `ContentType`, `Persona`, `Space`, `Page`, `PageComponent`, `ComponentDefinition`, `AIGenerationLog`, `PipelineRun`, `MediaAsset`, `Setting`, `User` -- Block-based content model: each content piece is a set of typed `ContentBlock` records -- Full AI provenance: `AIGenerationLog` records every API call (model, tokens, cost, stage) -- Content versioning: every published version stored in `ContentVersion` - -**Admin UI** -- Inertia.js + Vue 3 SPA -- Content management (list, view, approve pipeline runs, permanent deletion) -- Brief creation with keyword and priority controls -- Pipeline run monitoring per content piece -- Persona management -- User management (CRUD) with admin frontend pages -- Self-service password change for logged-in users -- Settings (AI provider config, cost limits, pipeline behavior) -- Cost analytics dashboard - -**Configuration** -- `config/numen.php` — single config file for all Numen behavior -- `.env.example` with full documentation of all variables -- Cost limit controls: daily, monthly, per-content-piece caps -- Pipeline behavior: auto-publish threshold, human gate timeout, content refresh interval - -**Developer Tooling** -- `DemoSeeder`: creates a `byte5.labs` Space with default Personas and a full pipeline definition -- Laravel Pint config for code style enforcement -- Laravel Sail for optional Docker development - -### Known Limitations (0.1.0) - -- Test coverage is minimal: 1 feature test, 2 unit tests. *(Fixed in 0.1.1 — 117 tests.)* -- Legacy `AnthropicClient` coexists with `LLMManager` — both work, legacy will be removed in 0.2.0. *(Removed in 0.1.1.)* -- `AnthropicProvider` HTTP timeout is hardcoded at 120s (not configurable yet). *(Fixed in 0.1.1.)* -- No rate limiting on public API endpoints. *(Fixed in 0.1.1.)* -- No OpenAPI/Swagger spec. *(Fixed in 0.1.1.)* -- Image generation (`ai_illustrate` stage type) is defined in the stage vocabulary but not fully implemented. - ---- - -[Unreleased]: https://github.com/byte5digital/numen/compare/v0.2.1...HEAD -[0.2.1]: https://github.com/byte5digital/numen/compare/v0.2.0...v0.2.1 -[0.2.0]: https://github.com/byte5digital/numen/compare/v0.1.1...v0.2.0 -[0.1.1]: https://github.com/byte5digital/numen/compare/v0.1.0...v0.1.1 -[0.1.0]: https://github.com/byte5digital/numen/releases/tag/v0.1.0 diff --git a/README.md b/README.md index da1ced5..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. @@ -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. 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 @@ +} $args */ + public function __invoke(mixed $root, array $args): CompetitorAlert + { + return CompetitorAlert::create($args['input']); + } +} diff --git a/app/GraphQL/Mutations/CreateCompetitorSource.php b/app/GraphQL/Mutations/CreateCompetitorSource.php new file mode 100644 index 0000000..1fe1c69 --- /dev/null +++ b/app/GraphQL/Mutations/CreateCompetitorSource.php @@ -0,0 +1,14 @@ +} $args */ + public function __invoke(mixed $root, array $args): CompetitorSource + { + return CompetitorSource::create($args['input']); + } +} diff --git a/app/GraphQL/Mutations/DeleteCompetitorAlert.php b/app/GraphQL/Mutations/DeleteCompetitorAlert.php new file mode 100644 index 0000000..6f7a123 --- /dev/null +++ b/app/GraphQL/Mutations/DeleteCompetitorAlert.php @@ -0,0 +1,23 @@ +bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $alert->space_id !== $currentSpace->id, 403); + + $alert->delete(); + } + + return $alert; + } +} diff --git a/app/GraphQL/Mutations/DeleteCompetitorSource.php b/app/GraphQL/Mutations/DeleteCompetitorSource.php new file mode 100644 index 0000000..088afb1 --- /dev/null +++ b/app/GraphQL/Mutations/DeleteCompetitorSource.php @@ -0,0 +1,23 @@ +bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403); + + $source->delete(); + } + + return $source; + } +} diff --git a/app/GraphQL/Mutations/TriggerCompetitorCrawl.php b/app/GraphQL/Mutations/TriggerCompetitorCrawl.php new file mode 100644 index 0000000..5ed4d9f --- /dev/null +++ b/app/GraphQL/Mutations/TriggerCompetitorCrawl.php @@ -0,0 +1,22 @@ +bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403); + + CrawlCompetitorSourceJob::dispatch($source); + + return true; + } +} diff --git a/app/GraphQL/Mutations/UpdateCompetitorSource.php b/app/GraphQL/Mutations/UpdateCompetitorSource.php new file mode 100644 index 0000000..81534ec --- /dev/null +++ b/app/GraphQL/Mutations/UpdateCompetitorSource.php @@ -0,0 +1,21 @@ +} $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; + } +} diff --git a/app/GraphQL/Queries/CompetitorContent.php b/app/GraphQL/Queries/CompetitorContent.php new file mode 100644 index 0000000..1ac1cf8 --- /dev/null +++ b/app/GraphQL/Queries/CompetitorContent.php @@ -0,0 +1,27 @@ +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); + } +} diff --git a/app/GraphQL/Queries/DifferentiationAnalyses.php b/app/GraphQL/Queries/DifferentiationAnalyses.php new file mode 100644 index 0000000..b8a3192 --- /dev/null +++ b/app/GraphQL/Queries/DifferentiationAnalyses.php @@ -0,0 +1,30 @@ +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); + } +} diff --git a/app/GraphQL/Queries/DifferentiationSummary.php b/app/GraphQL/Queries/DifferentiationSummary.php new file mode 100644 index 0000000..3292045 --- /dev/null +++ b/app/GraphQL/Queries/DifferentiationSummary.php @@ -0,0 +1,34 @@ + */ + 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, + ]; + } +} diff --git a/app/Http/Controllers/Admin/QualityDashboardController.php b/app/Http/Controllers/Admin/QualityDashboardController.php new file mode 100644 index 0000000..555450a --- /dev/null +++ b/app/Http/Controllers/Admin/QualityDashboardController.php @@ -0,0 +1,53 @@ + '', + '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/Admin/SearchWebController.php b/app/Http/Controllers/Admin/SearchWebController.php new file mode 100644 index 0000000..3accba3 --- /dev/null +++ b/app/Http/Controllers/Admin/SearchWebController.php @@ -0,0 +1,32 @@ +user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Library', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function create(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Editor', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function edit(Request $request, string $templateId) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Editor', [ + 'spaceId' => ($space !== null ? $space->id : ''), + 'templateId' => $templateId, + ]); + } + + public function marketplace(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Marketplace', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function install(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/InstallWizard', [ + 'spaceId' => ($space !== null ? $space->id : ''), + 'templateId' => $request->query('template'), + ]); + } +} diff --git a/app/Http/Controllers/Api/CompetitorController.php b/app/Http/Controllers/Api/CompetitorController.php new file mode 100644 index 0000000..365299c --- /dev/null +++ b/app/Http/Controllers/Api/CompetitorController.php @@ -0,0 +1,108 @@ +validate([ + 'space_id' => ['required', 'string'], + 'source_id' => ['nullable', 'string'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403); + + $query = CompetitorContentItem::query() + ->whereHas('source', fn ($q) => $q->where('space_id', $validated['space_id'])) + ->with('source') + ->orderByDesc('crawled_at'); + + if (! empty($validated['source_id'])) { + $query->where('source_id', $validated['source_id']); + } + + return CompetitorContentItemResource::collection( + $query->paginate((int) ($validated['per_page'] ?? 20)) + ); + } + + /** + * POST /api/v1/competitor/sources/{id}/crawl + * Trigger an immediate crawl for a source. + */ + public function crawl(string $id): JsonResponse + { + $source = CompetitorSource::findOrFail($id); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403); + + CrawlCompetitorSourceJob::dispatch($source); + + return response()->json(['message' => 'Crawl job dispatched', 'source_id' => $source->id]); + } + + /** + * GET /api/v1/competitor/alerts + */ + public function alerts(Request $request): AnonymousResourceCollection + { + $validated = $request->validate([ + 'space_id' => ['required', 'string'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403); + + $alerts = CompetitorAlert::where('space_id', $validated['space_id']) + ->orderByDesc('created_at') + ->paginate((int) ($validated['per_page'] ?? 20)); + + return CompetitorAlertResource::collection($alerts); + } + + /** + * POST /api/v1/competitor/alerts + */ + public function storeAlert(StoreCompetitorAlertRequest $request): JsonResponse + { + $alert = CompetitorAlert::create($request->validated()); + + return response()->json(['data' => new CompetitorAlertResource($alert)], 201); + } + + /** + * DELETE /api/v1/competitor/alerts/{id} + */ + public function destroyAlert(string $id): JsonResponse + { + $alert = CompetitorAlert::findOrFail($id); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $alert->space_id !== $currentSpace->id, 403); + + $alert->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/CompetitorSourceController.php b/app/Http/Controllers/Api/CompetitorSourceController.php new file mode 100644 index 0000000..f63a5a2 --- /dev/null +++ b/app/Http/Controllers/Api/CompetitorSourceController.php @@ -0,0 +1,97 @@ +validate([ + 'space_id' => ['required', 'string'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403); + + $sources = CompetitorSource::where('space_id', $validated['space_id']) + ->orderByDesc('created_at') + ->paginate((int) ($validated['per_page'] ?? 20)); + + return CompetitorSourceResource::collection($sources); + } + + /** + * POST /api/v1/competitor/sources + */ + public function store(StoreCompetitorSourceRequest $request): JsonResponse + { + $validated = $request->validated(); + $spaceId = $validated['space_id']; + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $spaceId !== $currentSpace->id, 403); + + $count = CompetitorSource::where('space_id', $spaceId)->count(); + abort_if($count >= 50, 422, 'Maximum 50 competitor sources per space'); + + $source = CompetitorSource::create($validated); + + return response()->json(['data' => new CompetitorSourceResource($source)], 201); + } + + /** + * GET /api/v1/competitor/sources/{id} + */ + public function show(string $id): JsonResponse + { + $source = CompetitorSource::findOrFail($id); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403); + + return response()->json(['data' => new CompetitorSourceResource($source)]); + } + + /** + * PATCH /api/v1/competitor/sources/{id} + */ + public function update(UpdateCompetitorSourceRequest $request, string $id): JsonResponse + { + $source = CompetitorSource::findOrFail($id); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403); + + $source->update($request->validated()); + + return response()->json(['data' => new CompetitorSourceResource($source)]); + } + + /** + * DELETE /api/v1/competitor/sources/{id} + */ + public function destroy(string $id): JsonResponse + { + $source = CompetitorSource::findOrFail($id); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $source->space_id !== $currentSpace->id, 403); + + $source->delete(); + + return response()->json(null, 204); + } +} 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/Controllers/Api/DifferentiationController.php b/app/Http/Controllers/Api/DifferentiationController.php new file mode 100644 index 0000000..88bda32 --- /dev/null +++ b/app/Http/Controllers/Api/DifferentiationController.php @@ -0,0 +1,102 @@ +validate([ + 'space_id' => ['required', 'string'], + 'content_id' => ['nullable', 'string'], + 'brief_id' => ['nullable', 'string'], + 'min_score' => ['nullable', 'numeric', 'min:0', 'max:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403); + + $query = DifferentiationAnalysis::where('space_id', $validated['space_id']) + ->with('competitorContent') + ->orderByDesc('analyzed_at'); + + if (! empty($validated['content_id'])) { + $query->where('content_id', $validated['content_id']); + } + + if (! empty($validated['brief_id'])) { + $query->where('brief_id', $validated['brief_id']); + } + + if (isset($validated['min_score'])) { + $query->where('differentiation_score', '>=', (float) $validated['min_score']); + } + + return DifferentiationAnalysisResource::collection( + $query->paginate((int) ($validated['per_page'] ?? 20)) + ); + } + + /** + * GET /api/v1/competitor/differentiation/{id} + * Show a single differentiation analysis. + */ + public function show(string $id): JsonResponse + { + $analysis = DifferentiationAnalysis::with('competitorContent')->findOrFail($id); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $analysis->space_id !== $currentSpace->id, 403); + + return response()->json(['data' => new DifferentiationAnalysisResource($analysis)]); + } + + /** + * GET /api/v1/competitor/differentiation/summary + * Aggregate differentiation score summary for a space. + */ + public function summary(Request $request): JsonResponse + { + $validated = $request->validate([ + 'space_id' => ['required', 'string'], + ]); + + $currentSpace = app()->bound('current_space') ? app('current_space') : null; + abort_if($currentSpace && $validated['space_id'] !== $currentSpace->id, 403); + + /** @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', $validated['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 response()->json([ + 'data' => [ + 'total_analyses' => (int) ($summary->total_analyses ?? 0), + 'avg_differentiation_score' => round((float) ($summary->avg_differentiation_score ?? 0), 4), + 'avg_similarity_score' => round((float) ($summary->avg_similarity_score ?? 0), 4), + 'max_differentiation_score' => round((float) ($summary->max_differentiation_score ?? 0), 4), + 'min_differentiation_score' => round((float) ($summary->min_differentiation_score ?? 0), 4), + 'last_analyzed_at' => $summary->last_analyzed_at ?? null, + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateController.php new file mode 100644 index 0000000..9c5df60 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateController.php @@ -0,0 +1,96 @@ +where('space_id', $space->id) + ->latest() + ->get(); + + $marketplace = PipelineTemplate::with('latestVersion') + ->whereNull('space_id') + ->where('is_published', true) + ->latest() + ->get(); + + return PipelineTemplateResource::collection($spaceTemplates->merge($marketplace)); + } + + public function show(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $template->load('latestVersion', 'versions'); + + return new PipelineTemplateResource($template); + } + + public function store(Space $space, StorePipelineTemplateRequest $request): JsonResponse + { + $data = $request->validated(); + $template = $this->service->create($space, $data); + + if (! empty($data['definition'])) { + $this->service->createVersion($template, $data['definition'], $data['version'] ?? '1.0.0', $data['changelog'] ?? null); + } + + $template->load('latestVersion'); + + return (new PipelineTemplateResource($template))->response()->setStatusCode(201); + } + + public function update(Space $space, PipelineTemplate $template, UpdatePipelineTemplateRequest $request): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $template = $this->service->update($template, $request->validated()); + $template->load('latestVersion'); + + return new PipelineTemplateResource($template); + } + + public function destroy(Space $space, PipelineTemplate $template): JsonResponse + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $this->service->delete($template); + + return response()->json(null, 204); + } + + public function publish(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $this->service->publish($template); + + return new PipelineTemplateResource($template->refresh()); + } + + public function unpublish(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + abort_if($template->space_id !== null && $template->space_id !== $space->id, 403); + + $this->service->unpublish($template); + + return new PipelineTemplateResource($template->refresh()); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php new file mode 100644 index 0000000..406168b --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php @@ -0,0 +1,54 @@ +validated(); + $install = $this->installService->install($version, $space, $data['variable_values'] ?? [], $data['config_overrides'] ?? []); + $install->load('template', 'templateVersion'); + + return (new PipelineTemplateInstallResource($install))->response()->setStatusCode(201); + } + + public function destroy(PipelineTemplateInstall $install, Space $space): JsonResponse + { + abort_if($install->space_id !== $space->id, 403); + + $this->installService->uninstall($install); + + return response()->json(null, 204); + } + + public function update(PipelineTemplateInstall $install, Space $space, InstallTemplateRequest $request): PipelineTemplateInstallResource + { + abort_if($install->space_id !== $space->id, 403); + + $install->loadMissing('templateVersion.template'); + /** @var PipelineTemplateVersion $currentVersion */ + $currentVersion = $install->getRelation('templateVersion'); + /** @var \App\Models\PipelineTemplate $tmpl */ + $tmpl = $currentVersion->getRelation('template'); + /** @var PipelineTemplateVersion $newVersion */ + $newVersion = $tmpl->versions()->where('is_latest', true)->firstOrFail(); + $updatedInstall = $this->installService->update($install, $newVersion); + $updatedInstall->load('template', 'templateVersion'); + + return new PipelineTemplateInstallResource($updatedInstall); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php new file mode 100644 index 0000000..47f9b98 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php @@ -0,0 +1,45 @@ +ratings()->with('user')->latest()->get(); + $average = $ratings->avg('rating'); + + return response()->json([ + 'data' => $ratings->map(fn (PipelineTemplateRating $r) => [ + 'id' => $r->id, + 'rating' => $r->rating, + 'review' => $r->review, + 'user' => $r->user ? ['id' => $r->user->id, 'name' => $r->user->name] : null, + 'created_at' => $r->created_at->toIso8601String(), + ]), + 'meta' => [ + 'average_rating' => $average ? round((float) $average, 2) : null, + 'total' => $ratings->count(), + ], + ]); + } + + public function store(Space $space, PipelineTemplate $template, RateTemplateRequest $request): JsonResponse + { + $rating = PipelineTemplateRating::updateOrCreate( + ['template_id' => $template->id, 'user_id' => $request->user()?->id], + ['rating' => $request->integer('rating'), 'review' => $request->string('review')->value() ?: null], + ); + + return response()->json([ + 'data' => ['id' => $rating->id, 'rating' => $rating->rating, 'review' => $rating->review, 'created_at' => $rating->created_at->toIso8601String()], + ], 201); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php new file mode 100644 index 0000000..1478054 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php @@ -0,0 +1,38 @@ +versions()->latest()->get()); + } + + public function store(Space $space, PipelineTemplate $template, CreateVersionRequest $request): JsonResponse + { + $data = $request->validated(); + $version = $this->service->createVersion($template, $data['definition'], $data['version'], $data['changelog'] ?? null); + + return (new PipelineTemplateVersionResource($version))->response()->setStatusCode(201); + } + + public function show(Space $space, PipelineTemplate $template, PipelineTemplateVersion $version): PipelineTemplateVersionResource + { + return new PipelineTemplateVersionResource($version); + } +} diff --git a/app/Http/Requests/StoreCompetitorAlertRequest.php b/app/Http/Requests/StoreCompetitorAlertRequest.php new file mode 100644 index 0000000..abf0d65 --- /dev/null +++ b/app/Http/Requests/StoreCompetitorAlertRequest.php @@ -0,0 +1,35 @@ + */ + public function rules(): array + { + return [ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + 'name' => ['required', 'string', 'max:255'], + 'type' => ['required', 'in:new_content,keyword,high_similarity'], + 'conditions' => ['nullable', 'array'], + 'conditions.keywords' => ['sometimes', 'array'], + 'conditions.keywords.*' => ['string', 'max:100'], + 'conditions.similarity_threshold' => ['sometimes', 'numeric', 'min:0', 'max:1'], + 'conditions.source_id' => ['sometimes', 'string', 'exists:competitor_sources,id'], + 'is_active' => ['boolean'], + 'notify_channels' => ['nullable', 'array'], + 'notify_channels.email' => ['sometimes', 'array'], + 'notify_channels.email.*' => ['email'], + 'notify_channels.slack_webhook' => ['sometimes', 'url', 'max:2048', new ExternalUrl], + 'notify_channels.webhook_url' => ['sometimes', 'url', 'max:2048', new ExternalUrl], + ]; + } +} diff --git a/app/Http/Requests/StoreCompetitorSourceRequest.php b/app/Http/Requests/StoreCompetitorSourceRequest.php new file mode 100644 index 0000000..42a197a --- /dev/null +++ b/app/Http/Requests/StoreCompetitorSourceRequest.php @@ -0,0 +1,29 @@ + */ + public function rules(): array + { + return [ + 'space_id' => ['required', 'string', 'exists:spaces,id'], + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url', 'max:2048', new ExternalUrl], + 'feed_url' => ['nullable', 'url', 'max:2048', new ExternalUrl], + 'crawler_type' => ['required', 'in:rss,sitemap,scrape,api'], + 'config' => ['nullable', 'array'], + 'is_active' => ['boolean'], + 'crawl_interval_minutes' => ['integer', 'min:5', 'max:10080'], + ]; + } +} diff --git a/app/Http/Requests/Templates/CreateVersionRequest.php b/app/Http/Requests/Templates/CreateVersionRequest.php new file mode 100644 index 0000000..4198004 --- /dev/null +++ b/app/Http/Requests/Templates/CreateVersionRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'version' => ['required', 'string', 'max:50'], + 'definition' => ['required', 'array'], + 'definition.schema_version' => ['required', 'string'], + 'definition.stages' => ['required', 'array', 'min:1'], + 'definition.settings' => ['nullable', 'array'], + 'definition.personas' => ['nullable', 'array'], + 'changelog' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Templates/InstallTemplateRequest.php b/app/Http/Requests/Templates/InstallTemplateRequest.php new file mode 100644 index 0000000..8f19b80 --- /dev/null +++ b/app/Http/Requests/Templates/InstallTemplateRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'variable_values' => ['nullable', 'array'], + 'config_overrides' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Templates/RateTemplateRequest.php b/app/Http/Requests/Templates/RateTemplateRequest.php new file mode 100644 index 0000000..fb3a001 --- /dev/null +++ b/app/Http/Requests/Templates/RateTemplateRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'rating' => ['required', 'integer', 'min:1', 'max:5'], + 'review' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Templates/StorePipelineTemplateRequest.php b/app/Http/Requests/Templates/StorePipelineTemplateRequest.php new file mode 100644 index 0000000..9b0ee47 --- /dev/null +++ b/app/Http/Requests/Templates/StorePipelineTemplateRequest.php @@ -0,0 +1,34 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'string', 'max:100'], + 'icon' => ['nullable', 'string', 'max:50'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:500'], + 'definition' => ['required', 'array'], + 'definition.schema_version' => ['required', 'string'], + 'definition.stages' => ['required', 'array', 'min:1'], + 'definition.settings' => ['nullable', 'array'], + 'definition.personas' => ['nullable', 'array'], + 'version' => ['nullable', 'string', 'max:50'], + 'changelog' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php b/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php new file mode 100644 index 0000000..3b69b74 --- /dev/null +++ b/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + /** @return array> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'string', 'max:100'], + 'icon' => ['nullable', 'string', 'max:50'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/UpdateCompetitorSourceRequest.php b/app/Http/Requests/UpdateCompetitorSourceRequest.php new file mode 100644 index 0000000..05e7711 --- /dev/null +++ b/app/Http/Requests/UpdateCompetitorSourceRequest.php @@ -0,0 +1,28 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'url' => ['sometimes', 'url', 'max:2048', new ExternalUrl], + 'feed_url' => ['nullable', 'url', 'max:2048', new ExternalUrl], + 'crawler_type' => ['sometimes', 'in:rss,sitemap,scrape,api'], + 'config' => ['nullable', 'array'], + 'is_active' => ['sometimes', 'boolean'], + 'crawl_interval_minutes' => ['sometimes', 'integer', 'min:5', 'max:10080'], + ]; + } +} diff --git a/app/Http/Resources/CompetitorAlertResource.php b/app/Http/Resources/CompetitorAlertResource.php new file mode 100644 index 0000000..fc7050f --- /dev/null +++ b/app/Http/Resources/CompetitorAlertResource.php @@ -0,0 +1,27 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'space_id' => $this->space_id, + 'name' => $this->name, + 'type' => $this->type, + 'conditions' => $this->conditions, + 'is_active' => $this->is_active, + 'notify_channels' => $this->notify_channels, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/CompetitorContentItemResource.php b/app/Http/Resources/CompetitorContentItemResource.php new file mode 100644 index 0000000..ab90bd6 --- /dev/null +++ b/app/Http/Resources/CompetitorContentItemResource.php @@ -0,0 +1,27 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'source_id' => $this->source_id, + 'external_url' => $this->external_url, + 'title' => $this->title, + 'excerpt' => $this->excerpt, + 'published_at' => $this->published_at?->toIso8601String(), + 'crawled_at' => $this->crawled_at?->toIso8601String(), + 'content_hash' => $this->content_hash, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Http/Resources/CompetitorSourceResource.php b/app/Http/Resources/CompetitorSourceResource.php new file mode 100644 index 0000000..3abc672 --- /dev/null +++ b/app/Http/Resources/CompetitorSourceResource.php @@ -0,0 +1,31 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'space_id' => $this->space_id, + 'name' => $this->name, + 'url' => $this->url, + 'feed_url' => $this->feed_url, + 'crawler_type' => $this->crawler_type, + 'config' => $this->config, + 'is_active' => $this->is_active, + 'crawl_interval_minutes' => $this->crawl_interval_minutes, + 'last_crawled_at' => $this->last_crawled_at?->toIso8601String(), + 'error_count' => $this->error_count, + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} 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/Http/Resources/DifferentiationAnalysisResource.php b/app/Http/Resources/DifferentiationAnalysisResource.php new file mode 100644 index 0000000..b04b5e6 --- /dev/null +++ b/app/Http/Resources/DifferentiationAnalysisResource.php @@ -0,0 +1,30 @@ + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'space_id' => $this->space_id, + 'content_id' => $this->content_id, + 'brief_id' => $this->brief_id, + 'competitor_content_id' => $this->competitor_content_id, + 'similarity_score' => $this->similarity_score, + 'differentiation_score' => $this->differentiation_score, + 'angles' => $this->angles, + 'gaps' => $this->gaps, + 'recommendations' => $this->recommendations, + 'analyzed_at' => $this->analyzed_at?->toIso8601String(), + 'competitor_content' => $this->whenLoaded('competitorContent', fn () => new CompetitorContentItemResource($this->competitorContent)), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateInstallResource.php b/app/Http/Resources/PipelineTemplateInstallResource.php new file mode 100644 index 0000000..400951b --- /dev/null +++ b/app/Http/Resources/PipelineTemplateInstallResource.php @@ -0,0 +1,28 @@ + $this->id, + 'template_id' => $this->template_id, + 'version_id' => $this->version_id, + 'space_id' => $this->space_id, + 'pipeline_id' => $this->pipeline_id, + 'installed_at' => $this->installed_at->toIso8601String(), + 'config_overrides' => $this->config_overrides, + 'template' => $this->whenLoaded('template', fn () => new PipelineTemplateResource($this->template)), + 'version' => $this->whenLoaded('templateVersion', fn () => new PipelineTemplateVersionResource($this->templateVersion)), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateResource.php b/app/Http/Resources/PipelineTemplateResource.php new file mode 100644 index 0000000..fdb80fd --- /dev/null +++ b/app/Http/Resources/PipelineTemplateResource.php @@ -0,0 +1,33 @@ + $this->id, + 'space_id' => $this->space_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'category' => $this->category, + 'icon' => $this->icon, + 'schema_version' => $this->schema_version, + 'is_published' => $this->is_published, + 'author_name' => $this->author_name, + 'author_url' => $this->author_url, + 'downloads_count' => $this->downloads_count, + 'latest_version' => $this->whenLoaded('latestVersion', fn () => new PipelineTemplateVersionResource($this->latestVersion)), + 'versions_count' => $this->whenLoaded('versions', fn () => $this->versions->count()), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateVersionResource.php b/app/Http/Resources/PipelineTemplateVersionResource.php new file mode 100644 index 0000000..90add71 --- /dev/null +++ b/app/Http/Resources/PipelineTemplateVersionResource.php @@ -0,0 +1,29 @@ + $this->id, + 'template_id' => $this->template_id, + 'version' => $this->version, + 'changelog' => $this->changelog, + 'is_latest' => $this->is_latest, + 'published_at' => $this->published_at?->toIso8601String(), + 'definition' => $this->when( + $request->routeIs('api.pipeline-templates.versions.show'), + fn () => $this->definition, + ), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/AnalyzeContentDifferentiationJob.php b/app/Jobs/AnalyzeContentDifferentiationJob.php new file mode 100644 index 0000000..e5595da --- /dev/null +++ b/app/Jobs/AnalyzeContentDifferentiationJob.php @@ -0,0 +1,78 @@ +onQueue('competitor'); + } + + public function handle( + DifferentiationAnalysisService $analysisService, + SimilarContentFinder $finder, + ): void { + $content = Content::find($this->contentId); + + if ($content === null) { + Log::warning('AnalyzeContentDifferentiationJob: content not found', ['content_id' => $this->contentId]); + + return; + } + + $fingerprint = ContentFingerprint::query() + ->where('fingerprintable_type', Content::class) + ->where('fingerprintable_id', $content->id) + ->first(); + + if ($fingerprint === null) { + Log::warning('AnalyzeContentDifferentiationJob: no fingerprint for content', ['content_id' => $this->contentId]); + + return; + } + + $similar = $finder->findSimilar($fingerprint, threshold: 0.3, limit: 10); + + if ($similar->isEmpty()) { + Log::info('AnalyzeContentDifferentiationJob: no similar competitor content found', ['content_id' => $this->contentId]); + + return; + } + + $analyses = $analysisService->analyze($content, $similar); + + Log::info('AnalyzeContentDifferentiationJob: complete', [ + 'content_id' => $this->contentId, + 'competitor_count' => $similar->count(), + 'analyses_stored' => $analyses->count(), + ]); + } + + public function failed(\Throwable $exception): void + { + Log::error('AnalyzeContentDifferentiationJob: job failed permanently', [ + 'content_id' => $this->contentId, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/CheckCompetitorAlertsJob.php b/app/Jobs/CheckCompetitorAlertsJob.php new file mode 100644 index 0000000..b9d756a --- /dev/null +++ b/app/Jobs/CheckCompetitorAlertsJob.php @@ -0,0 +1,52 @@ +competitorContentId); + + if (! $item) { + Log::warning('CheckCompetitorAlertsJob: content item not found', [ + 'id' => $this->competitorContentId, + ]); + + return; + } + + Log::info('CheckCompetitorAlertsJob: evaluating alerts', [ + 'competitor_content_id' => $item->id, + ]); + + $alertService->evaluate($item); + } + + public function tags(): array + { + return ['competitor', 'alerts', "content:{$this->competitorContentId}"]; + } +} diff --git a/app/Jobs/CrawlCompetitorSourceJob.php b/app/Jobs/CrawlCompetitorSourceJob.php new file mode 100644 index 0000000..4df127e --- /dev/null +++ b/app/Jobs/CrawlCompetitorSourceJob.php @@ -0,0 +1,73 @@ +onQueue('competitor'); + } + + public function handle(CrawlerService $crawlerService): void + { + Log::info('CrawlCompetitorSourceJob: starting', ['source_id' => $this->source->id]); + + $items = $crawlerService->crawlSource($this->source); + + if ($items->isEmpty()) { + Log::info('CrawlCompetitorSourceJob: no new items', ['source_id' => $this->source->id]); + + return; + } + + $saved = 0; + foreach ($items as $item) { + try { + /** @var CompetitorContentItem $item */ + $item->save(); + $saved++; + + // Dispatch fingerprinting after each new item is persisted + FingerprintContentJob::dispatch($item); + } catch (\Throwable $e) { + Log::warning('CrawlCompetitorSourceJob: failed to save item', [ + 'source_id' => $this->source->id, + 'url' => $item->external_url, + 'error' => $e->getMessage(), + ]); + } + } + + Log::info('CrawlCompetitorSourceJob: complete', [ + 'source_id' => $this->source->id, + 'saved' => $saved, + ]); + } + + public function failed(\Throwable $exception): void + { + Log::error('CrawlCompetitorSourceJob: job failed permanently', [ + 'source_id' => $this->source->id, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Jobs/FingerprintContentJob.php b/app/Jobs/FingerprintContentJob.php new file mode 100644 index 0000000..b64793b --- /dev/null +++ b/app/Jobs/FingerprintContentJob.php @@ -0,0 +1,58 @@ +onQueue('competitor'); + } + + public function handle(ContentFingerprintService $service): void + { + $type = $this->fingerprintable::class; + $id = $this->fingerprintable->getKey(); + + if ( + ! $this->fingerprintable instanceof Content + && ! $this->fingerprintable instanceof CompetitorContentItem + ) { + Log::warning('FingerprintContentJob: unsupported model type', ['type' => $type]); + + return; + } + + Log::info('FingerprintContentJob: fingerprinting', ['type' => $type, 'id' => $id]); + + $fingerprint = $service->fingerprint($this->fingerprintable); + + Log::info('FingerprintContentJob: done', [ + 'type' => $type, + 'id' => $id, + 'fingerprint_id' => $fingerprint->id, + 'topics' => count($fingerprint->topics ?? []), + 'entities' => count($fingerprint->entities ?? []), + 'keywords' => count($fingerprint->keywords ?? []), + ]); + } +} 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/FingerprintPublishedContent.php b/app/Listeners/FingerprintPublishedContent.php new file mode 100644 index 0000000..3e834d2 --- /dev/null +++ b/app/Listeners/FingerprintPublishedContent.php @@ -0,0 +1,21 @@ +content; + + Log::info('FingerprintPublishedContent: dispatching fingerprint job', [ + 'content_id' => $content->id, + ]); + + FingerprintContentJob::dispatch($content); + } +} 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..410c384 --- /dev/null +++ b/app/Models/CompetitorContentItem.php @@ -0,0 +1,79 @@ + 'datetime', + 'crawled_at' => 'datetime', + 'metadata' => 'array', + ]; + + /** @return BelongsTo */ + public function source(): BelongsTo + { + return $this->belongsTo(CompetitorSource::class, 'source_id'); + } + + /** @return BelongsTo */ + public function space(): BelongsTo + { + return $this->belongsTo(Space::class, 'space_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/ContentPipeline.php b/app/Models/ContentPipeline.php index 70b23bb..671bab6 100755 --- a/app/Models/ContentPipeline.php +++ b/app/Models/ContentPipeline.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property string $id @@ -22,7 +23,7 @@ */ class ContentPipeline extends Model { - use HasFactory, HasUlids; + use HasFactory, HasUlids, SoftDeletes; protected $fillable = ['space_id', 'name', 'stages', 'trigger_config', 'is_active']; 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/Models/PipelineTemplate.php b/app/Models/PipelineTemplate.php new file mode 100644 index 0000000..ece4ebe --- /dev/null +++ b/app/Models/PipelineTemplate.php @@ -0,0 +1,91 @@ + $versions + * @property-read \Illuminate\Database\Eloquent\Collection|PipelineTemplateVersion|null $latestVersion + * @property-read \Illuminate\Database\Eloquent\Collection $installs + * @property-read \Illuminate\Database\Eloquent\Collection $ratings + */ +class PipelineTemplate extends Model +{ + use HasFactory, HasUlids, SoftDeletes; + + protected $fillable = [ + 'space_id', + 'name', + 'slug', + 'description', + 'category', + 'icon', + 'schema_version', + 'author_name', + 'author_url', + 'downloads_count', + ]; + + protected $casts = [ + 'is_published' => 'boolean', + 'downloads_count' => 'integer', + ]; + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + public function versions(): HasMany + { + return $this->hasMany(PipelineTemplateVersion::class, 'template_id'); + } + + public function latestVersion(): HasOne + { + return $this->hasOne(PipelineTemplateVersion::class, 'template_id')->where('is_latest', true); + } + + public function installs(): HasMany + { + return $this->hasMany(PipelineTemplateInstall::class, 'template_id'); + } + + public function ratings(): HasMany + { + return $this->hasMany(PipelineTemplateRating::class, 'template_id'); + } + + public function isGlobal(): bool + { + return $this->space_id === null; + } + + public function averageRating(): float + { + return (float) $this->ratings()->avg('rating'); + } +} diff --git a/app/Models/PipelineTemplateInstall.php b/app/Models/PipelineTemplateInstall.php new file mode 100644 index 0000000..9466fb8 --- /dev/null +++ b/app/Models/PipelineTemplateInstall.php @@ -0,0 +1,56 @@ + 'datetime', + 'config_overrides' => 'array', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function templateVersion(): BelongsTo + { + return $this->belongsTo(PipelineTemplateVersion::class, 'version_id'); + } + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class, 'space_id'); + } +} diff --git a/app/Models/PipelineTemplateRating.php b/app/Models/PipelineTemplateRating.php new file mode 100644 index 0000000..cba7fcf --- /dev/null +++ b/app/Models/PipelineTemplateRating.php @@ -0,0 +1,45 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/PipelineTemplateVersion.php b/app/Models/PipelineTemplateVersion.php new file mode 100644 index 0000000..f4a1150 --- /dev/null +++ b/app/Models/PipelineTemplateVersion.php @@ -0,0 +1,52 @@ + $installs + */ +class PipelineTemplateVersion extends Model +{ + use HasFactory, HasUlids; + + protected $fillable = [ + 'template_id', + 'version', + 'definition', + 'changelog', + 'is_latest', + 'published_at', + ]; + + protected $casts = [ + 'definition' => 'array', + 'is_latest' => 'boolean', + 'published_at' => 'datetime', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function installs(): HasMany + { + return $this->hasMany(PipelineTemplateInstall::class, 'version_id'); + } +} diff --git a/app/Notifications/CompetitorAlertNotification.php b/app/Notifications/CompetitorAlertNotification.php new file mode 100644 index 0000000..7de296a --- /dev/null +++ b/app/Notifications/CompetitorAlertNotification.php @@ -0,0 +1,55 @@ + */ + public function via(mixed $notifiable): array + { + return ['mail']; + } + + public function toMail(mixed $notifiable): MailMessage + { + $source = $this->competitorContent->source; + + return (new MailMessage) + ->subject("[Numen] Competitor Alert: {$this->alert->name}") + ->greeting('Competitor Activity Detected') + ->line("Alert **{$this->alert->name}** was triggered.") + ->line('**Source:** '.($source !== null ? $source->name : 'Unknown')) + ->line('**Article:** '.($this->competitorContent->title ?? $this->competitorContent->external_url)) + ->line('**Published:** '.($this->competitorContent->published_at?->toDateTimeString() ?? 'Unknown')) + ->action('View Dashboard', url('/admin/competitors')) + ->line('This notification was sent by Numen Competitor Monitoring.'); + } + + /** @return array */ + public function toArray(mixed $notifiable): array + { + return [ + 'alert_id' => $this->alert->id, + 'alert_name' => $this->alert->name, + 'event_id' => $this->event->id, + 'competitor_content_id' => $this->competitorContent->id, + 'competitor_title' => $this->competitorContent->title, + ]; + } +} 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/Pipelines/Stages/CompetitorAnalysisStage.php b/app/Pipelines/Stages/CompetitorAnalysisStage.php new file mode 100644 index 0000000..17e4b11 --- /dev/null +++ b/app/Pipelines/Stages/CompetitorAnalysisStage.php @@ -0,0 +1,155 @@ + + */ + public static function configSchema(): array + { + return [ + 'enabled' => [ + 'type' => 'boolean', + 'default' => true, + 'description' => 'Whether to run competitor analysis for this stage.', + ], + 'similarity_threshold' => [ + 'type' => 'number', + 'default' => 0.25, + 'description' => 'Minimum similarity score to consider a competitor relevant.', + ], + 'max_competitors' => [ + 'type' => 'integer', + 'default' => 5, + 'description' => 'Maximum number of competitor items to analyse.', + ], + ]; + } + + /** + * Enrich the run's brief with competitor differentiation context. + * + * @param array $stageConfig + * @return array + */ + public function handle(PipelineRun $run, array $stageConfig): array + { + // ── 1. Resolve configuration ──────────────────────────────────────── + $globalEnabled = (bool) config('numen.competitor_analysis.enabled', true); + $stageEnabled = isset($stageConfig['enabled']) ? (bool) $stageConfig['enabled'] : $globalEnabled; + + if (! $stageEnabled) { + Log::info('CompetitorAnalysisStage: disabled — skipping', ['run_id' => $run->id]); + + return ['skipped' => true, 'reason' => 'disabled']; + } + + $threshold = isset($stageConfig['similarity_threshold']) + ? (float) $stageConfig['similarity_threshold'] + : (float) config('numen.competitor_analysis.similarity_threshold', 0.25); + + $maxCompetitors = isset($stageConfig['max_competitors']) + ? (int) $stageConfig['max_competitors'] + : (int) config('numen.competitor_analysis.max_competitors_to_analyze', 5); + + // ── 2. Resolve the brief ──────────────────────────────────────────── + $brief = $run->brief; + + if ($brief === null) { + Log::warning('CompetitorAnalysisStage: no brief attached to run', ['run_id' => $run->id]); + + return ['skipped' => true, 'reason' => 'no_brief']; + } + + // ── 3. Fingerprint + find similar competitors ─────────────────────── + try { + $fingerprint = $this->fingerprintService->fingerprint($brief); + $similar = $this->finder->findSimilar($fingerprint, threshold: $threshold, limit: $maxCompetitors); + } catch (\Throwable $e) { + Log::warning('CompetitorAnalysisStage: fingerprint/find failed', [ + 'run_id' => $run->id, + 'error' => $e->getMessage(), + ]); + + return ['skipped' => true, 'reason' => 'fingerprint_error', 'error' => $e->getMessage()]; + } + + if ($similar->isEmpty()) { + Log::info('CompetitorAnalysisStage: no similar competitors above threshold — skipping', [ + 'run_id' => $run->id, + 'threshold' => $threshold, + ]); + + return ['skipped' => true, 'reason' => 'no_similar_competitors']; + } + + // ── 4. Enrich the brief ───────────────────────────────────────────── + try { + $enrichedBrief = $this->analysisService->enrichBrief($brief, $this->finder); + } catch (\Throwable $e) { + Log::warning('CompetitorAnalysisStage: enrichment failed', [ + 'run_id' => $run->id, + 'error' => $e->getMessage(), + ]); + + return ['skipped' => true, 'reason' => 'enrichment_error', 'error' => $e->getMessage()]; + } + + // ── 5. Update pipeline context with competitor metadata ───────────── + $context = $run->context ?? []; + $context['competitor_analysis'] = $enrichedBrief->requirements['competitor_differentiation'] ?? []; + $run->update(['context' => $context]); + + $competitorCount = $similar->count(); + + Log::info('CompetitorAnalysisStage: brief enriched', [ + 'run_id' => $run->id, + 'brief_id' => $brief->id, + 'competitor_count' => $competitorCount, + ]); + + return [ + 'enriched' => true, + 'competitor_count' => $competitorCount, + 'brief_id' => $brief->id, + ]; + } +} diff --git a/app/Plugin/HookRegistry.php b/app/Plugin/HookRegistry.php index 254d95f..2e0499b 100644 --- a/app/Plugin/HookRegistry.php +++ b/app/Plugin/HookRegistry.php @@ -37,6 +37,12 @@ class HookRegistry /** @var array */ private array $vueComponents = []; + /** @var array */ + private array $templateCategories = []; + + /** @var array>, author: string|null, url: string|null}> */ + private array $templatePacks = []; + // ── Pipeline stages ──────────────────────────────────────────────────────── public function registerPipelineStage(string $stageName, Closure $handler): void @@ -251,4 +257,81 @@ public function getVueComponents(): array { return $this->vueComponents; } + // ── Template categories ──────────────────────────────────────────────────── + + /** + * Register a custom pipeline template category. + * + * @param array{slug: string, label: string, description?: string|null, icon?: string|null} $category + */ + public function registerTemplateCategory(array $category): void + { + $this->templateCategories[] = [ + 'slug' => $category['slug'], + 'label' => $category['label'], + 'description' => $category['description'] ?? null, + 'icon' => $category['icon'] ?? null, + ]; + } + + /** + * @return array + */ + public function getTemplateCategories(): array + { + return $this->templateCategories; + } + + /** + * Get all registered category slugs (built-in + plugin-registered). + * + * @return array + */ + public function getTemplateCategorySlugs(): array + { + return array_column($this->templateCategories, 'slug'); + } + + // ── Template packs ───────────────────────────────────────────────────────── + + /** + * Register a pack of pipeline templates bundled with a plugin. + * + * @param array{id: string, name: string, templates: array>, author?: string|null, url?: string|null} $pack + */ + public function registerTemplatePack(array $pack): void + { + $this->templatePacks[] = [ + 'id' => $pack['id'], + 'name' => $pack['name'], + 'templates' => $pack['templates'], + 'author' => $pack['author'] ?? null, + 'url' => $pack['url'] ?? null, + ]; + } + + /** + * @return array>, author: string|null, url: string|null}> + */ + public function getTemplatePacks(): array + { + return $this->templatePacks; + } + + /** + * Retrieve all template definitions from all registered packs. + * + * @return array> + */ + public function getAllPackTemplates(): array + { + $result = []; + foreach ($this->templatePacks as $pack) { + foreach ($pack['templates'] as $template) { + $result[] = array_merge($template, ['_pack_id' => $pack['id']]); + } + } + + return $result; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 62a51eb..47fd34d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use App\Listeners\UpdateKnowledgeGraphListener; use App\Models\Content; use App\Models\Setting; +use App\Pipelines\Stages\CompetitorAnalysisStage; use App\Plugin\HookRegistry; use App\Plugin\PluginLoader; use App\Policies\ContentPolicy; @@ -26,6 +27,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; @@ -47,6 +55,7 @@ public function register(): void // ── Authorization ────────────────────────────────────────────────── // ── Plugin system ────────────────────────────────────────────────────── $this->app->singleton(HookRegistry::class); + $this->app->singleton(\App\Services\PipelineTemplates\TemplateHookIntegrationService::class); $this->app->singleton(PluginLoader::class, fn ($app) => new PluginLoader($app)); $this->app->singleton(AuthorizationService::class); @@ -111,6 +120,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 @@ -133,6 +152,11 @@ public function boot(): void $llmManager->registerProvider($name, $provider); } + // Register built-in competitor analysis pipeline stage + $hookRegistry->registerPipelineStageClass( + CompetitorAnalysisStage::type(), + CompetitorAnalysisStage::class, + ); // Register search event listeners Event::listen(ContentPublished::class, IndexContentForSearch::class); Event::listen(ContentUnpublished::class, RemoveFromSearchIndex::class); 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/Competitor/Alerts/SlackChannel.php b/app/Services/Competitor/Alerts/SlackChannel.php new file mode 100644 index 0000000..4e18b61 --- /dev/null +++ b/app/Services/Competitor/Alerts/SlackChannel.php @@ -0,0 +1,72 @@ +notify_channels ?? []; + $webhookUrl = $channels['slack_webhook'] ?? null; + + if (! $webhookUrl) { + return; + } + + $source = $item->source; + $payload = [ + 'text' => "🔔 *Competitor Alert: {$alert->name}*", + 'blocks' => [ + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => "*Competitor Alert Triggered: {$alert->name}*", + ], + ], + [ + 'type' => 'section', + 'fields' => [ + ['type' => 'mrkdwn', 'text' => "*Source:*\n".($source !== null ? $source->name : 'Unknown')], + ['type' => 'mrkdwn', 'text' => "*Type:*\n".$alert->type], + ], + ], + [ + 'type' => 'section', + 'text' => [ + 'type' => 'mrkdwn', + 'text' => "*Article:* <{$item->external_url}|".($item->title ?? $item->external_url).'>', + ], + ], + [ + 'type' => 'actions', + 'elements' => [ + [ + 'type' => 'button', + 'text' => ['type' => 'plain_text', 'text' => 'View Dashboard'], + 'url' => url('/admin/competitors'), + ], + ], + ], + ], + ]; + + try { + Http::post($webhookUrl, $payload); + } catch (\Throwable $e) { + Log::warning('CompetitorAlert Slack send failed', [ + 'alert_id' => $alert->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Competitor/Alerts/WebhookChannel.php b/app/Services/Competitor/Alerts/WebhookChannel.php new file mode 100644 index 0000000..ceb655a --- /dev/null +++ b/app/Services/Competitor/Alerts/WebhookChannel.php @@ -0,0 +1,56 @@ +notify_channels ?? []; + $webhookUrl = $channels['webhook_url'] ?? null; + + if (! $webhookUrl) { + return; + } + + $payload = [ + 'event' => 'competitor_alert', + 'alert' => [ + 'id' => $alert->id, + 'name' => $alert->name, + 'type' => $alert->type, + ], + 'alert_event' => [ + 'id' => $event->id, + 'triggered_at' => now()->toIso8601String(), + 'trigger_data' => $event->trigger_data, + ], + 'competitor_content' => [ + 'id' => $item->id, + 'title' => $item->title, + 'url' => $item->external_url, + 'published_at' => $item->published_at?->toIso8601String(), + 'source_name' => $item->source !== null ? $item->source->name : null, + ], + ]; + + try { + Http::timeout(10)->post($webhookUrl, $payload); + } catch (\Throwable $e) { + Log::warning('CompetitorAlert webhook send failed', [ + 'alert_id' => $alert->id, + 'webhook_url' => $webhookUrl, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Competitor/CompetitorAlertService.php b/app/Services/Competitor/CompetitorAlertService.php new file mode 100644 index 0000000..88e60df --- /dev/null +++ b/app/Services/Competitor/CompetitorAlertService.php @@ -0,0 +1,172 @@ +source; + if (! $source) { + return; + } + + $alerts = CompetitorAlert::where('space_id', $source->space_id) + ->where('is_active', true) + ->get(); + + foreach ($alerts as $alert) { + if ($this->matches($alert, $item)) { + $this->fire($alert, $item); + } + } + } + + /** + * Check whether an alert's conditions match a competitor content item. + */ + public function matches(CompetitorAlert $alert, CompetitorContentItem $item): bool + { + $conditions = $alert->conditions ?? []; + + return match ($alert->type) { + 'new_content' => $this->matchesNewContent($conditions, $item), + 'keyword' => $this->matchesKeyword($conditions, $item), + 'high_similarity' => $this->matchesHighSimilarity($conditions, $item), + default => false, + }; + } + + /** + * Fire an alert: record the event and dispatch notifications. + */ + public function fire(CompetitorAlert $alert, CompetitorContentItem $item): CompetitorAlertEvent + { + // Deduplicate: skip if already notified for this (alert, item) pair + $existing = CompetitorAlertEvent::where('alert_id', $alert->id) + ->where('competitor_content_id', $item->id) + ->whereNotNull('notified_at') + ->first(); + + if ($existing) { + return $existing; + } + + $event = CompetitorAlertEvent::create([ + 'alert_id' => $alert->id, + 'competitor_content_id' => $item->id, + 'trigger_data' => [ + 'alert_type' => $alert->type, + 'conditions' => $alert->conditions, + 'fired_at' => now()->toIso8601String(), + ], + 'notified_at' => now(), + ]); + + $this->dispatch($alert, $event, $item); + + return $event; + } + + // ───────────────────────────────────────────────────────── + // Condition matchers + // ───────────────────────────────────────────────────────── + + /** @param array $conditions */ + private function matchesNewContent(array $conditions, CompetitorContentItem $item): bool + { + // If source_id restriction is set, ensure the item belongs to that source + if (isset($conditions['source_id']) && $item->source_id !== $conditions['source_id']) { + return false; + } + + // Only fire once per item (already crawled_at set means it's not new) + return $item->crawled_at !== null && + $item->crawled_at->diffInMinutes(now()) <= 10; + } + + /** @param array $conditions */ + private function matchesKeyword(array $conditions, CompetitorContentItem $item): bool + { + $keywords = $conditions['keywords'] ?? []; + if (empty($keywords)) { + return false; + } + + $searchText = mb_strtolower(($item->title ?? '').' '.($item->excerpt ?? '').' '.($item->body ?? '')); + + foreach ($keywords as $keyword) { + if (str_contains($searchText, mb_strtolower((string) $keyword))) { + return true; + } + } + + return false; + } + + /** @param array $conditions */ + private function matchesHighSimilarity(array $conditions, CompetitorContentItem $item): bool + { + $threshold = (float) ($conditions['similarity_threshold'] ?? 0.7); + + return $item->differentiationAnalyses() + ->where('similarity_score', '>=', $threshold) + ->exists(); + } + + // ───────────────────────────────────────────────────────── + // Notification dispatch + // ───────────────────────────────────────────────────────── + + private function dispatch( + CompetitorAlert $alert, + CompetitorAlertEvent $event, + CompetitorContentItem $item, + ): void { + $channels = $alert->notify_channels ?? []; + + // Email + if (! empty($channels['email'])) { + $emails = is_array($channels['email']) ? $channels['email'] : [$channels['email']]; + foreach ($emails as $email) { + try { + Notification::route('mail', $email) + ->notify(new CompetitorAlertNotification($alert, $event, $item)); + } catch (\Throwable $e) { + Log::warning('CompetitorAlert email dispatch failed', [ + 'alert_id' => $alert->id, + 'email' => $email, + 'error' => $e->getMessage(), + ]); + } + } + } + + // Slack + if (! empty($channels['slack_webhook'])) { + $this->slack->send($alert, $event, $item); + } + + // Generic webhook + if (! empty($channels['webhook_url'])) { + $this->webhook->send($alert, $event, $item); + } + } +} diff --git a/app/Services/Competitor/CompetitorGraphIndexer.php b/app/Services/Competitor/CompetitorGraphIndexer.php new file mode 100644 index 0000000..0a645b2 --- /dev/null +++ b/app/Services/Competitor/CompetitorGraphIndexer.php @@ -0,0 +1,151 @@ + $similarContentPairs + */ + public function index(CompetitorContentItem $item, array $similarContentPairs = []): string + { + $nodeId = $this->nodeIdForItem($item); + + // Upsert the competitor graph node (virtual — no owned content_id) + $this->upsertNode($nodeId, $item); + + // Create competitor_similarity edges to owned content nodes + foreach ($similarContentPairs as $pair) { + $this->upsertSimilarityEdge($nodeId, $pair['content_id'], (float) $pair['similarity_score'], ($item->source !== null ? $item->source->space_id : '')); + } + + Log::info('CompetitorGraphIndexer: indexed item', [ + 'competitor_content_id' => $item->id, + 'node_id' => $nodeId, + 'similarity_pairs' => count($similarContentPairs), + ]); + + return $nodeId; + } + + /** + * Remove all graph nodes and edges for a competitor source. + */ + public function removeSourceNodes(string $sourceId): int + { + // Find all node ids belonging to this source + $nodeIds = ContentGraphNode::where('node_metadata->source_id', $sourceId) + ->pluck('id'); + + if ($nodeIds->isEmpty()) { + return 0; + } + + // Delete edges + ContentGraphEdge::whereIn('source_id', $nodeIds) + ->orWhereIn('target_id', $nodeIds) + ->delete(); + + // Delete nodes + ContentGraphNode::whereIn('id', $nodeIds)->delete(); + + return $nodeIds->count(); + } + + // ───────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────── + + private function nodeIdForItem(CompetitorContentItem $item): string + { + // Stable deterministic ID based on competitor content ID + // We use a ULID-compatible 26-char string derived from the item id + return substr(sha1(self::NODE_PREFIX.$item->id), 0, 26); + } + + private function upsertNode(string $nodeId, CompetitorContentItem $item): void + { + $source = $item->source; + $spaceId = $source !== null ? $source->space_id : ''; + + /** @var array $metadata */ + $metadata = [ + 'competitor' => true, + 'source_id' => $item->source_id, + 'source_name' => $source !== null ? $source->name : null, + 'external_url' => $item->external_url, + 'title' => $item->title, + 'published_at' => $item->published_at?->toIso8601String(), + ]; + + // Build entity labels from title words (simple keyword extraction) + $entityLabels = $item->title !== null + ? array_values(array_unique(array_filter( + explode(' ', preg_replace('/[^\w\s]/u', '', $item->title) ?? ''), + fn (string $w): bool => mb_strlen($w) > 3, + ))) + : []; + + // Use updateOrCreate keyed on the virtual node id + ContentGraphNode::updateOrCreate( + ['id' => $nodeId], + [ + 'id' => $nodeId, + 'content_id' => $nodeId, // virtual — same as node id + 'space_id' => $spaceId, + 'locale' => 'en', + 'entity_labels' => $entityLabels, + 'node_metadata' => $metadata, + 'indexed_at' => now(), + ], + ); + } + + private function upsertSimilarityEdge( + string $competitorNodeId, + string $ownedContentId, + float $similarityScore, + string $spaceId, + ): void { + // Find the owned content's graph node + $ownedNode = ContentGraphNode::where('content_id', $ownedContentId)->first(); + + if (! $ownedNode) { + return; + } + + ContentGraphEdge::updateOrCreate( + [ + 'source_id' => $competitorNodeId, + 'target_id' => $ownedNode->id, + 'edge_type' => self::EDGE_TYPE, + ], + [ + 'space_id' => $spaceId, + 'weight' => round($similarityScore, 6), + 'edge_metadata' => [ + 'competitor_node_id' => $competitorNodeId, + 'owned_node_id' => $ownedNode->id, + 'owned_content_id' => $ownedContentId, + 'indexed_at' => now()->toIso8601String(), + ], + ], + ); + } +} diff --git a/app/Services/Competitor/ContentFingerprintService.php b/app/Services/Competitor/ContentFingerprintService.php new file mode 100644 index 0000000..122f778 --- /dev/null +++ b/app/Services/Competitor/ContentFingerprintService.php @@ -0,0 +1,295 @@ + $this->extractFromBrief($fingerprintable), + $fingerprintable instanceof Content => $this->extractFromContent($fingerprintable), + $fingerprintable instanceof CompetitorContentItem => $this->extractFromCompetitorItem($fingerprintable), + default => $this->extractFromText('', ''), + }; + + /** @var ContentFingerprint $fp */ + $fp = ContentFingerprint::firstOrCreate( + [ + 'fingerprintable_type' => $fingerprintable->getMorphClass(), + 'fingerprintable_id' => $fingerprintable->getKey(), + ], + [ + 'topics' => $topics, + 'entities' => $entities, + 'keywords' => $keywords, + 'fingerprinted_at' => now(), + ] + ); + + return $fp; + } + + /** @return array{0: array, 1: array, 2: array} */ + private function extractFromContent(Content $content): array + { + $version = $content->currentVersion; + $title = ($version !== null) ? ($version->title ?? '') : ''; + $body = ($version !== null) ? strip_tags($version->body ?? '') : ''; + $excerpt = ($version !== null) ? ($version->excerpt ?? '') : ''; + $text = implode(' ', array_filter([$title, $excerpt, $body])); + + $extractor = $this->resolveEntityExtractor(); + if ($extractor !== null) { + try { + $extracted = $extractor->extract($content); + + $topics = array_values(array_map( + fn (array $e) => $e['entity'], + array_filter($extracted, fn (array $e) => in_array($e['type'], ['topic', 'concept'], true)) + )); + + $entities = array_values(array_map( + fn (array $e) => $e['entity'], + array_filter($extracted, fn (array $e) => in_array($e['type'], ['person', 'product', 'place'], true)) + )); + + $keywords = $this->extractKeywords($text); + + return [$topics, $entities, $keywords]; + } catch (\Throwable $e) { + Log::warning('ContentFingerprintService: EntityExtractor failed, falling back to basic NLP', [ + 'content_id' => $content->id, + 'error' => $e->getMessage(), + ]); + } + } + + return $this->extractFromText($title, $body); + } + + /** @return array{0: array, 1: array, 2: array} */ + private function extractFromBrief(ContentBrief $brief): array + { + $title = $brief->title ?? ''; + $description = $brief->description ?? ''; + $targetKeywords = $brief->target_keywords ?? []; + + [$topics, $entities, $extractedKeywords] = $this->extractFromText($title, $description); + + // Use target_keywords as primary topics (they are explicit intent signals) + foreach ($targetKeywords as $kw) { + $kw = trim($kw); + if ($kw !== '' && ! in_array(strtolower($kw), array_map('strtolower', $topics), true)) { + array_unshift($topics, $kw); + } + } + + $topics = array_slice($topics, 0, 15); + + // Merge explicit target_keywords with extracted ones (target keywords take priority) + foreach ($targetKeywords as $kw) { + $kw = strtolower(trim($kw)); + if ($kw !== '') { + $extractedKeywords[$kw] = 1.0; // highest weight for explicit keywords + } + } + + arsort($extractedKeywords); + + return [$topics, $entities, array_slice($extractedKeywords, 0, self::TOP_KEYWORDS, true)]; + } + + /** @return array{0: array, 1: array, 2: array} */ + private function extractFromCompetitorItem(CompetitorContentItem $item): array + { + $title = $item->title ?? ''; + $body = strip_tags($item->body ?? ''); + + return $this->extractFromText($title, $body); + } + + /** @return array{0: array, 1: array, 2: array} */ + private function extractFromText(string $title, string $body): array + { + $fullText = implode(' ', array_filter([$title, $body])); + + $topics = $this->extractTopics($title, $body); + $entities = $this->extractEntities($title, $body); + $keywords = $this->extractKeywords($fullText); + + return [$topics, $entities, $keywords]; + } + + /** @return array */ + private function extractTopics(string $title, string $body): array + { + $topics = []; + + if ($title !== '') { + $segments = preg_split('/[:\-\x{2013}\x{2014}|]/u', $title); + if ($segments !== false) { + foreach ($segments as $segment) { + $clean = trim($segment); + if (mb_strlen($clean) >= 4 && mb_strlen($clean) <= 60) { + $topics[] = $clean; + } + } + } + } + + $bigrams = $this->extractBigrams($body); + foreach (array_slice($bigrams, 0, 10) as $bigram) { + if (! in_array($bigram, $topics, true)) { + $topics[] = $bigram; + } + } + + return array_values(array_unique(array_slice($topics, 0, 15))); + } + + /** @return array */ + private function extractEntities(string $title, string $body): array + { + $entities = []; + + $text = implode(' ', array_filter([$title, $body])); + $text = preg_replace('/([.!?]\s+)[A-Z]/', '$1_', $text); + if ($text === null) { + $text = ''; + } + + if (preg_match_all('/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/', $text, $matches)) { + foreach ($matches[1] as $match) { + $match = trim($match); + if (mb_strlen($match) >= 4 && mb_strlen($match) <= 50) { + $entities[] = $match; + } + } + } + + if (preg_match_all('/(?<=[a-z,;]\s)([A-Z][a-z]{2,})\b/', $text, $matches)) { + foreach ($matches[1] as $match) { + if (! in_array(strtolower($match), self::STOPWORDS, true)) { + $entities[] = trim($match); + } + } + } + + return array_values(array_unique(array_slice($entities, 0, 20))); + } + + /** @return array */ + private function extractKeywords(string $text): array + { + if (trim($text) === '') { + return []; + } + + $words = preg_split('/\W+/u', strtolower($text), -1, PREG_SPLIT_NO_EMPTY); + if ($words === false) { + return []; + } + + $freq = []; + foreach ($words as $word) { + if ( + mb_strlen($word) >= self::MIN_WORD_LENGTH + && ! in_array($word, self::STOPWORDS, true) + && ! is_numeric($word) + ) { + $freq[$word] = ($freq[$word] ?? 0) + 1; + } + } + + if (empty($freq)) { + return []; + } + + $totalWords = max(1, count($words)); + + $scored = []; + foreach ($freq as $term => $count) { + $tf = $count / $totalWords; + $lengthBonus = min(1.0, mb_strlen($term) / 10); + $scored[$term] = round($tf * (1 + $lengthBonus), 6); + } + + arsort($scored); + + return array_slice($scored, 0, self::TOP_KEYWORDS, true); + } + + /** @return array */ + private function extractBigrams(string $text): array + { + $words = preg_split('/\W+/u', strtolower($text), -1, PREG_SPLIT_NO_EMPTY); + if ($words === false) { + return []; + } + + $filtered = array_values(array_filter($words, fn (string $w) => mb_strlen($w) >= self::MIN_WORD_LENGTH + && ! in_array($w, self::STOPWORDS, true))); + + $bigrams = []; + $count = count($filtered); + for ($i = 0; $i < $count - 1; $i++) { + $bigrams[] = $filtered[$i].' '.$filtered[$i + 1]; + } + + $freq = array_count_values($bigrams); + arsort($freq); + + $result = array_keys(array_filter($freq, fn (int $c) => $c > 1)); + + return array_slice($result, 0, 10); + } + + private function resolveEntityExtractor(): ?EntityExtractor + { + if ($this->entityExtractor !== null) { + return $this->entityExtractor; + } + + try { + /** @var EntityExtractor $extractor */ + $extractor = App::make(EntityExtractor::class); + + return $extractor; + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Services/Competitor/CrawlerHealthMonitor.php b/app/Services/Competitor/CrawlerHealthMonitor.php new file mode 100644 index 0000000..1aa79d4 --- /dev/null +++ b/app/Services/Competitor/CrawlerHealthMonitor.php @@ -0,0 +1,85 @@ +}> + */ + public function unhealthySources(): Collection + { + return CompetitorSource::where('is_active', true) + ->get() + ->filter(fn (CompetitorSource $source) => count($this->issuesFor($source)) > 0) + ->map(fn (CompetitorSource $source) => [ + 'source' => $source, + 'issues' => $this->issuesFor($source), + ]) + ->values(); + } + + /** + * @return array + */ + public function issuesFor(CompetitorSource $source): array + { + $issues = []; + + if ($source->error_count >= self::MAX_ERROR_COUNT) { + $issues[] = "High error count: {$source->error_count} errors"; + } + + if (! $source->last_crawled_at) { + $issues[] = 'Never crawled'; + } elseif ($source->last_crawled_at->diffInHours(Carbon::now()) >= self::STALE_THRESHOLD_HOURS) { + $hours = $source->last_crawled_at->diffInHours(Carbon::now()); + $issues[] = "Stale: last crawled {$hours}h ago"; + } + + return $issues; + } + + /** + * Run a health check and log warnings for any unhealthy sources. + * + * @return array{healthy: int, unhealthy: int, issues: array}>} + */ + public function check(): array + { + $unhealthy = $this->unhealthySources(); + $total = CompetitorSource::where('is_active', true)->count(); + + $issues = $unhealthy->map(fn (array $entry) => [ + 'source_id' => $entry['source']->id, + 'source_name' => $entry['source']->name, + 'issues' => $entry['issues'], + ])->all(); + + if (! empty($issues)) { + Log::warning('CrawlerHealthMonitor: unhealthy sources detected', [ + 'count' => count($issues), + 'issues' => $issues, + ]); + } + + return [ + 'healthy' => $total - count($issues), + 'unhealthy' => count($issues), + 'issues' => $issues, + ]; + } +} diff --git a/app/Services/Competitor/CrawlerService.php b/app/Services/Competitor/CrawlerService.php new file mode 100644 index 0000000..1a2351d --- /dev/null +++ b/app/Services/Competitor/CrawlerService.php @@ -0,0 +1,239 @@ +crawlers = $crawlers; + } + + /** + * Register a crawler implementation. + */ + public function registerCrawler(CrawlerContract $crawler): void + { + $this->crawlers[] = $crawler; + } + + /** + * Crawl a single competitor source, applying rate-limiting, robots.txt, and circuit breaker logic. + * + * @return Collection + */ + public function crawlSource(CompetitorSource $source): Collection + { + // Rate limiting: skip if crawled too recently + if ($this->isTooSoon($source)) { + Log::info('CrawlerService: skipping source (rate limited)', ['source_id' => $source->id]); + + return collect(); + } + + // Circuit breaker: skip disabled sources + if (! $source->is_active) { + Log::info('CrawlerService: skipping inactive source', ['source_id' => $source->id]); + + return collect(); + } + + // Robots.txt check (only for scrape and sitemap types) + if (in_array($source->crawler_type, ['scrape', 'sitemap'], true)) { + if (! $this->isAllowedByRobots($source->url)) { + Log::warning('CrawlerService: blocked by robots.txt', ['url' => $source->url]); + + return collect(); + } + } + + $crawler = $this->resolveCrawler($source->crawler_type); + if (! $crawler) { + Log::error('CrawlerService: no crawler found for type', ['type' => $source->crawler_type]); + + return collect(); + } + + try { + $items = $crawler->crawl($source); + $items = $this->deduplicate($source, $items); + + // Reset error count on success + $source->update([ + 'last_crawled_at' => now(), + 'error_count' => 0, + ]); + + return $items; + } catch (\Throwable $e) { + $this->handleCrawlError($source, $e); + + return collect(); + } + } + + /** + * Check if the source was crawled too recently (rate limiting). + */ + public function isTooSoon(CompetitorSource $source): bool + { + if (! $source->last_crawled_at) { + return false; + } + + $intervalMinutes = max(1, $source->crawl_interval_minutes); + + return $source->last_crawled_at->addMinutes($intervalMinutes)->isFuture(); + } + + /** + * Check robots.txt for the given URL. + */ + public function isAllowedByRobots(string $url): bool + { + $parsed = parse_url($url); + if (! $parsed || empty($parsed['host'])) { + return true; + } + + $base = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? ''); + $robotsUrl = $base.'/robots.txt'; + + try { + $response = Http::timeout(10)->get($robotsUrl); + + if (! $response->successful()) { + // If robots.txt not found, assume allowed + return true; + } + + return $this->parseRobotsTxt($response->body(), $url); + } catch (\Throwable $e) { + // If we can't fetch robots.txt, assume allowed + Log::warning('CrawlerService: could not fetch robots.txt', ['url' => $robotsUrl, 'error' => $e->getMessage()]); + + return true; + } + } + + /** + * Parse robots.txt and determine if our crawler is allowed to access the URL. + */ + public function parseRobotsTxt(string $content, string $targetUrl): bool + { + $parsed = parse_url($targetUrl); + $path = $parsed['path'] ?? '/'; + + $inOurSection = false; + $disallowedPaths = []; + + foreach (explode("\n", $content) as $line) { + $line = trim($line); + + if (str_starts_with($line, '#')) { + continue; + } + + if (stripos($line, 'User-agent:') === 0) { + $agent = trim(substr($line, strlen('User-agent:'))); + $inOurSection = ($agent === '*' || stripos($agent, 'numen') !== false); + + continue; + } + + if ($inOurSection && stripos($line, 'Disallow:') === 0) { + $disallowedPath = trim(substr($line, strlen('Disallow:'))); + if ($disallowedPath) { + $disallowedPaths[] = $disallowedPath; + } + } + } + + foreach ($disallowedPaths as $disallowed) { + if (str_starts_with($path, $disallowed)) { + return false; + } + } + + return true; + } + + /** + * Deduplicate items by content_hash against existing DB records. + * + * @param Collection $items + * @return Collection + */ + public function deduplicate(CompetitorSource $source, Collection $items): Collection + { + if ($items->isEmpty()) { + return $items; + } + + $hashes = $items->pluck('content_hash')->filter()->all(); + $existingHashes = CompetitorContentItem::where('source_id', $source->id) + ->whereIn('content_hash', $hashes) + ->pluck('content_hash') + ->flip() + ->all(); + + return $items->filter(fn (CompetitorContentItem $item) => ! isset($existingHashes[$item->content_hash])); + } + + /** + * Handle a crawl error: increment error_count and disable source if threshold exceeded. + */ + private function handleCrawlError(CompetitorSource $source, \Throwable $e): void + { + Log::error('CrawlerService: crawl error', [ + 'source_id' => $source->id, + 'error' => $e->getMessage(), + ]); + + $newCount = $source->error_count + 1; + $shouldDisable = $newCount >= self::ERROR_THRESHOLD; + + $source->update([ + 'error_count' => $newCount, + 'is_active' => $shouldDisable ? false : $source->is_active, + 'last_crawled_at' => now(), + ]); + + if ($shouldDisable) { + Log::error('CrawlerService: source disabled due to repeated errors', [ + 'source_id' => $source->id, + 'error_count' => $newCount, + ]); + } + } + + /** + * Find a crawler that supports the given type. + */ + private function resolveCrawler(string $type): ?CrawlerContract + { + foreach ($this->crawlers as $crawler) { + if ($crawler->supports($type)) { + return $crawler; + } + } + + return null; + } +} diff --git a/app/Services/Competitor/Crawlers/ApiCrawler.php b/app/Services/Competitor/Crawlers/ApiCrawler.php new file mode 100644 index 0000000..6d73f3a --- /dev/null +++ b/app/Services/Competitor/Crawlers/ApiCrawler.php @@ -0,0 +1,168 @@ + + */ + public function crawl(CompetitorSource $source): Collection + { + $config = $source->config ?? []; + $endpoint = $config['endpoint'] ?? $source->url; + $fieldMap = $config['field_map'] ?? []; + $dataPath = $config['data_path'] ?? null; + $pagination = $config['pagination'] ?? null; + $params = $config['params'] ?? []; + + $items = collect(); + $maxPages = (int) ($pagination['max_pages'] ?? 1); + $pageParam = $pagination['param'] ?? 'page'; + + for ($page = 1; $page <= $maxPages; $page++) { + $requestParams = $params; + if ($pagination) { + $requestParams[$pageParam] = $page; + } + + $request = Http::timeout(30); + $request = $this->applyAuth($request, $config['auth'] ?? []); + $response = $request->get($endpoint, $requestParams); + + if (! $response->successful()) { + break; + } + + $data = $response->json(); + $rawItems = $this->extractDataPath($data, $dataPath); + + if (empty($rawItems)) { + break; + } + + foreach ($rawItems as $raw) { + $item = $this->mapItem($source, $raw, $fieldMap); + if ($item) { + $items->push($item); + } + } + + // If no pagination configured, only do one page + if (! $pagination) { + break; + } + } + + return $items; + } + + /** + * @param \Illuminate\Http\Client\PendingRequest $request + * @param array $auth + * @return \Illuminate\Http\Client\PendingRequest + */ + private function applyAuth($request, array $auth) + { + $type = $auth['type'] ?? 'none'; + + return match ($type) { + 'bearer' => $request->withToken($auth['token'] ?? ''), + 'basic' => $request->withBasicAuth($auth['username'] ?? '', $auth['password'] ?? ''), + 'header' => $request->withHeaders([$auth['header'] ?? 'X-API-Key' => $auth['value'] ?? '']), + default => $request, + }; + } + + /** + * Extract items from the response using a dot-notation path. + * + * @return array + */ + private function extractDataPath(mixed $data, ?string $path): array + { + if (! $path) { + return is_array($data) ? $data : []; + } + + $value = data_get($data, $path); + + return is_array($value) ? $value : []; + } + + /** + * @param array $raw + * @param array $fieldMap + */ + private function mapItem(CompetitorSource $source, array $raw, array $fieldMap): ?CompetitorContentItem + { + $get = function (string $key) use ($raw, $fieldMap): mixed { + $field = $fieldMap[$key] ?? $key; + + return data_get($raw, $field); + }; + + $url = (string) ($get('url') ?? ''); + if (empty($url)) { + return null; + } + + $title = (string) ($get('title') ?? ''); + $excerpt = (string) ($get('excerpt') ?? ''); + $body = (string) ($get('body') ?? ''); + $dateStr = $get('published_at'); + $publishedAt = $dateStr ? \Carbon\Carbon::parse((string) $dateStr) : null; + + return new CompetitorContentItem([ + 'source_id' => $source->id, + 'external_url' => $url, + 'title' => $title ?: null, + 'excerpt' => $excerpt ?: null, + 'body' => $body ?: null, + 'published_at' => $publishedAt, + 'crawled_at' => now(), + 'content_hash' => md5($url.$title.$body), + 'metadata' => ['source' => 'api', 'raw' => $raw], + ]); + } +} diff --git a/app/Services/Competitor/Crawlers/CrawlerContract.php b/app/Services/Competitor/Crawlers/CrawlerContract.php new file mode 100644 index 0000000..8c2777e --- /dev/null +++ b/app/Services/Competitor/Crawlers/CrawlerContract.php @@ -0,0 +1,22 @@ + + */ + public function crawl(CompetitorSource $source): Collection; + + /** + * Returns true if this crawler handles the given type string. + */ + public function supports(string $type): bool; +} diff --git a/app/Services/Competitor/Crawlers/RssCrawler.php b/app/Services/Competitor/Crawlers/RssCrawler.php new file mode 100644 index 0000000..da7516f --- /dev/null +++ b/app/Services/Competitor/Crawlers/RssCrawler.php @@ -0,0 +1,178 @@ + + */ + public function crawl(CompetitorSource $source): Collection + { + $feedUrl = $source->feed_url ?? $source->url; + + $response = Http::timeout(30)->get($feedUrl); + + if (! $response->successful()) { + throw new \RuntimeException("Failed to fetch RSS feed from {$feedUrl}: HTTP {$response->status()}"); + } + + return $this->parseXml($source, $response->body()); + } + + /** + * @return Collection + */ + public function parseXml(CompetitorSource $source, string $xml): Collection + { + $items = collect(); + + libxml_use_internal_errors(true); + $document = simplexml_load_string($xml); + + if ($document === false) { + $errors = libxml_get_errors(); + libxml_clear_errors(); + throw new \RuntimeException('Failed to parse RSS/Atom XML: '.($errors[0]->message ?? 'unknown error')); + } + + // Detect Atom vs RSS + $namespaces = $document->getNamespaces(true); + $isAtom = isset($namespaces['']) && str_contains((string) $document->getName(), 'feed') + || $document->getName() === 'feed'; + + if ($isAtom) { + $items = $this->parseAtom($source, $document); + } else { + $items = $this->parseRss($source, $document); + } + + return $items; + } + + /** + * @return Collection + */ + private function parseRss(CompetitorSource $source, \SimpleXMLElement $document): Collection + { + $items = collect(); + $channel = $document->channel ?? $document; + $contentNs = $document->getNamespaces(true)['content'] ?? null; + + foreach ($channel->item as $entry) { + try { + $url = (string) ($entry->link ?? ''); + $title = (string) ($entry->title ?? ''); + $description = (string) ($entry->description ?? ''); + + // Try content:encoded for full body + $body = $description; + if ($contentNs) { + $content = $entry->children($contentNs); + if (isset($content->encoded)) { + $body = (string) $content->encoded; + } + } + + $pubDate = (string) ($entry->pubDate ?? ''); + $publishedAt = $pubDate ? \Carbon\Carbon::parse($pubDate) : null; + + if (empty($url)) { + continue; + } + + $item = new CompetitorContentItem([ + 'source_id' => $source->id, + 'external_url' => $url, + 'title' => $title ?: null, + 'excerpt' => $this->extractExcerpt($description), + 'body' => strip_tags($body) ?: null, + 'published_at' => $publishedAt, + 'crawled_at' => now(), + 'content_hash' => md5($url.$title.$body), + 'metadata' => ['source' => 'rss'], + ]); + + $items->push($item); + } catch (\Throwable $e) { + Log::warning('RssCrawler: failed to parse item', ['error' => $e->getMessage()]); + } + } + + return $items; + } + + /** + * @return Collection + */ + private function parseAtom(CompetitorSource $source, \SimpleXMLElement $document): Collection + { + $items = collect(); + + foreach ($document->entry as $entry) { + try { + $url = ''; + foreach ($entry->link as $link) { + $rel = (string) ($link['rel'] ?? 'alternate'); + if ($rel === 'alternate' || $rel === '') { + $url = (string) ($link['href'] ?? ''); + break; + } + } + + if (empty($url)) { + continue; + } + + $title = (string) ($entry->title ?? ''); + $summary = (string) ($entry->summary ?? ''); + $content = (string) ($entry->content ?? $summary); + $published = (string) ($entry->published ?? $entry->updated ?? ''); + $publishedAt = $published ? \Carbon\Carbon::parse($published) : null; + + $item = new CompetitorContentItem([ + 'source_id' => $source->id, + 'external_url' => $url, + 'title' => $title ?: null, + 'excerpt' => $this->extractExcerpt($summary), + 'body' => strip_tags($content) ?: null, + 'published_at' => $publishedAt, + 'crawled_at' => now(), + 'content_hash' => md5($url.$title.$content), + 'metadata' => ['source' => 'atom'], + ]); + + $items->push($item); + } catch (\Throwable $e) { + Log::warning('RssCrawler: failed to parse Atom entry', ['error' => $e->getMessage()]); + } + } + + return $items; + } + + private function extractExcerpt(string $html, int $maxLength = 300): ?string + { + $text = strip_tags($html); + $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); + + if (empty($text)) { + return null; + } + + return mb_strlen($text) > $maxLength + ? mb_substr($text, 0, $maxLength).'...' + : $text; + } +} diff --git a/app/Services/Competitor/Crawlers/ScrapeCrawler.php b/app/Services/Competitor/Crawlers/ScrapeCrawler.php new file mode 100644 index 0000000..961f647 --- /dev/null +++ b/app/Services/Competitor/Crawlers/ScrapeCrawler.php @@ -0,0 +1,157 @@ + + */ + public function crawl(CompetitorSource $source): Collection + { + $config = $source->config ?? []; + $urls = $config['urls'] ?? [$source->url]; + $selectors = $config['selectors'] ?? []; + + $items = collect(); + + foreach ($urls as $url) { + try { + $response = Http::timeout(30)->get($url); + + if (! $response->successful()) { + continue; + } + + $scraped = $this->scrape($source, $url, $response->body(), $selectors); + $items = $items->merge($scraped); + } catch (\Throwable $e) { + Log::warning('ScrapeCrawler: failed to scrape page', ['url' => $url, 'error' => $e->getMessage()]); + } + } + + return $items; + } + + /** + * Scrape a single page. + * + * @param array $selectors + * @return Collection + */ + public function scrape(CompetitorSource $source, string $pageUrl, string $html, array $selectors): Collection + { + $items = collect(); + + libxml_use_internal_errors(true); + $document = new \DOMDocument; + $document->loadHTML(''.$html, LIBXML_NOERROR); + libxml_clear_errors(); + + $xpath = new \DOMXPath($document); + + $itemSelector = $selectors['items'] ?? '//article'; + $nodes = $xpath->query($itemSelector); + + if ($nodes === false || $nodes->count() === 0) { + // No item blocks found — treat whole page as single item + $items->push($this->buildItem($source, $pageUrl, $html, $selectors, $xpath, null)); + + return $items; + } + + foreach ($nodes as $node) { + try { + $items->push($this->buildItem($source, $pageUrl, $html, $selectors, $xpath, $node)); + } catch (\Throwable $e) { + Log::warning('ScrapeCrawler: failed to parse node', ['error' => $e->getMessage()]); + } + } + + return $items; + } + + /** + * @param array $selectors + */ + private function buildItem( + CompetitorSource $source, + string $pageUrl, + string $html, + array $selectors, + \DOMXPath $xpath, + ?\DOMNode $context + ): CompetitorContentItem { + $get = function (string $key, string $default) use ($selectors, $xpath, $context): ?string { + $selector = $selectors[$key] ?? null; + if (! $selector) { + return null; + } + + $nodes = $context + ? $xpath->query($selector, $context) + : $xpath->query($selector); + + if ($nodes === false || $nodes->count() === 0) { + return null; + } + + $node = $nodes->item(0); + + return $node ? trim($node->textContent ?? $node->nodeValue ?? '') : null; + }; + + $url = $get('url', '') ?? $pageUrl; + // Make absolute if relative + if ($url && ! str_starts_with($url, 'http')) { + $parsed = parse_url($pageUrl); + $base = ($parsed['scheme'] ?? 'https').'://'.($parsed['host'] ?? ''); + $url = $base.'/'.ltrim($url, '/'); + } + + $title = $get('title', ''); + $excerpt = $get('excerpt', ''); + $body = $get('body', '') ?? ($context ? strip_tags($context->textContent ?? '') : null); + $dateStr = $get('date', ''); + $publishedAt = $dateStr ? \Carbon\Carbon::parse($dateStr) : null; + + return new CompetitorContentItem([ + 'source_id' => $source->id, + 'external_url' => $url ?: $pageUrl, + 'title' => $title ?: null, + 'excerpt' => $excerpt ?: null, + 'body' => $body ?: null, + 'published_at' => $publishedAt, + 'crawled_at' => now(), + 'content_hash' => md5(($url ?: $pageUrl).($title ?? '').($body ?? '')), + 'metadata' => ['source' => 'scrape'], + ]); + } +} diff --git a/app/Services/Competitor/Crawlers/SitemapCrawler.php b/app/Services/Competitor/Crawlers/SitemapCrawler.php new file mode 100644 index 0000000..9c1d4c5 --- /dev/null +++ b/app/Services/Competitor/Crawlers/SitemapCrawler.php @@ -0,0 +1,159 @@ +maxPages = $maxPages; + } + + public function supports(string $type): bool + { + return $type === 'sitemap'; + } + + /** + * @return Collection + */ + public function crawl(CompetitorSource $source): Collection + { + $sitemapUrl = $source->feed_url ?? ($source->url.'/sitemap.xml'); + $urls = $this->parseSitemap($sitemapUrl); + + return $this->fetchPages($source, $urls->take($this->maxPages)); + } + + /** + * @return Collection + */ + public function parseSitemap(string $url): Collection + { + $response = Http::timeout(30)->get($url); + + if (! $response->successful()) { + throw new \RuntimeException("Failed to fetch sitemap from {$url}: HTTP {$response->status()}"); + } + + return $this->extractUrls($response->body()); + } + + /** + * @return Collection + */ + public function extractUrls(string $xml): Collection + { + libxml_use_internal_errors(true); + $document = simplexml_load_string($xml); + + if ($document === false) { + libxml_clear_errors(); + + throw new \RuntimeException('Failed to parse sitemap XML'); + } + + $urls = collect(); + $name = $document->getName(); + + // Sitemap index — recurse into child sitemaps (one level deep) + if ($name === 'sitemapindex') { + foreach ($document->sitemap as $sitemap) { + $childUrl = (string) ($sitemap->loc ?? ''); + if ($childUrl) { + try { + $childResponse = Http::timeout(30)->get($childUrl); + if ($childResponse->successful()) { + $urls = $urls->merge($this->extractUrls($childResponse->body())); + } + } catch (\Throwable $e) { + Log::warning('SitemapCrawler: failed to fetch child sitemap', ['url' => $childUrl, 'error' => $e->getMessage()]); + } + } + } + } else { + // Regular sitemap + foreach ($document->url as $urlEntry) { + $loc = (string) ($urlEntry->loc ?? ''); + if ($loc) { + $urls->push($loc); + } + } + } + + return $urls; + } + + /** + * @param Collection $urls + * @return Collection + */ + private function fetchPages(CompetitorSource $source, Collection $urls): Collection + { + $items = collect(); + + foreach ($urls as $url) { + try { + $response = Http::timeout(30)->get($url); + + if (! $response->successful()) { + continue; + } + + $html = $response->body(); + [$title, $excerpt, $body] = $this->extractContent($html); + + $item = new CompetitorContentItem([ + 'source_id' => $source->id, + 'external_url' => $url, + 'title' => $title, + 'excerpt' => $excerpt, + 'body' => $body, + 'published_at' => null, + 'crawled_at' => now(), + 'content_hash' => md5($url.$body), + 'metadata' => ['source' => 'sitemap'], + ]); + + $items->push($item); + } catch (\Throwable $e) { + Log::warning('SitemapCrawler: failed to fetch page', ['url' => $url, 'error' => $e->getMessage()]); + } + } + + return $items; + } + + /** + * Extract title, excerpt, and body from HTML. + * + * @return array{0: string|null, 1: string|null, 2: string|null} + */ + public function extractContent(string $html): array + { + // Extract title + $title = null; + if (preg_match('/]*>(.*?)<\/title>/is', $html, $matches)) { + $title = trim(strip_tags($matches[1])); + } + + // Strip scripts/styles + $cleaned = preg_replace('/<(script|style|nav|header|footer|aside)[^>]*>.*?<\/\1>/is', '', $html) ?? $html; + $text = strip_tags($cleaned); + $text = trim(preg_replace('/\s+/', ' ', $text) ?? $text); + + $excerpt = mb_strlen($text) > 300 ? mb_substr($text, 0, 300).'...' : ($text ?: null); + $body = $text ?: null; + + return [$title ?: null, $excerpt, $body]; + } +} diff --git a/app/Services/Competitor/DifferentiationAnalysisService.php b/app/Services/Competitor/DifferentiationAnalysisService.php new file mode 100644 index 0000000..080f031 --- /dev/null +++ b/app/Services/Competitor/DifferentiationAnalysisService.php @@ -0,0 +1,231 @@ +fingerprintService->fingerprint($content); + $spaceId = $content->space_id; + $results = collect(); + + foreach ($similarCompetitorContent as $entry) { + $competitorItem = $entry['item']; + $competitorFingerprint = $entry['fingerprint']; + + try { + $similarityScore = $this->calculator->calculateSimilarity($ourFingerprint, $competitorFingerprint); + $differentiationScore = round(max(0.0, 1.0 - $similarityScore), 6); + $llmResult = $this->generateDifferentiationInsights($content, $competitorItem); + + $contentId = $content instanceof Content ? $content->id : null; + $briefId = $content instanceof ContentBrief ? $content->id : null; + + $analysis = DifferentiationAnalysis::updateOrCreate( + [ + 'space_id' => $spaceId, + 'content_id' => $contentId, + 'brief_id' => $briefId, + 'competitor_content_id' => $competitorItem->id, + ], + [ + 'similarity_score' => $similarityScore, + 'differentiation_score' => $differentiationScore, + 'angles' => $llmResult->angles, + 'gaps' => $llmResult->gaps, + 'recommendations' => $llmResult->recommendations, + 'analyzed_at' => now(), + ] + ); + + $results->push($analysis); + } catch (\Throwable $e) { + Log::warning('DifferentiationAnalysisService: failed to analyse competitor item', [ + 'competitor_content_id' => $competitorItem->id, + 'error' => $e->getMessage(), + ]); + } + } + + return $results; + } + + public function enrichBrief(ContentBrief $brief, SimilarContentFinder $finder): ContentBrief + { + try { + $fingerprint = $this->fingerprintService->fingerprint($brief); + $similar = $finder->findSimilar($fingerprint, threshold: 0.25, limit: 5); + + if ($similar->isEmpty()) { + return $brief; + } + + $analyses = $this->analyze($brief, $similar); + + if ($analyses->isEmpty()) { + return $brief; + } + + $allAngles = $analyses->flatMap(fn (DifferentiationAnalysis $a) => $a->angles ?? [])->unique()->values()->all(); + $allGaps = $analyses->flatMap(fn (DifferentiationAnalysis $a) => $a->gaps ?? [])->unique()->values()->all(); + $allRecommendations = $analyses->flatMap(fn (DifferentiationAnalysis $a) => $a->recommendations ?? [])->unique()->values()->all(); + + $avgDifferentiation = round($analyses->avg('differentiation_score'), 4); + $avgSimilarity = round($analyses->avg('similarity_score'), 4); + + $existingRequirements = $brief->requirements ?? []; + $brief->requirements = array_merge($existingRequirements, [ + 'competitor_differentiation' => [ + 'competitor_count' => $similar->count(), + 'avg_similarity_score' => $avgSimilarity, + 'avg_differentiation_score' => $avgDifferentiation, + 'unique_angles' => array_slice($allAngles, 0, 5), + 'content_gaps' => array_slice($allGaps, 0, 5), + 'differentiation_recommendations' => array_slice($allRecommendations, 0, 5), + 'enriched_at' => now()->toIso8601String(), + ], + ]); + + $brief->save(); + + Log::info('DifferentiationAnalysisService: brief enriched', [ + 'brief_id' => $brief->id, + 'competitor_count' => $similar->count(), + 'avg_differentiation' => $avgDifferentiation, + ]); + } catch (\Throwable $e) { + Log::warning('DifferentiationAnalysisService: brief enrichment failed', [ + 'brief_id' => $brief->id, + 'error' => $e->getMessage(), + ]); + } + + return $brief; + } + + private function generateDifferentiationInsights( + Content|ContentBrief $ourContent, + \App\Models\CompetitorContentItem $competitorItem + ): DifferentiationResult { + $ourSummary = $this->buildOurContentSummary($ourContent); + $competitorSummary = $this->buildCompetitorSummary($competitorItem); + $personaContext = $this->buildPersonaContext($ourContent); + + $systemPrompt = 'You are a content strategy expert. Analyse how a piece of content differs from competitor content and identify differentiation opportunities. Respond ONLY with valid JSON: {"angles":["..."],"gaps":["..."],"recommendations":["..."]}. angles = unique perspectives our content could take (2-4 items). gaps = topics/questions competitors missed (2-4 items). recommendations = specific actionable steps (2-4 items).'; + + $userPrompt = "## Our Content\n{$ourSummary}\n\n## Competitor Content\n{$competitorSummary}{$personaContext}\n\nAnalyse differentiation opportunities."; + + $response = $this->llm->complete([ + 'model' => self::DEFAULT_MODEL, + 'system' => $systemPrompt, + 'messages' => [['role' => 'user', 'content' => $userPrompt]], + 'max_tokens' => self::MAX_TOKENS, + 'temperature' => self::TEMPERATURE, + '_purpose' => 'differentiation_analysis', + ]); + + return $this->parseLLMResponse($response->content); + } + + private function buildOurContentSummary(Content|ContentBrief $content): string + { + if ($content instanceof ContentBrief) { + $keywords = implode(', ', $content->target_keywords ?? []); + + return implode("\n", array_filter([ + "Title: {$content->title}", + $content->description ? "Description: {$content->description}" : null, + $keywords ? "Target keywords: {$keywords}" : null, + "Locale: {$content->target_locale}", + ])); + } + + $version = $content->currentVersion; + $title = ($version !== null) ? $version->title : $content->slug; + $excerpt = ($version !== null) ? ($version->excerpt ?? '') : ''; + $body = substr(strip_tags(($version !== null) ? $version->body : ''), 0, 500); + + return implode("\n", array_filter([ + "Title: {$title}", + $excerpt ? "Excerpt: {$excerpt}" : null, + $body ? "Body preview: {$body}" : null, + ])); + } + + private function buildCompetitorSummary(\App\Models\CompetitorContentItem $item): string + { + $body = substr(strip_tags($item->body ?? ''), 0, 500); + + return implode("\n", array_filter([ + $item->title ? "Title: {$item->title}" : null, + $item->excerpt ? "Excerpt: {$item->excerpt}" : null, + $body ? "Body preview: {$body}" : null, + $item->external_url ? "URL: {$item->external_url}" : null, + ])); + } + + private function buildPersonaContext(Content|ContentBrief $content): string + { + if (! $content instanceof ContentBrief || $content->persona_id === null) { + return ''; + } + + $persona = $content->persona; + + if ($persona === null) { + return ''; + } + + return " + +## Persona Context +Name: {$persona->name} +"; + } + + private function parseLLMResponse(string $raw): DifferentiationResult + { + $json = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', trim($raw)); + $decoded = json_decode($json ?? '', true); + + if (! is_array($decoded)) { + Log::warning('DifferentiationAnalysisService: LLM returned invalid JSON', ['raw' => substr($raw, 0, 200)]); + + return new DifferentiationResult( + similarityScore: 0.0, + differentiationScore: 1.0, + angles: [], + gaps: [], + recommendations: [], + ); + } + + return new DifferentiationResult( + similarityScore: 0.0, + differentiationScore: 1.0, + angles: array_values(array_filter((array) ($decoded['angles'] ?? []), 'is_string')), + gaps: array_values(array_filter((array) ($decoded['gaps'] ?? []), 'is_string')), + recommendations: array_values(array_filter((array) ($decoded['recommendations'] ?? []), 'is_string')), + ); + } +} diff --git a/app/Services/Competitor/DifferentiationResult.php b/app/Services/Competitor/DifferentiationResult.php new file mode 100644 index 0000000..7d17fbd --- /dev/null +++ b/app/Services/Competitor/DifferentiationResult.php @@ -0,0 +1,31 @@ + $angles Unique angles our content could take + * @param array $gaps Topics/perspectives competitors missed + * @param array $recommendations Specific action items for differentiation + */ + public function __construct( + public float $similarityScore, + public float $differentiationScore, + public array $angles, + public array $gaps, + public array $recommendations, + ) {} + + /** @return array */ + public function toArray(): array + { + return [ + 'similarity_score' => $this->similarityScore, + 'differentiation_score' => $this->differentiationScore, + 'angles' => $this->angles, + 'gaps' => $this->gaps, + 'recommendations' => $this->recommendations, + ]; + } +} diff --git a/app/Services/Competitor/RetentionPolicyService.php b/app/Services/Competitor/RetentionPolicyService.php new file mode 100644 index 0000000..8d386b3 --- /dev/null +++ b/app/Services/Competitor/RetentionPolicyService.php @@ -0,0 +1,72 @@ +pruneCompetitorContent(); + $analysesPruned = $this->pruneDifferentiationAnalyses(); + $alertEventsPruned = $this->pruneAlertEvents(); + + Log::info('RetentionPolicyService: pruning complete', [ + 'content_pruned' => $contentPruned, + 'analyses_pruned' => $analysesPruned, + 'alert_events_pruned' => $alertEventsPruned, + ]); + + return [ + 'content_pruned' => $contentPruned, + 'analyses_pruned' => $analysesPruned, + 'alert_events_pruned' => $alertEventsPruned, + ]; + } + + private function pruneCompetitorContent(): int + { + $days = (int) config('numen.competitor_analysis.content_retention_days', self::DEFAULT_CONTENT_RETENTION_DAYS); + $cutoff = Carbon::now()->subDays($days); + + return CompetitorContentItem::where('crawled_at', '<', $cutoff)->delete(); + } + + private function pruneDifferentiationAnalyses(): int + { + $days = (int) config('numen.competitor_analysis.analysis_retention_days', self::DEFAULT_ANALYSIS_RETENTION_DAYS); + $cutoff = Carbon::now()->subDays($days); + + return DifferentiationAnalysis::where('analyzed_at', '<', $cutoff)->delete(); + } + + private function pruneAlertEvents(): int + { + $days = (int) config('numen.competitor_analysis.alert_event_retention_days', self::DEFAULT_ALERT_EVENT_RETENTION_DAYS); + $cutoff = Carbon::now()->subDays($days); + + return CompetitorAlertEvent::where('notified_at', '<', $cutoff)->delete(); + } +} diff --git a/app/Services/Competitor/SimilarContentFinder.php b/app/Services/Competitor/SimilarContentFinder.php new file mode 100644 index 0000000..c582971 --- /dev/null +++ b/app/Services/Competitor/SimilarContentFinder.php @@ -0,0 +1,64 @@ + + */ + public function findSimilar( + ContentFingerprint $fingerprint, + float $threshold = 0.3, + int $limit = 10 + ): Collection { + $threshold = max(0.0, min(1.0, $threshold)); + $limit = max(1, $limit); + + /** @var array $scored */ + $scored = []; + + ContentFingerprint::query() + ->where('fingerprintable_type', CompetitorContentItem::class) + ->where('id', '!=', $fingerprint->id) + ->with('fingerprintable') + ->chunkById(self::BATCH_SIZE, function (Collection $chunk) use ($fingerprint, $threshold, &$scored): void { + foreach ($chunk as $candidate) { + /** @var ContentFingerprint $candidate */ + $item = $candidate->fingerprintable; + + if (! $item instanceof CompetitorContentItem) { + continue; + } + + $score = $this->calculator->calculateSimilarity($fingerprint, $candidate); + + if ($score >= $threshold) { + $scored[] = [ + 'item' => $item, + 'score' => $score, + 'fingerprint' => $candidate, + ]; + } + } + }); + + usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']); + + return collect(array_slice($scored, 0, $limit)); + } +} diff --git a/app/Services/Competitor/SimilarityCalculator.php b/app/Services/Competitor/SimilarityCalculator.php new file mode 100644 index 0000000..e1b2902 --- /dev/null +++ b/app/Services/Competitor/SimilarityCalculator.php @@ -0,0 +1,110 @@ +jaccardSimilarity($a, $b); + $cosineScore = $this->cosineSimilarity($a, $b); + + return round( + (self::JACCARD_WEIGHT * $jaccardScore) + (self::COSINE_WEIGHT * $cosineScore), + 6 + ); + } + + public function jaccardSimilarity(ContentFingerprint $a, ContentFingerprint $b): float + { + $setA = $this->buildTermSet($a); + $setB = $this->buildTermSet($b); + + if (empty($setA) && empty($setB)) { + return 0.0; + } + + $intersection = count(array_intersect($setA, $setB)); + $union = count(array_unique(array_merge($setA, $setB))); + + if ($union === 0) { + return 0.0; + } + + return $intersection / $union; + } + + public function cosineSimilarity(ContentFingerprint $a, ContentFingerprint $b): float + { + $vecA = $this->buildKeywordVector($a); + $vecB = $this->buildKeywordVector($b); + + if (empty($vecA) || empty($vecB)) { + return 0.0; + } + + $allTerms = array_unique(array_merge(array_keys($vecA), array_keys($vecB))); + + $dotProduct = 0.0; + $normA = 0.0; + $normB = 0.0; + + foreach ($allTerms as $term) { + $scoreA = $vecA[$term] ?? 0.0; + $scoreB = $vecB[$term] ?? 0.0; + + $dotProduct += $scoreA * $scoreB; + $normA += $scoreA * $scoreA; + $normB += $scoreB * $scoreB; + } + + $denominator = sqrt($normA) * sqrt($normB); + + if ($denominator < 1e-10) { + return 0.0; + } + + return max(0.0, min(1.0, $dotProduct / $denominator)); + } + + /** @return array */ + private function buildTermSet(ContentFingerprint $fp): array + { + $topics = array_map('strtolower', array_map('trim', $fp->topics ?? [])); + $entities = array_map('strtolower', array_map('trim', $fp->entities ?? [])); + + return array_values(array_unique(array_merge($topics, $entities))); + } + + /** @return array */ + private function buildKeywordVector(ContentFingerprint $fp): array + { + $raw = $fp->keywords ?? []; + + $vector = []; + foreach ($raw as $term => $score) { + // Handle both formats: + // 1. Associative: ['machine learning' => 0.5, ...] (term => score) + // 2. Numeric-indexed: ['machine learning', 'beginner', ...] (plain list) + if (is_int($term)) { + $key = strtolower(trim((string) $score)); + $val = 1.0; + } else { + $key = strtolower(trim((string) $term)); + $val = (float) $score; + } + + if ($key !== '') { + $vector[$key] = $val; + } + } + + return $vector; + } +} diff --git a/app/Services/PipelineTemplates/PersonaResolver.php b/app/Services/PipelineTemplates/PersonaResolver.php new file mode 100644 index 0000000..bad925e --- /dev/null +++ b/app/Services/PipelineTemplates/PersonaResolver.php @@ -0,0 +1,56 @@ +> $personaDefinitions + * @return array + */ + public function resolvePersonas(array $personaDefinitions, Space $space): array + { + $resolved = []; + foreach ($personaDefinitions as $def) { + $ref = $def['persona_ref'] ?? ($def['name'] ?? ''); + $name = $def['name'] ?? $ref; + if (empty($ref)) { + continue; + } + $persona = $this->findExistingPersona($space, $name) + ?? $this->createPersona($space, $def, $name); + $resolved[$ref] = $persona; + } + + return $resolved; + } + + private function findExistingPersona(Space $space, string $name): ?Persona + { + /** @var Persona|null */ + return $space->personas()->where('name', $name)->first(); + } + + /** @param array $def */ + private function createPersona(Space $space, array $def, string $name): Persona + { + return Persona::create([ + 'space_id' => $space->id, + 'name' => $name, + 'role' => $def['role'] ?? 'creator', + 'system_prompt' => $def['system_prompt'] ?? '', + 'capabilities' => $def['capabilities'] ?? ['content_generation'], + 'model_config' => $def['model_config'] ?? [ + 'model' => config('numen.models.generation', 'claude-sonnet-4-6'), + 'temperature' => 0.7, + 'max_tokens' => 4096, + ], + 'voice_guidelines' => $def['voice_guidelines'] ?? null, + 'constraints' => $def['constraints'] ?? null, + 'is_active' => true, + ]); + } +} diff --git a/app/Services/PipelineTemplates/PipelineTemplateInstallService.php b/app/Services/PipelineTemplates/PipelineTemplateInstallService.php new file mode 100644 index 0000000..7aa578a --- /dev/null +++ b/app/Services/PipelineTemplates/PipelineTemplateInstallService.php @@ -0,0 +1,104 @@ + $variableValues + * @param array $configOverrides + */ + public function install( + PipelineTemplateVersion $version, + Space $space, + array $variableValues = [], + array $configOverrides = [], + ): PipelineTemplateInstall { + return DB::transaction(function () use ($version, $space, $variableValues, $configOverrides): PipelineTemplateInstall { + $definition = $this->variableResolver->resolve($version->definition, $variableValues); + $personaDefs = $definition['personas'] ?? []; + $personas = $this->personaResolver->resolvePersonas($personaDefs, $space); + $stages = $this->buildStages($definition['stages'] ?? [], $personas); + $pipelineName = $definition['settings']['name'] + ?? $version->template->name + ?? 'Imported Pipeline'; + + $pipeline = ContentPipeline::create([ + 'space_id' => $space->id, + 'name' => $pipelineName, + 'stages' => $stages, + 'trigger_config' => $definition['settings']['trigger_config'] ?? [], + 'is_active' => true, + ]); + + return PipelineTemplateInstall::create([ + 'template_id' => $version->template_id, + 'version_id' => $version->id, + 'space_id' => $space->id, + 'pipeline_id' => $pipeline->id, + 'installed_at' => now(), + 'config_overrides' => empty($configOverrides) ? null : $configOverrides, + ]); + }); + } + + public function uninstall(PipelineTemplateInstall $install): void + { + DB::transaction(function () use ($install): void { + if ($install->pipeline_id !== null) { + /** @var ContentPipeline|null $pipeline */ + $pipeline = ContentPipeline::find($install->pipeline_id); + $pipeline?->delete(); + } + $install->delete(); + }); + } + + public function update( + PipelineTemplateInstall $install, + PipelineTemplateVersion $newVersion, + ): PipelineTemplateInstall { + return DB::transaction(function () use ($install, $newVersion): PipelineTemplateInstall { + $configOverrides = $install->config_overrides ?? []; + + if ($install->pipeline_id !== null) { + /** @var ContentPipeline|null $pipeline */ + $pipeline = ContentPipeline::find($install->pipeline_id); + $pipeline?->delete(); + } + + $newInstall = $this->install($newVersion, $install->space, [], $configOverrides); + $install->delete(); + + return $newInstall; + }); + } + + /** + * @param array> $stages + * @param array $personas + * @return array> + */ + private function buildStages(array $stages, array $personas): array + { + return array_map(function (array $stage) use ($personas): array { + $ref = $stage['persona_ref'] ?? null; + if ($ref !== null && isset($personas[$ref])) { + $stage['persona_id'] = $personas[$ref]->id; + } + + return $stage; + }, $stages); + } +} diff --git a/app/Services/PipelineTemplates/PipelineTemplateService.php b/app/Services/PipelineTemplates/PipelineTemplateService.php new file mode 100644 index 0000000..f9a247d --- /dev/null +++ b/app/Services/PipelineTemplates/PipelineTemplateService.php @@ -0,0 +1,220 @@ + $data */ + public function create(Space $space, array $data): PipelineTemplate + { + $slug = $data['slug'] ?? Str::slug($data['name'] ?? ''); + + $template = (new PipelineTemplate)->forceFill([ + 'space_id' => $space->id, + 'name' => $data['name'], + 'slug' => $this->uniqueSlug($slug), + 'description' => $data['description'] ?? null, + 'category' => $data['category'] ?? null, + 'icon' => $data['icon'] ?? null, + 'schema_version' => $data['schema_version'] ?? '1.0', + 'is_published' => false, + 'author_name' => $data['author_name'] ?? null, + 'author_url' => $data['author_url'] ?? null, + ]); + $template->save(); + + return $template; + } + + /** @param array $data */ + public function update(PipelineTemplate $template, array $data): PipelineTemplate + { + $fillable = ['name', 'description', 'category', 'icon', 'author_name', 'author_url']; + $updates = array_intersect_key($data, array_flip($fillable)); + + if (isset($data['slug'])) { + $updates['slug'] = $data['slug'] === $template->slug + ? $template->slug + : $this->uniqueSlug($data['slug'], $template->id); + } + + $template->update($updates); + + return $template->refresh(); + } + + public function delete(PipelineTemplate $template): void + { + $template->delete(); + } + + public function publish(PipelineTemplate $template): void + { + $template->forceFill(['is_published' => true, 'space_id' => null])->save(); + } + + public function unpublish(PipelineTemplate $template): void + { + $template->forceFill(['is_published' => false])->save(); + } + + // ------------------------------------------------------------------------- + // Version management + // ------------------------------------------------------------------------- + + /** @param array $definition */ + public function createVersion( + PipelineTemplate $template, + array $definition, + string $version, + ?string $changelog = null, + ): PipelineTemplateVersion { + $result = $this->validator->validate($definition); + + if (! $result->isValid()) { + throw new InvalidArgumentException( + 'Template definition is invalid: '.implode('; ', $result->errors()), + ); + } + + return DB::transaction(function () use ($template, $definition, $version, $changelog): PipelineTemplateVersion { + $template->versions()->where('is_latest', true)->update(['is_latest' => false]); + + return PipelineTemplateVersion::create([ + 'template_id' => $template->id, + 'version' => $version, + 'definition' => $definition, + 'changelog' => $changelog, + 'is_latest' => true, + 'published_at' => now(), + ]); + }); + } + + // ------------------------------------------------------------------------- + // Import / Export + // ------------------------------------------------------------------------- + + /** @return array */ + public function export(PipelineTemplate $template): array + { + /** @var PipelineTemplateVersion|null $latestVersion */ + $latestVersion = $template->versions()->where('is_latest', true)->first(); + + return [ + 'numen_export' => '1.0', + 'exported_at' => now()->toIso8601String(), + 'template' => [ + 'name' => $template->name, + 'slug' => $template->slug, + 'description' => $template->description, + 'category' => $template->category, + 'icon' => $template->icon, + 'schema_version' => $template->schema_version, + 'author_name' => $template->author_name, + 'author_url' => $template->author_url, + ], + 'version' => $latestVersion ? [ + 'version' => $latestVersion->version, + 'changelog' => $latestVersion->changelog, + 'definition' => $latestVersion->definition, + ] : null, + ]; + } + + /** @param array $data */ + public function import(Space $space, array $data): PipelineTemplate + { + if (! isset($data['template'])) { + throw new InvalidArgumentException('Import data is missing the "template" key.'); + } + + return DB::transaction(function () use ($space, $data): PipelineTemplate { + $templateData = $data['template']; + $template = $this->create($space, $templateData); + + if (isset($data['version']) && is_array($data['version'])) { + $v = $data['version']; + $this->createVersion( + $template, + $v['definition'] ?? [], + $v['version'] ?? '1.0.0', + $v['changelog'] ?? null, + ); + } + + return $template->refresh(); + }); + } + + public function exportToFile(PipelineTemplate $template): string + { + $payload = $this->export($template); + $filename = 'pipeline-templates/'.$template->slug.'-'.now()->format('Ymd_His').'.json'; + + Storage::put($filename, (string) json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + return Storage::path($filename); + } + + public function importFromFile(Space $space, string $path): PipelineTemplate + { + if (! file_exists($path)) { + throw new RuntimeException("Import file not found: {$path}"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read file: {$path}"); + } + + /** @var array $data */ + $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + + return $this->import($space, $data); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function uniqueSlug(string $base, ?string $excludeId = null): string + { + $slug = $base !== '' ? $base : 'template'; + $count = 0; + $candidate = $slug; + + do { + $query = PipelineTemplate::withTrashed()->where('slug', $candidate); + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + $exists = $query->exists(); + + if ($exists) { + $count++; + $candidate = $slug.'-'.$count; + } + } while ($exists); + + return $candidate; + } +} diff --git a/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php b/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php new file mode 100644 index 0000000..708ff4d --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php @@ -0,0 +1,153 @@ +> */ + private array $stages = []; + + /** @var array> */ + private array $personas = []; + + /** @var array */ + private array $settings = []; + + /** @var array> */ + private array $variables = []; + + public function __construct(private readonly TemplateSchemaValidator $validator) {} + + public function version(string $version): static + { + $this->version = $version; + + return $this; + } + + /** @param array $config */ + public function addStage( + string $type, + string $name, + array $config = [], + ?string $personaRef = null, + ?string $provider = null, + bool $enabled = true, + ): static { + $stage = ['type' => $type, 'name' => $name, 'config' => $config, 'enabled' => $enabled]; + if ($personaRef !== null) { + $stage['persona_ref'] = $personaRef; + } + if ($provider !== null) { + $stage['provider'] = $provider; + } + $this->stages[] = $stage; + + return $this; + } + + public function addPersona( + string $ref, + string $name, + string $systemPrompt, + string $llmProvider, + string $llmModel, + string $voiceGuidelines = '', + ): static { + $persona = [ + 'ref' => $ref, + 'name' => $name, + 'system_prompt' => $systemPrompt, + 'llm_provider' => $llmProvider, + 'llm_model' => $llmModel, + ]; + if ($voiceGuidelines !== '') { + $persona['voice_guidelines'] = $voiceGuidelines; + } + $this->personas[] = $persona; + + return $this; + } + + /** + * @param array $options Required for select/multiselect types. + */ + public function addVariable( + string $key, + string $type, + string $label, + mixed $default = null, + bool $required = false, + array $options = [], + ): static { + $variable = ['key' => $key, 'type' => $type, 'label' => $label, 'required' => $required]; + if ($default !== null) { + $variable['default'] = $default; + } + if ($options !== []) { + $variable['options'] = $options; + } + $this->variables[] = $variable; + + return $this; + } + + /** @param array $settings */ + public function setSettings(array $settings): static + { + $this->settings = array_merge($this->settings, $settings); + + return $this; + } + + /** + * Build and validate the definition. Throws on invalid schema. + * + * @return array + * + * @throws InvalidArgumentException + */ + public function build(): array + { + $definition = [ + 'version' => $this->version, + 'stages' => $this->stages, + 'personas' => $this->personas, + 'settings' => $this->settings, + ]; + if ($this->variables !== []) { + $definition['variables'] = $this->variables; + } + $result = $this->validator->validate($definition); + if (! $result->isValid()) { + throw new InvalidArgumentException('Invalid template definition: '.implode('; ', $result->errors())); + } + + return $definition; + } + + /** + * Build without throwing; returns definition + ValidationResult. + * + * @return array{definition: array, result: ValidationResult} + */ + public function buildWithValidation(): array + { + $definition = [ + 'version' => $this->version, + 'stages' => $this->stages, + 'personas' => $this->personas, + 'settings' => $this->settings, + ]; + if ($this->variables !== []) { + $definition['variables'] = $this->variables; + } + + return ['definition' => $definition, 'result' => $this->validator->validate($definition)]; + } +} diff --git a/app/Services/PipelineTemplates/TemplateHookIntegrationService.php b/app/Services/PipelineTemplates/TemplateHookIntegrationService.php new file mode 100644 index 0000000..f0f43e1 --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateHookIntegrationService.php @@ -0,0 +1,118 @@ +registry->getAllPackTemplates() as $def) { + try { + $this->upsertPackTemplate($space, $def); + } catch (\Throwable $e) { + Log::warning('[TemplateHookIntegration] Failed to sync pack template.', [ + 'pack_id' => $def['_pack_id'] ?? 'unknown', + 'name' => $def['name'] ?? 'unknown', + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * Return all categories: built-in defaults merged with plugin-registered. + * + * @return array + */ + public function getAvailableCategories(): array + { + $builtin = [ + ['slug' => 'blog', 'label' => 'Blog', 'description' => null, 'icon' => null], + ['slug' => 'social_media', 'label' => 'Social Media', 'description' => null, 'icon' => null], + ['slug' => 'seo', 'label' => 'SEO', 'description' => null, 'icon' => null], + ['slug' => 'ecommerce', 'label' => 'E-Commerce', 'description' => null, 'icon' => null], + ['slug' => 'newsletter', 'label' => 'Newsletter', 'description' => null, 'icon' => null], + ['slug' => 'technical', 'label' => 'Technical', 'description' => null, 'icon' => null], + ['slug' => 'custom', 'label' => 'Custom', 'description' => null, 'icon' => null], + ]; + + return array_merge($builtin, $this->registry->getTemplateCategories()); + } + + /** + * Return all registered template packs. + * + * @return array>, author: string|null, url: string|null}> + */ + public function getTemplatePacks(): array + { + return $this->registry->getTemplatePacks(); + } + + /** @param array $def */ + private function upsertPackTemplate(Space $space, array $def): void + { + if (empty($def['name']) || empty($def['definition'])) { + return; + } + + // Validate definition before persisting + /** @var array $definition */ + $definition = $def['definition']; + if (! $this->validator->validate($definition)->isValid()) { + Log::warning('[TemplateHookIntegration] Invalid template definition from pack, skipping.', [ + 'name' => $def['name'], + ]); + + return; + } + + // Check if a template with this slug already exists for the space + $slug = $def['slug'] ?? \Illuminate\Support\Str::slug((string) $def['name']); + $existing = \App\Models\PipelineTemplate::where('space_id', $space->id) + ->where('slug', $slug) + ->first(); + + if ($existing) { + return; // Don't overwrite user customisations + } + + $template = $this->templateService->create($space, [ + 'name' => $def['name'], + 'slug' => $slug, + 'description' => $def['description'] ?? null, + 'category' => $def['category'] ?? 'custom', + 'icon' => $def['icon'] ?? null, + 'author_name' => $def['author_name'] ?? null, + 'author_url' => $def['author_url'] ?? null, + ]); + + $this->templateService->createVersion( + $template, + $definition, + (string) ($def['version'] ?? '1.0.0'), + 'Imported from plugin pack', + ); + } +} diff --git a/app/Services/PipelineTemplates/TemplateSchemaValidator.php b/app/Services/PipelineTemplates/TemplateSchemaValidator.php new file mode 100644 index 0000000..7a24392 --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateSchemaValidator.php @@ -0,0 +1,269 @@ + $definition */ + public function validate(array $definition): ValidationResult + { + $errors = []; + $warnings = []; + // Accept both 'version' and 'schema_version' field names + $schemaVersion = $definition['version'] ?? $definition['schema_version'] ?? null; + if ($schemaVersion === null) { + $errors[] = 'Missing required field: version'; + } elseif (! in_array($schemaVersion, self::SUPPORTED_VERSIONS, true)) { + $errors[] = "Unsupported schema version: \"{$schemaVersion}\""; + } + if (! isset($definition['stages'])) { + $errors[] = 'Missing required field: stages'; + } elseif (! is_array($definition['stages'])) { + $errors[] = 'Field "stages" must be an array'; + } elseif (empty($definition['stages'])) { + $errors[] = 'Field "stages" must contain at least one stage'; + } else { + $errors = array_merge($errors, $this->validateStages($definition['stages'])); + } + if (isset($definition['personas'])) { + if (! is_array($definition['personas'])) { + $errors[] = 'Field "personas" must be an array'; + } else { + [$personaErrors, $personaWarnings] = $this->validatePersonas($definition['personas']); + $errors = array_merge($errors, $personaErrors); + $warnings = array_merge($warnings, $personaWarnings); + } + } + if (isset($definition['settings'])) { + if (! is_array($definition['settings'])) { + $errors[] = 'Field "settings" must be an object/array'; + } else { + $errors = array_merge($errors, $this->validateSettings($definition['settings'])); + } + } + if (isset($definition['variables'])) { + if (! is_array($definition['variables'])) { + $errors[] = 'Field "variables" must be an array'; + } else { + $errors = array_merge($errors, $this->validateVariables($definition['variables'])); + } + } + if (empty($errors) && isset($definition['personas'])) { + $refs = $this->collectPersonaRefs($definition['personas']); + $errors = array_merge($errors, $this->validatePersonaRefs($definition['stages'] ?? [], $refs)); + } + + return empty($errors) ? ValidationResult::valid($warnings) : ValidationResult::invalid($errors, $warnings); + } + + /** + * @param array $stages + * @return array + */ + private function validateStages(array $stages): array + { + $errors = []; + $allowed = $this->getAllowedStageTypes(); + foreach ($stages as $i => $stage) { + $p = "stages[$i]"; + if (! is_array($stage)) { + $errors[] = "{$p}: Each stage must be an object"; + + continue; + } + if (! isset($stage['type']) || ! is_string($stage['type']) || $stage['type'] === '') { + $errors[] = "{$p}: Missing required field \"type\""; + } elseif (! in_array($stage['type'], $allowed, true)) { + $errors[] = "{$p}: Unknown stage type \"{$stage['type']}\". Allowed: ".implode(', ', $allowed); + } + if (! isset($stage['name']) || ! is_string($stage['name']) || $stage['name'] === '') { + $errors[] = "{$p}: Missing required field \"name\""; + } + if (isset($stage['config']) && ! is_array($stage['config'])) { + $errors[] = "{$p}: Field \"config\" must be an object/array"; + } + if (isset($stage['persona_ref']) && ! is_string($stage['persona_ref'])) { + $errors[] = "{$p}: Field \"persona_ref\" must be a string"; + } + if (isset($stage['provider']) && ! is_string($stage['provider'])) { + $errors[] = "{$p}: Field \"provider\" must be a string"; + } + if (isset($stage['enabled']) && ! is_bool($stage['enabled'])) { + $errors[] = "{$p}: Field \"enabled\" must be a boolean"; + } + } + + return $errors; + } + + /** + * @param array $personas + * @return array{0: array, 1: array} + */ + private function validatePersonas(array $personas): array + { + $errors = []; + $warnings = []; + $refs = []; + foreach ($personas as $i => $persona) { + $p = "personas[$i]"; + if (! is_array($persona)) { + $errors[] = "{$p}: Each persona must be an object"; + + continue; + } + foreach (['ref', 'name', 'system_prompt', 'llm_provider', 'llm_model'] as $field) { + if (! isset($persona[$field]) || ! is_string($persona[$field]) || $persona[$field] === '') { + $errors[] = "{$p}: Missing required field \"{$field}\""; + } + } + if (isset($persona['voice_guidelines']) && ! is_string($persona['voice_guidelines'])) { + $errors[] = "{$p}: Field \"voice_guidelines\" must be a string"; + } + if (isset($persona['ref'])) { + if (in_array($persona['ref'], $refs, true)) { + $errors[] = "{$p}: Duplicate persona ref \"{$persona['ref']}\""; + } else { + $refs[] = $persona['ref']; + } + } + } + + return [$errors, $warnings]; + } + + /** + * @param array $settings + * @return array + */ + private function validateSettings(array $settings): array + { + $errors = []; + if (isset($settings['auto_publish']) && ! is_bool($settings['auto_publish'])) { + $errors[] = 'settings.auto_publish must be a boolean'; + } + if (isset($settings['review_required']) && ! is_bool($settings['review_required'])) { + $errors[] = 'settings.review_required must be a boolean'; + } + if (isset($settings['max_retries']) && ! is_int($settings['max_retries'])) { + $errors[] = 'settings.max_retries must be an integer'; + } + if (isset($settings['timeout_seconds']) && ! is_int($settings['timeout_seconds'])) { + $errors[] = 'settings.timeout_seconds must be an integer'; + } + + return $errors; + } + + /** + * @param array $variables + * @return array + */ + private function validateVariables(array $variables): array + { + $errors = []; + $keys = []; + foreach ($variables as $i => $variable) { + $p = "variables[$i]"; + if (! is_array($variable)) { + $errors[] = "{$p}: Each variable must be an object"; + + continue; + } + if (! isset($variable['key']) || ! is_string($variable['key']) || $variable['key'] === '') { + $errors[] = "{$p}: Missing required field \"key\""; + } else { + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $variable['key'])) { + $errors[] = "{$p}: Field \"key\" must be a valid identifier"; + } + if (in_array($variable['key'], $keys, true)) { + $errors[] = "{$p}: Duplicate variable key \"{$variable['key']}\""; + } else { + $keys[] = $variable['key']; + } + } + if (! isset($variable['type']) || ! is_string($variable['type'])) { + $errors[] = "{$p}: Missing required field \"type\""; + } elseif (! in_array($variable['type'], self::VARIABLE_TYPES, true)) { + $errors[] = "{$p}: Invalid variable type \"{$variable['type']}\""; + } + if (! isset($variable['label']) || ! is_string($variable['label']) || $variable['label'] === '') { + $errors[] = "{$p}: Missing required field \"label\""; + } + if (isset($variable['required']) && ! is_bool($variable['required'])) { + $errors[] = "{$p}: Field \"required\" must be a boolean"; + } + if (isset($variable['type']) && in_array($variable['type'], ['select', 'multiselect'], true)) { + if (! isset($variable['options']) || ! is_array($variable['options']) || empty($variable['options'])) { + $errors[] = "{$p}: Variable of type \"{$variable['type']}\" must include a non-empty \"options\" array"; + } + } + } + + return $errors; + } + + /** + * @param array $stages + * @param array $personaRefs + * @return array + */ + private function validatePersonaRefs(array $stages, array $personaRefs): array + { + $errors = []; + foreach ($stages as $i => $stage) { + if (! is_array($stage)) { + continue; + } + if (isset($stage['persona_ref']) && is_string($stage['persona_ref'])) { + if (! in_array($stage['persona_ref'], $personaRefs, true)) { + $errors[] = "stages[$i]: persona_ref \"{$stage['persona_ref']}\" does not reference any defined persona"; + } + } + } + + return $errors; + } + + /** + * @param array $personas + * @return array + */ + private function collectPersonaRefs(array $personas): array + { + $refs = []; + foreach ($personas as $persona) { + if (is_array($persona) && isset($persona['ref']) && is_string($persona['ref'])) { + $refs[] = $persona['ref']; + } + } + + return $refs; + } + + /** @return array */ + private function getAllowedStageTypes(): array + { + return array_unique(array_merge( + self::CORE_STAGE_TYPES, + $this->hookRegistry->getRegisteredPipelineStageTypes(), + )); + } +} diff --git a/app/Services/PipelineTemplates/ValidationResult.php b/app/Services/PipelineTemplates/ValidationResult.php new file mode 100644 index 0000000..10cce2c --- /dev/null +++ b/app/Services/PipelineTemplates/ValidationResult.php @@ -0,0 +1,47 @@ + $errors + * @param array $warnings + */ + public function __construct( + private readonly array $errors = [], + private readonly array $warnings = [], + ) {} + + /** @param array $warnings */ + public static function valid(array $warnings = []): self + { + return new self([], $warnings); + } + + /** + * @param array $errors + * @param array $warnings + */ + public static function invalid(array $errors, array $warnings = []): self + { + return new self($errors, $warnings); + } + + public function isValid(): bool + { + return empty($this->errors); + } + + /** @return array */ + public function errors(): array + { + return $this->errors; + } + + /** @return array */ + public function warnings(): array + { + return $this->warnings; + } +} diff --git a/app/Services/PipelineTemplates/VariableResolver.php b/app/Services/PipelineTemplates/VariableResolver.php new file mode 100644 index 0000000..248a3ad --- /dev/null +++ b/app/Services/PipelineTemplates/VariableResolver.php @@ -0,0 +1,107 @@ + $definition + * @param array $values + * @return array + */ + public function resolve(array $definition, array $values): array + { + $variables = $definition['variables'] ?? []; + $this->validateRequiredVariables($variables, $values); + $coercedValues = $this->coerceValues($variables, $values); + + return $this->replacePlaceholders($definition, $coercedValues); + } + + /** + * @param array> $variables + * @param array $values + */ + private function validateRequiredVariables(array $variables, array $values): void + { + $missing = []; + foreach ($variables as $variable) { + $name = $variable['name'] ?? ''; + $required = $variable['required'] ?? true; + if ($required && ! array_key_exists($name, $values)) { + $missing[] = $name; + } + } + if (! empty($missing)) { + throw new InvalidArgumentException( + 'Missing required template variables: '.implode(', ', $missing), + ); + } + } + + /** + * @param array> $variables + * @param array $values + * @return array + */ + private function coerceValues(array $variables, array $values): array + { + $result = $values; + foreach ($variables as $variable) { + $name = $variable['name'] ?? ''; + $type = $variable['type'] ?? 'string'; + if (! array_key_exists($name, $values)) { + if (array_key_exists('default', $variable)) { + $result[$name] = $this->coerce($variable['default'], $type); + } + + continue; + } + $result[$name] = $this->coerce($values[$name], $type); + } + + return $result; + } + + private function coerce(mixed $value, string $type): mixed + { + return match ($type) { + 'number' => is_numeric($value) ? $value + 0 : (float) $value, + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value, + 'select' => (string) $value, + 'multiselect' => is_array($value) ? $value : [(string) $value], + default => (string) $value, + }; + } + + /** + * @param array $definition + * @param array $values + * @return array + */ + private function replacePlaceholders(array $definition, array $values): array + { + array_walk_recursive($definition, function (mixed &$item) use ($values): void { + if (! is_string($item)) { + return; + } + $item = preg_replace_callback( + '/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/', + function (array $matches) use ($values): string { + $key = $matches[1]; + if (! array_key_exists($key, $values)) { + return $matches[0]; + } + $val = $values[$key]; + + return is_array($val) ? implode(', ', $val) : (string) $val; + }, + $item, + ) ?? $item; + }); + + return $definition; + } +} 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/numen.php b/config/numen.php index ba1f700..e6136e9 100755 --- a/config/numen.php +++ b/config/numen.php @@ -191,4 +191,21 @@ 'queue' => env('GRAPH_QUEUE', 'graph'), ], + /* + |-------------------------------------------------------------------------- + | Competitor Analysis + |-------------------------------------------------------------------------- + | Controls the competitor-aware content differentiation pipeline stage. + */ + 'competitor_analysis' => [ + 'enabled' => env('COMPETITOR_ANALYSIS_ENABLED', true), + 'similarity_threshold' => (float) env('COMPETITOR_SIMILARITY_THRESHOLD', 0.25), + 'max_competitors_to_analyze' => (int) env('COMPETITOR_MAX_ANALYZE', 5), + 'auto_enrich_briefs' => env('COMPETITOR_AUTO_ENRICH_BRIEFS', true), + // Retention settings (days) + 'content_retention_days' => (int) env('COMPETITOR_CONTENT_RETENTION_DAYS', 90), + 'analysis_retention_days' => (int) env('COMPETITOR_ANALYSIS_RETENTION_DAYS', 180), + 'alert_event_retention_days' => (int) env('COMPETITOR_ALERT_EVENT_RETENTION_DAYS', 30), + ], + ]; 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..f804ab0 --- /dev/null +++ b/database/factories/CompetitorContentItemFactory.php @@ -0,0 +1,29 @@ + CompetitorSource::factory(), + 'space_id' => Space::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..6bd79a9 --- /dev/null +++ b/database/factories/ContentFingerprintFactory.php @@ -0,0 +1,31 @@ +faker->words(8); + $keywords = []; + foreach ($words as $word) { + $keywords[$word] = round($this->faker->randomFloat(4, 0.01, 1.0), 4); + } + + return [ + 'fingerprintable_type' => CompetitorContentItem::class, + 'fingerprintable_id' => CompetitorContentItem::factory(), + 'topics' => $this->faker->words(5), + 'entities' => $this->faker->words(3), + 'keywords' => $keywords, + '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/factories/PipelineTemplateFactory.php b/database/factories/PipelineTemplateFactory.php new file mode 100644 index 0000000..b7ba347 --- /dev/null +++ b/database/factories/PipelineTemplateFactory.php @@ -0,0 +1,47 @@ +faker->words(3, true).' Template'; + + return [ + 'space_id' => null, + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::random(6), + 'description' => $this->faker->sentence(), + 'category' => $this->faker->randomElement(['content', 'seo', 'social', 'email', 'ecommerce']), + 'icon' => $this->faker->randomElement(['document', 'sparkles', 'megaphone', 'mail', 'shopping-cart']), + 'schema_version' => '1.0', + 'is_published' => false, + 'author_name' => $this->faker->name(), + 'author_url' => $this->faker->url(), + 'downloads_count' => 0, + ]; + } + + public function published(): static + { + return $this->state(['is_published' => true]); + } + + public function global(): static + { + return $this->state(['space_id' => null]); + } + + public function forSpace(Space $space): static + { + return $this->state(['space_id' => $space->id]); + } +} diff --git a/database/factories/PipelineTemplateInstallFactory.php b/database/factories/PipelineTemplateInstallFactory.php new file mode 100644 index 0000000..5f1f646 --- /dev/null +++ b/database/factories/PipelineTemplateInstallFactory.php @@ -0,0 +1,31 @@ + PipelineTemplate::factory(), + 'version_id' => PipelineTemplateVersion::factory(), + 'space_id' => Space::factory(), + 'pipeline_id' => null, + 'installed_at' => now(), + 'config_overrides' => null, + ]; + } + + public function withConfigOverrides(array $overrides = []): static + { + return $this->state(['config_overrides' => $overrides ?: ['persona_id' => 'custom']]); + } +} diff --git a/database/factories/PipelineTemplateRatingFactory.php b/database/factories/PipelineTemplateRatingFactory.php new file mode 100644 index 0000000..58ad4d8 --- /dev/null +++ b/database/factories/PipelineTemplateRatingFactory.php @@ -0,0 +1,28 @@ + PipelineTemplate::factory(), + 'user_id' => User::factory(), + 'rating' => $this->faker->numberBetween(1, 5), + 'review' => $this->faker->optional()->sentence(), + ]; + } + + public function withRating(int $rating): static + { + return $this->state(['rating' => $rating]); + } +} diff --git a/database/factories/PipelineTemplateVersionFactory.php b/database/factories/PipelineTemplateVersionFactory.php new file mode 100644 index 0000000..44d53ee --- /dev/null +++ b/database/factories/PipelineTemplateVersionFactory.php @@ -0,0 +1,44 @@ + PipelineTemplate::factory(), + 'version' => $this->faker->semver(), + 'definition' => [ + 'stages' => [ + ['name' => 'generate', 'type' => 'ai_generate', 'persona_role' => 'creator'], + ['name' => 'review', 'type' => 'human_gate'], + ['name' => 'publish', 'type' => 'auto_publish'], + ], + 'trigger' => 'manual', + ], + 'changelog' => $this->faker->sentence(), + 'is_latest' => false, + 'published_at' => null, + ]; + } + + public function latest(): static + { + return $this->state([ + 'is_latest' => true, + 'published_at' => now(), + ]); + } + + public function published(): static + { + return $this->state(['published_at' => now()]); + } +} 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..56fad05 --- /dev/null +++ b/database/migrations/2026_03_15_400002_create_competitor_content_items_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->index(); + $table->string('source_id', 26)->index(); + $table->string('space_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_500001_create_pipeline_templates_table.php b/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php new file mode 100644 index 0000000..bb72dbb --- /dev/null +++ b/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php @@ -0,0 +1,35 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->nullable()->index(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->string('icon')->nullable(); + $table->string('schema_version')->default('1.0'); + $table->boolean('is_published')->default(false); + $table->string('author_name')->nullable(); + $table->string('author_url')->nullable(); + $table->unsignedBigInteger('downloads_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_templates'); + } +}; diff --git a/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php b/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php new file mode 100644 index 0000000..39c96c3 --- /dev/null +++ b/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('version'); + $table->json('definition'); + $table->text('changelog')->nullable(); + $table->boolean('is_latest')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_versions'); + } +}; diff --git a/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php b/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php new file mode 100644 index 0000000..83dd59d --- /dev/null +++ b/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('version_id', 26)->index(); + $table->string('space_id', 26)->index(); + $table->string('pipeline_id', 26)->nullable()->index(); + $table->timestamp('installed_at')->useCurrent(); + $table->json('config_overrides')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_installs'); + } +}; diff --git a/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php b/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php new file mode 100644 index 0000000..b5ccd28 --- /dev/null +++ b/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('user_id', 26)->index(); + $table->tinyInteger('rating')->unsigned(); + $table->text('review')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_ratings'); + } +}; diff --git a/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php b/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php new file mode 100644 index 0000000..d3ca582 --- /dev/null +++ b/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php @@ -0,0 +1,24 @@ +softDeletes(); + }); + } + } + + public function down(): void + { + Schema::table('content_pipelines', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; 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/database/seeders/BuiltInTemplateSeeder.php b/database/seeders/BuiltInTemplateSeeder.php new file mode 100644 index 0000000..9eaaafc --- /dev/null +++ b/database/seeders/BuiltInTemplateSeeder.php @@ -0,0 +1,304 @@ +templates() as $spec) { + $exists = PipelineTemplate::withTrashed() + ->where('slug', $spec['slug']) + ->whereNull('space_id') + ->exists(); + + if ($exists) { + continue; + } + + $template = PipelineTemplate::create([ + 'space_id' => null, + 'name' => $spec['name'], + 'slug' => $spec['slug'], + 'description' => $spec['description'], + 'category' => $spec['category'], + 'icon' => $spec['icon'], + 'schema_version' => '1.0', + 'is_published' => true, + 'author_name' => 'Numen', + 'author_url' => 'https://numen.ai', + ]); + + PipelineTemplateVersion::create([ + 'template_id' => $template->id, + 'version' => '1.0.0', + 'definition' => $spec['definition'], + 'changelog' => 'Initial built-in template.', + 'is_latest' => true, + 'published_at' => now(), + ]); + } + } + + /** @return array> */ + private function templates(): array + { + return [ + $this->blogPostPipeline(), + $this->socialMediaCampaign(), + $this->productDescription(), + $this->emailNewsletter(), + $this->pressRelease(), + $this->landingPage(), + $this->technicalDocumentation(), + $this->videoScript(), + ]; + } + + /** @return array */ + private function blogPostPipeline(): array + { + return [ + 'name' => 'Blog Post Pipeline', + 'slug' => 'blog-post-pipeline', + 'description' => 'Full blog post creation pipeline: outline, draft, SEO review, human gate, publish.', + 'category' => 'content', + 'icon' => 'pencil', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'blog_writer', 'name' => 'Blog Writer', 'system_prompt' => 'You are an expert blog writer producing engaging, well-structured long-form content.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Conversational, informative, SEO-aware.'], + ['ref' => 'seo_reviewer', 'name' => 'SEO Reviewer', 'system_prompt' => 'You are an SEO specialist who reviews content for keyword density, meta descriptions, and readability.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Outline', 'persona_ref' => 'blog_writer', 'config' => ['prompt_template' => 'Create a detailed outline for a blog post about {topic}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Draft', 'persona_ref' => 'blog_writer', 'config' => ['prompt_template' => 'Write a full blog post based on this outline: {outline}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'SEO Check', 'persona_ref' => 'seo_reviewer', 'config' => ['prompt_template' => 'Review this blog post for SEO quality: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Editor Approval', 'config' => ['instructions' => 'Review and approve the blog post before publishing.'], 'enabled' => true], + ['type' => 'auto_publish', 'name' => 'Publish', 'config' => ['target' => 'cms'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 300], + 'variables' => [ + ['key' => 'topic', 'type' => 'string', 'label' => 'Blog Topic', 'required' => true], + ['key' => 'keywords', 'type' => 'text', 'label' => 'Target Keywords', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function socialMediaCampaign(): array + { + return [ + 'name' => 'Social Media Campaign', + 'slug' => 'social-media-campaign', + 'description' => 'Generate platform-specific social media posts from a single campaign brief.', + 'category' => 'social', + 'icon' => 'megaphone', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'social_copywriter', 'name' => 'Social Copywriter', 'system_prompt' => 'You are a social media expert who crafts engaging, platform-native copy.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Punchy, trend-aware, emoji-friendly.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Twitter Post', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write a Twitter post (max 280 chars) for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'LinkedIn Post', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write a professional LinkedIn post for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Instagram Caption', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write an Instagram caption with hashtags for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Brand Voice Check', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Check the following posts for brand consistency: {posts}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 180], + 'variables' => [ + ['key' => 'campaign_brief', 'type' => 'text', 'label' => 'Campaign Brief', 'required' => true], + ['key' => 'brand_tone', 'type' => 'select', 'label' => 'Brand Tone', 'required' => false, 'options' => ['professional', 'playful', 'inspirational', 'edgy']], + ], + ], + ]; + } + + /** @return array */ + private function productDescription(): array + { + return [ + 'name' => 'Product Description', + 'slug' => 'product-description', + 'description' => 'Generate compelling product descriptions with feature bullets and SEO optimization.', + 'category' => 'ecommerce', + 'icon' => 'cart', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'product_copywriter', 'name' => 'Product Copywriter', 'system_prompt' => 'You write compelling product descriptions that convert browsers into buyers.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Benefit-led, clear, persuasive.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Long Description', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Write a detailed product description for {product_name}: {product_details}'], 'enabled' => true], + ['type' => 'ai_transform', 'name' => 'Feature Bullets', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Extract 5 key feature bullets from: {long_description}'], 'enabled' => true], + ['type' => 'ai_transform', 'name' => 'Meta Description', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Write a 160-char SEO meta description for: {long_description}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => true, 'review_required' => false, 'max_retries' => 3, 'timeout_seconds' => 120], + 'variables' => [ + ['key' => 'product_name', 'type' => 'string', 'label' => 'Product Name', 'required' => true], + ['key' => 'product_details', 'type' => 'text', 'label' => 'Product Details', 'required' => true], + ['key' => 'target_audience', 'type' => 'string', 'label' => 'Target Audience', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function emailNewsletter(): array + { + return [ + 'name' => 'Email Newsletter', + 'slug' => 'email-newsletter', + 'description' => 'Create full email newsletters with subject line variants and CTA optimisation.', + 'category' => 'email', + 'icon' => 'email', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'email_writer', 'name' => 'Email Writer', 'system_prompt' => 'You are an expert email marketer who writes newsletters people actually read.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Warm, direct, action-oriented.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Subject Lines', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Write 5 email subject line variants for a newsletter about: {newsletter_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Newsletter Body', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Write a newsletter body for: {newsletter_topic}. Include intro, 3 sections, and a CTA.'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Spam Check', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Review this email for spam triggers and deliverability issues: {body}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Final Approval', 'config' => ['instructions' => 'Review and approve the newsletter before sending.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 240], + 'variables' => [ + ['key' => 'newsletter_topic', 'type' => 'string', 'label' => 'Newsletter Topic', 'required' => true], + ['key' => 'send_date', 'type' => 'string', 'label' => 'Planned Send Date', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function pressRelease(): array + { + return [ + 'name' => 'Press Release', + 'slug' => 'press-release', + 'description' => 'Generate professional press releases with datelines, quotes, and boilerplate.', + 'category' => 'pr', + 'icon' => 'newspaper', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'pr_writer', 'name' => 'PR Writer', 'system_prompt' => 'You are a public relations professional who writes clear, factual press releases in AP style.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Formal, objective, newswire-ready.'], + ['ref' => 'legal_reviewer', 'name' => 'Legal Reviewer', 'system_prompt' => 'You review press releases for legal risks, false claims, and compliance issues.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Draft Press Release', 'persona_ref' => 'pr_writer', 'config' => ['prompt_template' => 'Write a press release for: {announcement}. Company: {company_name}. Contact: {contact_info}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Legal Review', 'persona_ref' => 'legal_reviewer', 'config' => ['prompt_template' => 'Review this press release for legal risks: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Executive Sign-off', 'config' => ['instructions' => 'Route to executive for approval before distribution.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 300], + 'variables' => [ + ['key' => 'announcement', 'type' => 'text', 'label' => 'Announcement Details', 'required' => true], + ['key' => 'company_name', 'type' => 'string', 'label' => 'Company Name', 'required' => true], + ['key' => 'contact_info', 'type' => 'text', 'label' => 'Media Contact Info', 'required' => true], + ['key' => 'embargo_date', 'type' => 'string', 'label' => 'Embargo Date', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function landingPage(): array + { + return [ + 'name' => 'Landing Page', + 'slug' => 'landing-page', + 'description' => 'Craft high-converting landing page copy: headline, hero, benefits, social proof, CTA.', + 'category' => 'marketing', + 'icon' => 'rocket', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'conversion_copywriter', 'name' => 'Conversion Copywriter', 'system_prompt' => 'You are a direct-response copywriter specialising in high-converting landing pages.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Benefit-driven, urgent, customer-centric.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Headline Variants', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 5 headline variants for a landing page about {offer}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Hero Section', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write hero copy (headline, subheadline, CTA) for: {offer}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Benefits Section', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 6 key benefits for: {offer}. Target audience: {target_audience}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'CTA Copy', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 3 CTA button text variants and supporting copy for: {offer}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Conversion Review', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Review the landing page copy for CRO: {full_copy}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 360], + 'variables' => [ + ['key' => 'offer', 'type' => 'text', 'label' => 'Offer / Product', 'required' => true], + ['key' => 'target_audience', 'type' => 'text', 'label' => 'Target Audience', 'required' => true], + ['key' => 'unique_value', 'type' => 'text', 'label' => 'Unique Value Prop', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function technicalDocumentation(): array + { + return [ + 'name' => 'Technical Documentation', + 'slug' => 'technical-documentation', + 'description' => 'Generate developer-ready technical documentation: API refs, guides, and FAQs.', + 'category' => 'technical', + 'icon' => 'books', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'technical_writer', 'name' => 'Technical Writer', 'system_prompt' => 'You are a technical writer who creates clear, accurate developer documentation.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Precise, scannable, code-inclusive.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Overview Section', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write an overview section for documentation of {feature_name}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Getting Started', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write a Getting Started guide for {feature_name}. Include code examples.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'API Reference', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Document the following API endpoints in OpenAPI style: {api_spec}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'FAQ Section', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write an FAQ section for {feature_name} based on common developer questions.'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Accuracy Review', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Review the following documentation for technical accuracy and completeness: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Engineer Review', 'config' => ['instructions' => 'Have an engineer verify technical accuracy.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 480], + 'variables' => [ + ['key' => 'feature_name', 'type' => 'string', 'label' => 'Feature / Product Name', 'required' => true], + ['key' => 'api_spec', 'type' => 'text', 'label' => 'API Spec / Endpoints', 'required' => false], + ['key' => 'audience_level', 'type' => 'select', 'label' => 'Audience Level', 'required' => false, 'options' => ['beginner', 'intermediate', 'advanced']], + ], + ], + ]; + } + + /** @return array */ + private function videoScript(): array + { + return [ + 'name' => 'Video Script', + 'slug' => 'video-script', + 'description' => 'Generate full video scripts with hook, scene breakdown, narration, and call-to-action.', + 'category' => 'video', + 'icon' => 'film', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'script_writer', 'name' => 'Script Writer', 'system_prompt' => 'You write compelling video scripts for YouTube, explainer videos, and ads.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Engaging, visual, paced for on-camera delivery.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Hook', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Write a compelling 5-second hook for a video about: {video_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Scene Breakdown', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Create a scene-by-scene breakdown for a {video_length}-minute video about: {video_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Full Script', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Write the full narration script. Topic: {video_topic}. Scenes: {scene_breakdown}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Pacing Review', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Review this script for pacing, clarity, and engagement: {full_script}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Director Sign-off', 'config' => ['instructions' => 'Have the director review and approve the script.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 360], + 'variables' => [ + ['key' => 'video_topic', 'type' => 'string', 'label' => 'Video Topic', 'required' => true], + ['key' => 'video_length', 'type' => 'select', 'label' => 'Video Length (min)', 'required' => true, 'options' => ['1', '2', '3', '5', '10', '15']], + ['key' => 'video_style', 'type' => 'select', 'label' => 'Video Style', 'required' => false, 'options' => ['explainer', 'tutorial', 'testimonial', 'ad', 'documentary']], + ], + ], + ]; + } +} 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-competitor-differentiation.md b/docs/blog-competitor-differentiation.md new file mode 100644 index 0000000..5124574 --- /dev/null +++ b/docs/blog-competitor-differentiation.md @@ -0,0 +1,90 @@ +--- +title: "Know Your Competition: Introducing Competitor-Aware Content Differentiation" +slug: competitor-aware-content-differentiation +date: 2026-03-16 +author: byte5.labs +tags: [product, content-strategy, AI, competitive-intelligence] +excerpt: "Numen now automatically crawls your competitors, compares their content against yours, and surfaces exactly where you need to differentiate. Stop guessing. Start winning." +--- + +# Know Your Competition: Introducing Competitor-Aware Content Differentiation + +Great content doesn't exist in a vacuum. Your readers are comparing you against three other tabs right now. So why are most content teams still doing competitive research manually — occasional ad-hoc checks, spreadsheets, and gut feelings? + +Today we're shipping **Competitor-Aware Content Differentiation** — a new Numen feature that puts automated competitive intelligence directly in your content workflow. + +## What It Does + +At its core, this feature does three things: + +**1. Continuously monitors your competitors** +Add competitor RSS feeds, sitemaps, or websites. Numen crawls them on your schedule and keeps a live inventory of their published content. + +**2. Automatically scores your differentiation** +For every piece of content you create or brief you plan, Numen compares it against similar competitor content using TF-IDF fingerprinting and cosine similarity. You get a differentiation score from 0–100%, where higher means you're covering angles they're not. + +**3. Surfaces actionable insights** +Using Claude, Numen identifies: +- **Content angles** your competitors are using (so you can avoid or counter them) +- **Gaps** in their coverage (your opportunity) +- **Recommendations** to make your piece more distinct + +## How It Works + +``` +Your Content / Brief + │ + ▼ +ContentFingerprintService ──► TF-IDF Vectors + │ + ▼ +SimilarContentFinder ──────► Top-5 Competitor Items + │ + ▼ +DifferentiationAnalysisService (Claude) + │ + ├── similarity_score: 0.31 + ├── differentiation_score: 0.69 + ├── angles: ["feature-comparison", "pricing-focus"] + ├── gaps: ["security-depth", "enterprise-use-cases"] + └── recommendations: ["Add security audit section", ...] +``` + +The whole pipeline runs automatically when you create a brief or publish content — no extra steps required. + +## Alert System + +You don't have to keep checking the dashboard. Set up alerts: + +- **New Content** — get notified when a competitor publishes something new +- **Keyword Match** — track specific topics (e.g., "AI content generation", "headless CMS") +- **High Similarity** — get an alert when competitor content is dangerously similar to yours + +Alerts deliver via **email**, **Slack**, or any **webhook** — wherever your team already works. + +## Knowledge Graph Integration + +Competitor insights don't live in isolation. They're wired into Numen's Knowledge Graph, creating `competitor_similarity` edges between your content and theirs. This means: + +- Your gap analysis now includes competitor context +- Related content suggestions factor in what competitors have already covered +- Topic clusters surface your differentiation opportunities visually + +## Getting Started + +1. Go to **Settings → Competitor Sources** and add your first competitor +2. Numen will crawl it within the hour and start indexing content +3. Create a new brief — the differentiation score appears automatically +4. Set up a keyword alert for your core topics + +## What's Next + +This is v1.0 of competitor intelligence. On the roadmap: +- Trend analysis over time (are you converging or diverging from competitors?) +- SERP integration — compare against what's actually ranking +- Persona-aware differentiation (what's different for *your* audience segment) +- Multi-language competitor tracking + +--- + +*Competitor-Aware Content Differentiation ships in Numen v0.14.0. Available to all plans.* 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/docs/competitor-differentiation-api.yaml b/docs/competitor-differentiation-api.yaml new file mode 100644 index 0000000..a9e8151 --- /dev/null +++ b/docs/competitor-differentiation-api.yaml @@ -0,0 +1,321 @@ +openapi: 3.1.0 +info: + title: Numen Competitor Differentiation API + version: 1.0.0 + description: | + REST API for the Competitor-Aware Content Differentiation feature (#37). + + Endpoints for managing competitor sources, triggering crawls, reviewing + differentiation analyses, and configuring alert rules. + contact: + name: byte5.labs + url: https://byte5.de + +servers: + - url: /api/v1/competitor + description: Competitor Intelligence API + +security: + - sanctum: [] + +paths: + /sources: + get: + summary: List competitor sources + tags: [Sources] + parameters: + - name: space_id + in: query + required: true + schema: { type: string } + - name: per_page + in: query + schema: { type: integer, default: 20, minimum: 1, maximum: 100 } + responses: + '200': + description: Paginated list of competitor sources + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/CompetitorSource' } + post: + summary: Create a competitor source + tags: [Sources] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateCompetitorSourceInput' } + responses: + '201': + description: Created source + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/CompetitorSource' } + + /sources/{id}: + get: + summary: Get a competitor source + tags: [Sources] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Competitor source + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/CompetitorSource' } + patch: + summary: Update a competitor source + tags: [Sources] + parameters: + - name: id + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateCompetitorSourceInput' } + responses: + '200': + description: Updated source + delete: + summary: Delete a competitor source + tags: [Sources] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '204': + description: Deleted + + /sources/{id}/crawl: + post: + summary: Trigger an immediate crawl + tags: [Sources] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Job dispatched + content: + application/json: + schema: + type: object + properties: + message: { type: string } + source_id: { type: string } + + /content: + get: + summary: List crawled competitor content + tags: [Content] + parameters: + - name: space_id + in: query + required: true + schema: { type: string } + - name: source_id + in: query + schema: { type: string } + - name: per_page + in: query + schema: { type: integer, default: 20 } + responses: + '200': + description: Paginated list of competitor content items + + /alerts: + get: + summary: List competitor alerts + tags: [Alerts] + parameters: + - name: space_id + in: query + required: true + schema: { type: string } + responses: + '200': + description: Paginated list of alerts + post: + summary: Create a competitor alert + tags: [Alerts] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateCompetitorAlertInput' } + responses: + '201': + description: Created alert + + /alerts/{id}: + delete: + summary: Delete a competitor alert + tags: [Alerts] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '204': + description: Deleted + + /differentiation: + get: + summary: List differentiation analyses + tags: [Differentiation] + parameters: + - name: space_id + in: query + required: true + schema: { type: string } + - name: content_id + in: query + schema: { type: string } + - name: brief_id + in: query + schema: { type: string } + - name: min_score + in: query + schema: { type: number, minimum: 0, maximum: 1 } + - name: per_page + in: query + schema: { type: integer, default: 20 } + responses: + '200': + description: Paginated list of analyses + + /differentiation/summary: + get: + summary: Get differentiation score summary + tags: [Differentiation] + parameters: + - name: space_id + in: query + required: true + schema: { type: string } + responses: + '200': + description: Summary statistics + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/DifferentiationSummary' } + + /differentiation/{id}: + get: + summary: Get a single differentiation analysis + tags: [Differentiation] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: Analysis detail + +components: + securitySchemes: + sanctum: + type: apiKey + in: cookie + name: laravel_session + + schemas: + CompetitorSource: + type: object + properties: + id: { type: string } + space_id: { type: string } + name: { type: string } + url: { type: string, format: uri } + feed_url: { type: string, nullable: true } + crawler_type: { type: string, enum: [rss, sitemap, scrape, api] } + is_active: { type: boolean } + crawl_interval_minutes: { type: integer } + last_crawled_at: { type: string, format: date-time, nullable: true } + error_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + CreateCompetitorSourceInput: + type: object + required: [space_id, name, url, crawler_type] + properties: + space_id: { type: string } + name: { type: string } + url: { type: string, format: uri } + feed_url: { type: string, nullable: true } + crawler_type: { type: string, enum: [rss, sitemap, scrape, api] } + config: { type: object, nullable: true } + is_active: { type: boolean, default: true } + crawl_interval_minutes: { type: integer, default: 60, minimum: 5, maximum: 10080 } + + UpdateCompetitorSourceInput: + type: object + properties: + name: { type: string } + url: { type: string, format: uri } + feed_url: { type: string, nullable: true } + crawler_type: { type: string, enum: [rss, sitemap, scrape, api] } + config: { type: object, nullable: true } + is_active: { type: boolean } + crawl_interval_minutes: { type: integer, minimum: 5, maximum: 10080 } + + CreateCompetitorAlertInput: + type: object + required: [space_id, name, type] + properties: + space_id: { type: string } + name: { type: string } + type: { type: string, enum: [new_content, keyword, high_similarity] } + conditions: + type: object + nullable: true + properties: + keywords: { type: array, items: { type: string } } + similarity_threshold: { type: number, minimum: 0, maximum: 1 } + source_id: { type: string } + is_active: { type: boolean, default: true } + notify_channels: + type: object + nullable: true + properties: + email: { type: array, items: { type: string, format: email } } + slack_webhook: { type: string, format: uri } + webhook_url: { type: string, format: uri } + + DifferentiationSummary: + type: object + properties: + total_analyses: { type: integer } + avg_differentiation_score: { type: number } + avg_similarity_score: { type: number } + max_differentiation_score: { type: number } + min_differentiation_score: { type: number } + last_analyzed_at: { type: string, format: date-time, nullable: true } diff --git a/docs/pipeline-templates.md b/docs/pipeline-templates.md new file mode 100644 index 0000000..2c5a691 --- /dev/null +++ b/docs/pipeline-templates.md @@ -0,0 +1,296 @@ +# AI Pipeline Templates & Preset Library + +**New in v0.10.0.** Reusable AI pipeline templates for accelerated content creation workflows. + +--- + +## Overview + +Pipeline Templates allow you to create, share, and install reusable AI content pipelines. Instead of configuring personas, stages, and variables from scratch for every brief, teams can install a pre-built template that handles the entire workflow. + +The system includes 8 production-ready built-in templates, a community library with ratings and installs, template versioning, and plugin hooks for extending the library with custom template packs. + +### Use Cases + +- **Marketing teams** — Reuse campaign templates across projects (Blog Post, Social Media, Landing Page) +- **Product teams** — Standardize e-commerce workflows (Product Descriptions) +- **Agency teams** — White-label templates for client work (Email Newsletter, Press Release) +- **Technical writers** — Consistent documentation templates (Technical Documentation) +- **Content creators** — Video production at scale (Video Script) + +--- + +## Built-in Templates + +Numen ships with 8 production-ready templates covering common content workflows: + +### 1. Blog Post Pipeline +**Slug:** `blog-post-pipeline` + +Complete blog post creation with outline, draft, SEO review, and editorial gate. + +### 2. Social Media Campaign +**Slug:** `social-media-campaign` + +Generate platform-specific posts (Twitter, LinkedIn, Instagram) from a single brief. + +### 3. Product Description +**Slug:** `product-description` + +Generate compelling product copy with feature bullets and SEO meta descriptions. + +### 4. Email Newsletter +**Slug:** `email-newsletter` + +Create full newsletters with subject line variants, body, and spam check. + +### 5. Press Release +**Slug:** `press-release` + +Generate professional press releases in AP style with legal review gate. + +### 6. Landing Page +**Slug:** `landing-page` + +Craft high-converting landing page copy with headlines, hero, benefits, and CTAs. + +### 7. Technical Documentation +**Slug:** `technical-documentation` + +Generate developer-ready docs: overview, getting started, API reference, FAQ. + +### 8. Video Script +**Slug:** `video-script` + +Create full video scripts with hooks, scene breakdown, narration, and pacing review. + +--- + +## Template Schema Format + +### Template Metadata + +```json +{ + "name": "Template Display Name", + "slug": "kebab-case-slug", + "description": "Short description of what the template does", + "category": "content|social|ecommerce|email|pr|marketing|technical|video", + "icon": "pencil|megaphone|cart|email|newspaper|rocket|books|film", + "schema_version": "1.0", + "author_name": "Creator Name", + "author_url": "https://example.com", + "is_published": true +} +``` + +### Definition Schema (JSON) + +The `definition` field in `PipelineTemplateVersion` contains the full pipeline configuration: + +```json +{ + "version": "1.0", + "personas": [ + { + "ref": "unique_ref", + "name": "Display Name", + "system_prompt": "System prompt for the persona...", + "llm_provider": "openai|anthropic|azure-openai|together-ai", + "llm_model": "gpt-4o|claude-3-sonnet|...", + "voice_guidelines": "Brand voice and tone notes..." + } + ], + "stages": [ + { + "type": "ai_generate|ai_review|ai_transform|human_gate|auto_publish", + "name": "Stage Display Name", + "persona_ref": "reference_to_personas[*].ref", + "config": { + "prompt_template": "Prompt with {variables}...", + "instructions": "For human_gate and auto_publish stages" + }, + "enabled": true + } + ], + "settings": { + "auto_publish": false, + "review_required": true, + "max_retries": 3, + "timeout_seconds": 300 + }, + "variables": [ + { + "key": "variable_key", + "type": "string|text|select|textarea", + "label": "Display Label", + "required": true, + "options": ["option1", "option2"] + } + ] +} +``` + +--- + +## API Endpoints Reference + +### List Templates (Paginated) + +``` +GET /api/v1/spaces/{space}/pipeline-templates +``` + +Query: `page`, `per_page`, `category`, `published`, `sort` + +### Create Template + +``` +POST /api/v1/spaces/{space}/pipeline-templates +``` + +### Get Template + +``` +GET /api/v1/spaces/{space}/pipeline-templates/{template} +``` + +### Update Template + +``` +PATCH /api/v1/spaces/{space}/pipeline-templates/{template} +``` + +### Delete Template + +``` +DELETE /api/v1/spaces/{space}/pipeline-templates/{template} +``` + +### Publish/Unpublish Template + +``` +POST /api/v1/spaces/{space}/pipeline-templates/{template}/publish +POST /api/v1/spaces/{space}/pipeline-templates/{template}/unpublish +``` + +### Manage Versions + +``` +GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions +POST /api/v1/spaces/{space}/pipeline-templates/{template}/versions +GET /api/v1/spaces/{space}/pipeline-templates/{template}/versions/{version} +``` + +### Template Installation + +``` +POST /api/v1/spaces/{space}/pipeline-templates/installs/{version} +PATCH /api/v1/spaces/{space}/pipeline-templates/installs/{install} +DELETE /api/v1/spaces/{space}/pipeline-templates/installs/{install} +``` + +**Rate Limited:** 5 requests per minute per user + +### Template Ratings + +``` +GET /api/v1/spaces/{space}/pipeline-templates/{template}/ratings +POST /api/v1/spaces/{space}/pipeline-templates/{template}/ratings +``` + +--- + +## Install Wizard Flow + +1. **Select Template** — Browse templates by category, rating, or download count +2. **Review Definition** — Display template metadata, ratings, definition (personas, stages, variables) +3. **Confirm Installation** — Creates install record and Brief with template configuration +4. **Launch Pipeline** — Users can immediately start briefs using the template + +--- + +## Plugin Hook Integration + +### Register Template Category + +```php +$hookRegistry = app(\App\Plugin\HookRegistry::class); + +$hookRegistry->registerTemplateCategory([ + 'slug' => 'custom-category', + 'label' => 'Custom Content Type', + 'description' => 'Templates for custom content workflows', + 'icon' => 'star', +]); +``` + +### Register Template Pack + +```php +$hookRegistry->registerTemplatePack([ + 'id' => 'my-plugin-templates', + 'name' => 'My Plugin Templates', + 'author' => 'Your Company', + 'url' => 'https://example.com', + 'templates' => [ + [ + 'name' => 'Custom Template 1', + 'slug' => 'custom-template-1', + 'description' => 'Description...', + 'category' => 'custom-category', + 'icon' => 'star', + 'schema_version' => '1.0', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ /* ... */ ], + 'stages' => [ /* ... */ ], + 'variables' => [ /* ... */ ], + ], + ], + ], +]); +``` + +--- + +## Security Notes + +### Space Scoping + +- **Global** (`space_id = null`) — Available to all spaces (built-in, published templates) +- **Space-scoped** (`space_id = `) — Private to a specific space + +### Role-Based Access Control + +| Operation | Permission | Role | +|-----------|-----------|------| +| Create | `templates.create` | Editor, Admin | +| Update | `templates.update` | Editor, Admin | +| Publish | `templates.publish` | Admin | +| Delete | `templates.delete` | Admin | +| Install | `templates.install` | Editor, Author | +| Rate | `templates.rate` | Any authenticated user | + +### Audit Logging + +All template operations logged for compliance: +- Template creation/update/deletion +- Version publishing +- Template installation +- Template ratings + +### API Rate Limiting + +- **Install template:** 5 requests per minute +- **Rating:** 1 request per minute +- **Template creation:** 10 requests per minute + +--- + +## Related Documentation + +- [Pipeline Architecture](../architecture/pipelines.md) +- [Plugin System](../plugins.md) +- [RBAC Guide](../RBAC_GUIDE.md) + diff --git a/graphql/schema.graphql b/graphql/schema.graphql index c78e5b2..045f0a2 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -472,3 +472,170 @@ type Subscription { pipelineRunCompleted(spaceId: ID!): PipelineRun @subscription(class: "App\\GraphQL\\Subscriptions\\PipelineRunCompleted") } + +# ─── Competitor-Aware Content Differentiation ──────────────────────────────── + +type CompetitorSource { + id: ID! + space_id: ID! + name: String! + url: String! + feed_url: String + crawler_type: String! + config: JSON + is_active: Boolean! + crawl_interval_minutes: Int! + last_crawled_at: DateTime + error_count: Int! + created_at: DateTime! + updated_at: DateTime! + content_items: [CompetitorContentItem!]! @hasMany +} + +type CompetitorContentItem { + id: ID! + source_id: ID! + external_url: String! + title: String + excerpt: String + published_at: DateTime + crawled_at: DateTime + content_hash: String + metadata: JSON + source: CompetitorSource! @belongsTo + differentiation_analyses: [DifferentiationAnalysis!]! @hasMany +} + +type DifferentiationAnalysis { + id: ID! + space_id: ID! + content_id: ID + brief_id: ID + competitor_content_id: ID! + similarity_score: Float! + differentiation_score: Float! + angles: JSON + gaps: JSON + recommendations: JSON + analyzed_at: DateTime + competitor_content: CompetitorContentItem! @belongsTo(relation: "competitorContent") +} + +type CompetitorAlert { + id: ID! + space_id: ID! + name: String! + type: String! + conditions: JSON + is_active: Boolean! + notify_channels: JSON + created_at: DateTime! + updated_at: DateTime! +} + +type DifferentiationSummary { + total_analyses: Int! + avg_differentiation_score: Float! + avg_similarity_score: Float! + max_differentiation_score: Float! + min_differentiation_score: Float! + last_analyzed_at: DateTime +} + +extend type Query { + competitorSources(space_id: ID!, first: Int = 20, page: Int): CompetitorSourcePaginator! + @guard + @paginate(model: "App\\Models\\CompetitorSource", scopes: ["bySpace"]) + + competitorContent(space_id: ID!, source_id: ID, first: Int = 20, page: Int): CompetitorContentItemPaginator! + @guard + @field(resolver: "App\\GraphQL\\Queries\\CompetitorContent") + + differentiationAnalyses(space_id: ID!, content_id: ID, brief_id: ID, first: Int = 20, page: Int): DifferentiationAnalysisPaginator! + @guard + @field(resolver: "App\\GraphQL\\Queries\\DifferentiationAnalyses") + + differentiationSummary(space_id: ID!): DifferentiationSummary! + @guard + @field(resolver: "App\\GraphQL\\Queries\\DifferentiationSummary") + + competitorAlerts(space_id: ID!, first: Int = 20, page: Int): CompetitorAlertPaginator! + @guard + @paginate(model: "App\\Models\\CompetitorAlert", scopes: ["bySpace"]) +} + +extend type Mutation { + createCompetitorSource(input: CreateCompetitorSourceInput!): CompetitorSource! + @guard + @field(resolver: "App\\GraphQL\\Mutations\\CreateCompetitorSource") + + updateCompetitorSource(id: ID!, input: UpdateCompetitorSourceInput!): CompetitorSource! + @guard + @field(resolver: "App\\GraphQL\\Mutations\\UpdateCompetitorSource") + + deleteCompetitorSource(id: ID!): CompetitorSource + @guard + @field(resolver: "App\\GraphQL\\Mutations\\DeleteCompetitorSource") + + createCompetitorAlert(input: CreateCompetitorAlertInput!): CompetitorAlert! + @guard + @field(resolver: "App\\GraphQL\\Mutations\\CreateCompetitorAlert") + + deleteCompetitorAlert(id: ID!): CompetitorAlert + @guard + @field(resolver: "App\\GraphQL\\Mutations\\DeleteCompetitorAlert") + + triggerCompetitorCrawl(source_id: ID!): Boolean! + @guard + @field(resolver: "App\\GraphQL\\Mutations\\TriggerCompetitorCrawl") +} + +input CreateCompetitorSourceInput { + space_id: ID! + name: String! + url: String! + feed_url: String + crawler_type: String! + config: JSON + is_active: Boolean = true + crawl_interval_minutes: Int = 60 +} + +input UpdateCompetitorSourceInput { + name: String + url: String + feed_url: String + crawler_type: String + config: JSON + is_active: Boolean + crawl_interval_minutes: Int +} + +input CreateCompetitorAlertInput { + space_id: ID! + name: String! + type: String! + conditions: JSON + is_active: Boolean = true + notify_channels: JSON +} + +type CompetitorSourcePaginator { + data: [CompetitorSource!]! + paginatorInfo: PaginatorInfo! +} + +type CompetitorContentItemPaginator { + data: [CompetitorContentItem!]! + paginatorInfo: PaginatorInfo! +} + +type DifferentiationAnalysisPaginator { + data: [DifferentiationAnalysis!]! + paginatorInfo: PaginatorInfo! +} + +type CompetitorAlertPaginator { + data: [CompetitorAlert!]! + paginatorInfo: PaginatorInfo! +} diff --git a/openapi.yaml b/openapi.yaml index 769afac..798a669 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2108,6 +2108,384 @@ paths: '200': description: List of supported formats + /spaces/{space}/pipeline-templates: + get: + operationId: listPipelineTemplates + summary: List pipeline templates + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: page + in: query + schema: + type: integer + - name: per_page + in: query + schema: + type: integer + - name: category + in: query + schema: + type: string + responses: + '200': + description: Paginated list of templates + '401': + description: Unauthenticated + + post: + operationId: createPipelineTemplate + summary: Create pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + slug: + type: string + responses: + '201': + description: Template created + '401': + description: Unauthenticated + + /spaces/{space}/pipeline-templates/{template}: + get: + operationId: getPipelineTemplate + summary: Get pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template details + + patch: + operationId: updatePipelineTemplate + summary: Update pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Template updated + + delete: + operationId: deletePipelineTemplate + summary: Delete pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '204': + description: Template deleted + + /spaces/{space}/pipeline-templates/{template}/publish: + post: + operationId: publishPipelineTemplate + summary: Publish pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template published + + /spaces/{space}/pipeline-templates/{template}/unpublish: + post: + operationId: unpublishPipelineTemplate + summary: Unpublish pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: Template unpublished + + /spaces/{space}/pipeline-templates/{template}/versions: + get: + operationId: listVersions + summary: List template versions + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: List of versions + + post: + operationId: createVersion + summary: Create template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + version: + type: string + definition: + type: object + responses: + '201': + description: Version created + + /spaces/{space}/pipeline-templates/{template}/versions/{version}: + get: + operationId: getVersion + summary: Get template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + responses: + '200': + description: Version details + + /spaces/{space}/pipeline-templates/installs/{version}: + post: + operationId: installTemplate + summary: Install template version + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: version + in: path + required: true + schema: + type: string + responses: + '201': + description: Template installed (rate-limited 5/min) + '429': + description: Rate limited + + /spaces/{space}/pipeline-templates/installs/{install}: + patch: + operationId: updateInstall + summary: Update template install + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: install + in: path + required: true + schema: + type: string + responses: + '200': + description: Install updated + + delete: + operationId: deleteInstall + summary: Delete template install + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: install + in: path + required: true + schema: + type: string + responses: + '204': + description: Install deleted + + /spaces/{space}/pipeline-templates/{template}/ratings: + get: + operationId: listRatings + summary: List template ratings + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + responses: + '200': + description: List of ratings + + post: + operationId: rateTemplate + summary: Rate pipeline template + tags: [Pipeline Templates] + security: + - sanctumToken: [] + parameters: + - name: space + in: path + required: true + schema: + type: string + - name: template + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + rating: + type: integer + minimum: 1 + maximum: 5 + comment: + type: string + responses: + '201': + description: Rating created + tags: - name: Content description: Public content delivery endpoints (60 req/min) @@ -2133,3 +2511,5 @@ tags: description: AI-powered content repurposing to 8 formats (authenticated) - name: Format Templates description: Custom format templates for content repurposing (authenticated) + - name: Pipeline Templates + description: Reusable AI pipeline templates for accelerated content workflows (authenticated, 5 req/min install) 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/phpstan.neon b/phpstan.neon index 3a41f97..e0d818f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,10 +8,11 @@ parameters: treatPhpDocTypesAsCertain: false excludePaths: - app/Http/Middleware/HandleInertiaRequests.php + - app/Services/Quality/ + - app/Services/PipelineTemplates/ ignoreErrors: - '#Access to an undefined property .*(User|Role)::.*pivot#' - '#Parameter.*callback of method.*map.*expects callable.*Closure.*given#' - - '#Using nullsafe property access.*on left side of.*is unnecessary#' phpVersion: 80200 parallel: maximumNumberOfProcesses: 1 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/Competitor/CompetitorDashboard.vue b/resources/js/Components/Competitor/CompetitorDashboard.vue new file mode 100644 index 0000000..447866c --- /dev/null +++ b/resources/js/Components/Competitor/CompetitorDashboard.vue @@ -0,0 +1,199 @@ + + + diff --git a/resources/js/Components/Competitor/CompetitorSourceManager.vue b/resources/js/Components/Competitor/CompetitorSourceManager.vue new file mode 100644 index 0000000..8c8ffdd --- /dev/null +++ b/resources/js/Components/Competitor/CompetitorSourceManager.vue @@ -0,0 +1,243 @@ + + + diff --git a/resources/js/Components/Competitor/DifferentiationScoreWidget.vue b/resources/js/Components/Competitor/DifferentiationScoreWidget.vue new file mode 100644 index 0000000..7346307 --- /dev/null +++ b/resources/js/Components/Competitor/DifferentiationScoreWidget.vue @@ -0,0 +1,133 @@ + + + diff --git a/resources/js/Components/Competitor/DifferentiationTrendChart.vue b/resources/js/Components/Competitor/DifferentiationTrendChart.vue new file mode 100644 index 0000000..e29a550 --- /dev/null +++ b/resources/js/Components/Competitor/DifferentiationTrendChart.vue @@ -0,0 +1,191 @@ + + + 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/Components/Templates/RatingPanel.vue b/resources/js/Components/Templates/RatingPanel.vue new file mode 100644 index 0000000..0d95e53 --- /dev/null +++ b/resources/js/Components/Templates/RatingPanel.vue @@ -0,0 +1,155 @@ + + +