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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## [Unreleased]

### Phase 7 — in-process AutoPublishCrew admin path (AI-042) (2026-06-15)

An admin-triggered, **in-process** path that runs the AI-041 specialists over `ILlmService` + the AI-040 `CrewOrchestrator` to generate SEO **prose** for an Edition (`Description` + `SeoRelevanceText`). **Why a new path, not a swap**: the legacy bash + Claude-CLI systemd poller (`seo-publish-poll.sh`/`seo-generate.sh`) is the production SEO pipeline — it works, it's the default, and it stays **fully intact and untouched**. This is the *observable* alternative: every call routes through the traced gateway (`llm_traces`), persists as an `agent_run` with the full sub-agent transcript (AI-045 replay), and is gated by a fail-closed critic — none of which the opaque CLI poller offers. Shipping it parallel (not as a replacement) lets us prove the crew on real editions with zero risk to the live poller, exactly as `AgentLoop` shipped before any caller migrated.

- **`Application/Agents/AutoPublishBriefs.cs`** (new) — static factory for the two `ContentBrief`s: `Description("en")` (800-1600 chars) + `Relevance("en")` (500-1000). Shared hardcoded `BannedPhrases` ("masterpiece", "must-read", "timeless classic", "page-turner", "tour de force", "magnum opus") and `StyleGuide` ("Factual, encyclopedic tone; no subjective superlatives; third person.") — encodes the legacy "no subjective superlatives" rule as a list the critic can actually score against. Admin-editable later.
- **`Application/Agents/AutoPublishCrew.cs`** (new, scoped) — DB-free service: builds a 4-stage sequential `CrewPlan` (researcher → drafter → critic → editor via `CrewTasks.Of`), runs it under `CrewOptions(CostCapUsd: 0.02m, MaxParallelism: 1)`, persists via `CrewRunRecordFactory` → `IAgentRunWriter` (agent `crew.autopublish`, goal `edition.{field}`), and returns an `AutoPublishFieldResult` (edited text + critique + fail-closed `NeedsReview` + status + runId). **Writes nothing to the Edition and never publishes** — the caller owns apply/publish. The `NeedsReview` gate is a pure, unit-tested static: `true` unless the crew COMPLETED **and** the editor's text clears the brief's `MinLength` floor **and** the critic produced a parseable verdict **and** that verdict raised no `blocker` (so an error/budget halt, **empty/whitespace/below-floor edited text**, a missing/unparseable critic, or any blocker all fail closed). The **empty-output floor** (`NeedsReview(status, critique, editedText, minLength)`) closes the P1 data-loss hole where an editor returning `""` with a clean critic would otherwise read as a clean pass and let the endpoint overwrite a real field with an empty string. Per-field cost cap is a `const decimal CostCapUsd = 0.02m`.
- **`Api/Endpoints/AdminAutoPublishEndpoints.cs`** — new `POST /admin/autopublish/editions/{editionId}/crew-generate` (same `/admin/*` auth group, new `autopublish.crew` rate-limit policy). Loads the same source material `seo-generate.sh` feeds Claude (title, author(s), language, `LEFT(plain_text, 1000)` of the first chapter), runs the crew TWICE (Description + Relevance, separate runIds). **Gate**: if EITHER field needs review → writes nothing, returns `{ needsReview: true, runIds, fields }` with per-field critique-score summaries; if BOTH clean → writes `Description` + `SeoRelevanceText`, sets `SeoSource = Auto`, saves. **Manual-source protection (P2)**: before the write block, `IsManualProtected(edition.SeoSource, Description, SeoRelevanceText)` (pure helper) blocks the write entirely when the edition is `SeoSource.Manual` **and** either targeted field already holds hand-written content — the response carries `manualProtected: true` so the admin sees why, the Edition (and its `SeoSource`) stays untouched, and only the crew transcripts persist (audit). Honors the same "Manual flag protects filled content from overwrite" contract as the legacy `SeoCoverageAnalyzer`. Empty Manual fields are still fair game for first-time generation. Edition stays `Draft` regardless. Response carries both runIds for the AI-045 transcript UI.
- **DI / config** — `AutoPublishCrew` registered scoped (it persists via the scoped `IAgentRunWriter`) next to the specialists in `Program.cs`; `autopublish.crew` rate-limit policy (4/min per IP — a generate is 8 LLM calls) mirrors the `studybuddy` policy shape. **No DB migration** — reuses the Phase 6 `agent_run` table and the existing Edition columns.
- Tests: `AutoPublishCrewTests` + `AutoPublishManualProtectionTests` (fake `ILlmService` routed per `FeatureTag` + recording `IAgentRunWriter`, no network/DB) — clean critic → not flagged + edited text; critic `blocker` → flagged; garbage critic → parser fail-closed → flagged; persists exactly once as `crew.autopublish` with editionId + 4 nested sub-agent steps; per-field cost cap → `budget_exhausted` + flagged + partial run persisted (only the research stage ran); plus the `NeedsReview` gate exercised directly across completed/halted/null/parse-failed/blocker/minor-major cases. **P1 floor**: empty / whitespace-only / below-`MinLength` editor output → flagged (was a pinned `_BUG` regression, now flipped); at/above-floor + clean critic → not flagged. **P2 manual-protect**: `IsManualProtected` pure helper — `Manual` + filled Description or Relevance → blocked; `Manual` + empty fields → allowed; `Auto`/`Hybrid` with content → allowed. No `ITool` introduced — the StudyBuddy tool set-equality test is unaffected.

### Phase 7 — crew specialist sub-agents + prompts (AI-041) (2026-06-15)

The four generic, **single-call** crew specialists the content crews (AI-042/043) compose via `CrewTasks.Of` + `CrewOrchestrator` (AI-040). Each is exactly ONE `ILlmService` gateway call — no tools, no `AgentLoop`, no iteration — and is domain-agnostic (no SEO/AutoPublish specifics): they operate on a shared `ContentBrief` (length in CHARACTERS, banned phrases, target language, optional style guide). **Why these four, in this order**: a researcher condenses the source into grounded bullet FACTS; a drafter writes the field strictly from those notes; a critic scores the draft 1-5 **against the research notes** (not its own knowledge) — every claim not supported by the notes is a factual-accuracy `blocker` — and an editor rewrites fixing each issue blockers-first. Grounding the critic on the research notes is the crux: it turns "does this sound plausible?" into "is this actually in the source?", which is what catches hallucinations the drafter slipped in.
Expand Down
1 change: 1 addition & 0 deletions backend/src/Api/Api.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<InternalsVisibleTo Include="TextStack.IntegrationTests" />
<InternalsVisibleTo Include="TextStack.UnitTests" />
</ItemGroup>

<ItemGroup>
Expand Down
138 changes: 138 additions & 0 deletions backend/src/Api/Endpoints/AdminAutoPublishEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using Api.Middleware;
using Application.AdminSettings;
using Application.Agents;
using Application.Common.Interfaces;
using Domain.Entities;
using Domain.Enums;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TextStack.Ai.Core;

namespace Api.Endpoints;

Expand All @@ -25,6 +28,11 @@ public static void MapAdminAutoPublishEndpoints(this WebApplication app)
group.MapPost("/trigger", Trigger);
group.MapPost("/queue/{editionId:guid}", QueueEdition);
group.MapGet("/candidates", GetCandidates);

// AI-042: in-process crew path — runs the AI-041 specialists over ILlmService to generate SEO prose.
// Behind the same /admin/* auth group; rate-limited (each call = two 4-stage crews = 8 LLM calls).
group.MapPost("/editions/{editionId:guid}/crew-generate", CrewGenerate)
.RequireRateLimiting("autopublish.crew");
}

private static async Task<IResult> GetSettings(AdminSettingsService settings, CancellationToken ct)
Expand Down Expand Up @@ -233,8 +241,138 @@ private static async Task<IResult> GetCandidates(

return Results.Ok(candidates);
}

/// <summary>
/// AI-042 — runs the in-process content crew over an Edition to generate its two SEO prose fields
/// (<c>Description</c> + <c>SeoRelevanceText</c>). Loads the same source material the legacy
/// <c>seo-generate.sh</c> uses (title, author(s), language, first-chapter excerpt), runs the crew once per
/// field (own runId, own cost cap, own persisted agent_run), and applies a fail-closed review gate: only if
/// BOTH fields pass cleanly does it write them and set <c>SeoSource = Auto</c>. The Edition stays Draft
/// either way — publishing remains the existing separate flow. No auto-publish, ever.
/// </summary>
private static async Task<IResult> CrewGenerate(
Guid editionId,
HttpContext httpContext,
IAppDbContext db,
AutoPublishCrew crew,
CancellationToken ct)
{
var adminId = httpContext.GetAdminUserId();

var edition = await db.Editions
.Include(e => e.EditionAuthors)
.ThenInclude(ea => ea.Author)
.FirstOrDefaultAsync(e => e.Id == editionId, ct);
if (edition == null) return Results.NotFound();

// First-chapter excerpt — mirror seo-generate.sh: LEFT(plain_text, 1000) of the lowest chapter_number.
var excerpt = await db.Chapters
.Where(c => c.EditionId == editionId)
.OrderBy(c => c.ChapterNumber)
.Select(c => c.PlainText)
.FirstOrDefaultAsync(ct);

var authors = string.Join(", ", edition.EditionAuthors
.Select(ea => ea.Author?.Name)
.Where(n => !string.IsNullOrWhiteSpace(n)));
var lang = edition.Language;

var sourceMaterial = BuildSourceMaterial(edition.Title, authors, lang, excerpt);

// One crew per field — separate runIds so AI-045's transcript UI can fetch each independently.
var descCtx = new AgentContext(adminId, editionId, Guid.NewGuid(), httpContext.RequestServices);
var descResult = await crew.RunFieldAsync(AutoPublishBriefs.Description(lang), sourceMaterial, descCtx, ct);

var relCtx = new AgentContext(adminId, editionId, Guid.NewGuid(), httpContext.RequestServices);
var relResult = await crew.RunFieldAsync(AutoPublishBriefs.Relevance(lang), sourceMaterial, relCtx, ct);

var runIds = new[] { descResult.RunId, relResult.RunId };
var fields = new[]
{
FieldSummary("description", descResult),
FieldSummary("relevance", relResult),
};

// Manual-source protection (AI-042 P2): a Manual edition with hand-written prose in either targeted field
// is never overwritten by the crew — same contract legacy SEO backfill honors (SeoCoverageAnalyzer). The
// crew transcripts are still persisted (audit), only the write-back is blocked.
if (IsManualProtected(edition.SeoSource, edition.Description, edition.SeoRelevanceText))
return Results.Ok(new CrewGenerateResponse(true, runIds, fields, ManualProtected: true));

// Fail-closed gate: if EITHER field needs review, write NOTHING — the admin inspects both transcripts.
var needsReview = descResult.NeedsReview || relResult.NeedsReview;
if (needsReview)
return Results.Ok(new CrewGenerateResponse(true, runIds, fields));

// Both clean → apply the prose and mark provenance Auto. Edition stays Draft regardless.
edition.Description = descResult.EditedText;
edition.SeoRelevanceText = relResult.EditedText;
edition.SeoSource = SeoSource.Auto;
edition.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);

return Results.Ok(new CrewGenerateResponse(false, runIds, fields));
}

/// <summary>
/// The manual-source write-block decision (AI-042 P2), pure for unit testing. Returns true when the edition is
/// <see cref="SeoSource.Manual"/> AND either targeted prose field already holds hand-written content — in which
/// case the crew must NOT overwrite it. Empty Manual fields are still fair game for first-time generation.
/// </summary>
internal static bool IsManualProtected(SeoSource source, string? description, string? relevanceText) =>
source == SeoSource.Manual &&
(!string.IsNullOrWhiteSpace(description) || !string.IsNullOrWhiteSpace(relevanceText));

/// <summary>The source block the crew reasons from — same fields seo-generate.sh feeds Claude.</summary>
private static string BuildSourceMaterial(string title, string author, string lang, string? excerpt) =>
$"""
Book: {title}
Author: {(string.IsNullOrWhiteSpace(author) ? "Unknown" : author)}
Language: {lang}
First chapter excerpt: {Excerpt(excerpt)}
""";

private static string Excerpt(string? plainText)
{
if (string.IsNullOrWhiteSpace(plainText)) return "(none)";
return plainText.Length <= 1000 ? plainText : plainText[..1000];
}

private static CrewFieldSummary FieldSummary(string field, AutoPublishFieldResult r) =>
new(
field,
r.RunId,
r.Status,
r.NeedsReview,
r.EditedText?.Length ?? 0,
r.Critique is { } c
? new CrewCritiqueSummary(c.FactualAccuracy, c.Tone, c.Length, c.BannedPhrases, c.ParseFailed,
c.Issues.Count(i => i.Severity == "blocker"))
: null);
}

public record CrewGenerateResponse(
bool NeedsReview,
IReadOnlyList<Guid> RunIds,
IReadOnlyList<CrewFieldSummary> Fields,
bool ManualProtected = false);

public record CrewFieldSummary(
string Field,
Guid RunId,
string Status,
bool NeedsReview,
int CharLength,
CrewCritiqueSummary? Critique);

public record CrewCritiqueSummary(
int FactualAccuracy,
int Tone,
int Length,
int BannedPhrases,
bool ParseFailed,
int BlockerCount);

public record AutoPublishSettingsDto(
bool Enabled, int BooksPerDay, int HourUtc, bool RequireReview, string Language);

Expand Down
16 changes: 16 additions & 0 deletions backend/src/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@
builder.Services.AddSingleton<Application.Agents.DrafterAgent>();
builder.Services.AddSingleton<Application.Agents.CriticAgent>();
builder.Services.AddSingleton<Application.Agents.EditorAgent>();
// AutoPublish crew (Phase 7, AI-042): in-process admin path that runs the specialists over ILlmService to
// generate SEO prose for an Edition. Scoped because it persists via the scoped IAgentRunWriter (per-request
// DbContext). The legacy bash + Claude-CLI poller stays the default; this is the observable, traced alternative.
builder.Services.AddScoped<Application.Agents.AutoPublishCrew>();
builder.Services.AddAuthSettings(builder.Configuration);

var connectionString = builder.Configuration.GetConnectionString("Default")
Expand Down Expand Up @@ -320,6 +324,18 @@
QueueLimit = 0,
});
});
// AutoPublish crew (AI-042): an admin generate is TWO 4-stage crews = 8 LLM calls, so a tight per-IP cap.
// Mirrors the studybuddy policy shape; it sits behind admin auth too, this is just runaway protection.
options.AddPolicy("autopublish.crew", httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 4,
QueueLimit = 0,
});
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Emit Retry-After so clients can back off intelligently instead of
// hammering in a tight retry loop. RateLimiter exposes the metadata
Expand Down
46 changes: 46 additions & 0 deletions backend/src/Application/Agents/AutoPublishBriefs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace Application.Agents;

/// <summary>
/// The hardcoded writing assignments for the in-process AutoPublish crew (Phase 7, AI-042). Mirrors the
/// "factual, encyclopedic tone; no subjective superlatives" rules the legacy <c>seo-generate.sh</c> hands
/// Claude, but as typed <see cref="ContentBrief"/>s the AI-041 specialists all read identically. Prose-only:
/// the crew owns just <c>Edition.Description</c> + <c>Edition.SeoRelevanceText</c>; themes/FAQs stay legacy.
///
/// Banned phrases and the style guide are constants for now (admin-editable later, AI-04x) — keep them in one
/// place so drafter, critic and editor enforce the exact same list. Bounds are in CHARACTERS.
/// </summary>
public static class AutoPublishBriefs
{
private const string Entity = "edition";

/// <summary>Description: 800-1600 chars (~150-250 words), the plot summary + literary significance block.</summary>
public const int DescriptionMin = 800;
public const int DescriptionMax = 1600;

/// <summary>Relevance: 500-1000 chars (~100-150 words), modern connections + why readers today should care.</summary>
public const int RelevanceMin = 500;
public const int RelevanceMax = 1000;

/// <summary>
/// Subjective superlatives the SEO prose must avoid — encodes the legacy "no subjective superlatives" rule
/// as an explicit list the critic can score against. The critic blocks a draft that uses any of these.
/// </summary>
public static readonly IReadOnlyList<string> BannedPhrases =
[
"masterpiece",
"must-read",
"timeless classic",
"page-turner",
"tour de force",
"magnum opus",
];

/// <summary>The shared tone contract — same string every specialist reads, so there is no style drift.</summary>
public const string StyleGuide = "Factual, encyclopedic tone; no subjective superlatives; third person.";

public static ContentBrief Description(string lang) =>
new(Entity, "description", DescriptionMin, DescriptionMax, BannedPhrases, lang, StyleGuide);

public static ContentBrief Relevance(string lang) =>
new(Entity, "relevance", RelevanceMin, RelevanceMax, BannedPhrases, lang, StyleGuide);
}
Loading
Loading