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
60 changes: 55 additions & 5 deletions packages/application/src/content/content-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import { assertGroundedContentDraft, evaluateContentReadiness } from "@outbound/domain/content/content-asset";

export const CONTENT_GENERATION_JOB_TYPE = "content.asset.generate";
export const CONTENT_GENERATION_JOB_PRIORITY = 60;

export interface ContentGenerationRunView {
readonly id: string;
Expand Down Expand Up @@ -75,14 +76,18 @@ export interface ContentGenerationRepository {
startRun(input: { workspaceId: string; runId: string; now: Date }): Promise<void>;
saveBrief(input: { workspaceId: string; runId: string; brief: ContentBriefSnapshot; now: Date }): Promise<void>;
saveDraft(input: { workspaceId: string; runId: string; draft: ContentDraftSnapshot; now: Date }): Promise<void>;
reviseDraftAfterAudit(input: { workspaceId: string; runId: string; draft: ContentDraftSnapshot; now: Date }): Promise<void>;
saveAudit(input: { workspaceId: string; runId: string; audit: ContentEvidenceAudit; now: Date }): Promise<void>;
completeRun(input: { workspaceId: string; runId: string; critique: ContentEditorialCritique; readiness: { ready: boolean; blockers: readonly string[] }; now: Date }): Promise<void>;
failRun(input: { workspaceId: string; runId: string; code: string; message: string; now: Date }): Promise<void>;
}

export interface ContentPipelineAgent {
buildBrief(input: Pick<ContentGenerationContext, "run" | "idea" | "strategy" | "evidence">): Promise<ContentBriefSnapshot>;
write(input: Pick<ContentGenerationContext, "run" | "idea" | "strategy" | "evidence" | "recentBodies"> & { readonly brief: ContentBriefSnapshot }): Promise<ContentDraftSnapshot>;
write(input: Pick<ContentGenerationContext, "run" | "idea" | "strategy" | "evidence" | "recentBodies"> & {
readonly brief: ContentBriefSnapshot;
readonly validationFeedback?: readonly string[];
}): Promise<ContentDraftSnapshot>;
audit(input: Pick<ContentGenerationContext, "run" | "strategy" | "evidence"> & { readonly brief: ContentBriefSnapshot; readonly draft: ContentDraftSnapshot }): Promise<ContentEvidenceAudit>;
critique(input: Pick<ContentGenerationContext, "run" | "idea" | "strategy" | "recentBodies"> & { readonly brief: ContentBriefSnapshot; readonly draft: ContentDraftSnapshot; readonly audit: ContentEvidenceAudit }): Promise<ContentEditorialCritique>;
}
Expand Down Expand Up @@ -130,16 +135,22 @@ export class ContentGenerationJobProcessor {
}
if (stageAtOrBefore(context.run.stage, "writer")) {
if (!context.brief) throw new Error("CONTENT_BRIEF_CHECKPOINT_MISSING");
const draft = await this.agent.write({ ...context, brief: context.brief });
assertGroundedContentDraft(draft, context.evidence.map((item) => item.key));
const draft = await writeGroundedDraft(this.agent, { ...context, brief: context.brief });
await this.repository.saveDraft({ workspaceId: job.workspaceId, runId: payload.runId, draft, now: this.now() });
context = { ...context, draft, run: { ...context.run, stage: "audit" } };
}
if (stageAtOrBefore(context.run.stage, "audit")) {
if (!context.brief || !context.draft) throw new Error("CONTENT_DRAFT_CHECKPOINT_MISSING");
const audit = await this.agent.audit({ ...context, brief: context.brief, draft: context.draft });
let draft = context.draft;
let audit = await this.agent.audit({ ...context, brief: context.brief, draft });
const auditFeedback = repairableAuditFeedback(audit);
if (auditFeedback.length > 0) {
draft = await writeGroundedDraft(this.agent, { ...context, brief: context.brief }, auditFeedback);
await this.repository.reviseDraftAfterAudit({ workspaceId: job.workspaceId, runId: payload.runId, draft, now: this.now() });
audit = await this.agent.audit({ ...context, brief: context.brief, draft });
}
await this.repository.saveAudit({ workspaceId: job.workspaceId, runId: payload.runId, audit, now: this.now() });
context = { ...context, audit, run: { ...context.run, stage: "critic" } };
context = { ...context, draft, audit, run: { ...context.run, stage: "critic" } };
}
if (stageAtOrBefore(context.run.stage, "critic")) {
if (!context.brief || !context.draft || !context.audit) throw new Error("CONTENT_AUDIT_CHECKPOINT_MISSING");
Expand All @@ -157,6 +168,45 @@ export class ContentGenerationJobProcessor {
}
}

async function writeGroundedDraft(
agent: ContentPipelineAgent,
input: Parameters<ContentPipelineAgent["write"]>[0],
initialValidationFeedback: readonly string[] = [],
): Promise<ContentDraftSnapshot> {
const evidenceKeys = input.evidence.map((item) => item.key);
let validationFeedback = initialValidationFeedback;
for (let attempt = 1; attempt <= 2; attempt += 1) {
const draft = await agent.write({ ...input, ...(validationFeedback.length ? { validationFeedback } : {}) });
try {
assertGroundedContentDraft(draft, evidenceKeys);
return draft;
} catch (error) {
if (!isRepairableDraftError(error) || attempt === 2) throw error;
validationFeedback = [error.message];
}
}
throw new Error("CONTENT_DRAFT_REPAIR_EXHAUSTED");
}

function repairableAuditFeedback(audit: ContentEvidenceAudit): readonly string[] {
if (audit.forbiddenTopicMatches.length > 0) return [];
const feedback = [
...audit.ungroundedStatements.map((statement) => `CONTENT_AUDIT_UNGROUNDED_STATEMENT: ${statement}`),
...audit.reviewedClaims
.filter((claim) => claim.verdict !== "supported")
.map((claim) => `CONTENT_AUDIT_UNSUPPORTED_CLAIM: ${claim.statement} — ${claim.reason}`),
];
return feedback.slice(0, 8).map((item) => item.slice(0, 1_000));
}

function isRepairableDraftError(error: unknown): error is Error {
return error instanceof Error && [
"CONTENT_DRAFT_UNRESOLVED_CLAIM",
"CONTENT_DRAFT_CLAIM_NOT_IN_BODY",
"CONTENT_DRAFT_UNSOURCED_NUMBER",
].includes(error.message);
}

function assertBriefGrounded(brief: ContentBriefSnapshot, context: ContentGenerationContext): void {
const evidence = new Set(context.evidence.map((item) => item.key));
const claims = new Set(context.strategy.allowedClaimIds);
Expand Down
1 change: 1 addition & 0 deletions packages/application/src/content/content-ideas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ContentIdeaCandidate, ContentIdeaSourceType, ContentIdeaStatus } f
import { assertGroundedIdeaCandidate } from "@outbound/domain/content/content-idea";

export const CONTENT_IDEA_DISCOVERY_JOB_TYPE = "content.ideas.discover";
export const CONTENT_IDEA_DISCOVERY_JOB_PRIORITY = 60;

export interface ContentIdeaEvidence {
readonly key: string;
Expand Down
1 change: 1 addition & 0 deletions packages/application/src/content/content-publications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SocialProviderError } from "@outbound/application/content/social-ports"
import type { ContentPublicationReconciliationView } from "@outbound/application/content/content-publication-reconciliation";

export const CONTENT_PUBLICATION_JOB_TYPE = "content.publication.publish";
export const CONTENT_PUBLICATION_JOB_PRIORITY = 70;

export type ContentPublicationStatus =
| "scheduled"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function boundedContext(input: Partial<ContentGenerationContext> & Record<string
brief: input.brief,
draft: input.draft,
audit: input.audit,
validationFeedback: input.validationFeedback,
recentBodies: input.recentBodies?.slice(0, 12),
};
}
Expand Down Expand Up @@ -117,6 +118,12 @@ async function invokePipelineModel(input: Parameters<ModelInvoker>[0]) {
"Use the complete offer context, audience, idea, brief, real evidence and recent posts. The post must be specific enough that it cannot be swapped into another company.",
"Open with a concrete tension, observation or consequence. Never use empty thought-leadership hooks, fabricated urgency or generic B2B advice.",
"Every factual statement, number, performance claim or product capability must appear verbatim in factualClaims with exact supplied source keys.",
"Every factualClaims.statement must also be a verbatim contiguous excerpt of body; never paraphrase the ledger separately.",
"If validationFeedback contains CONTENT_DRAFT_UNSOURCED_NUMBER, remove every number absent from evidence or add the exact sourced sentence to factualClaims.",
"If validationFeedback contains CONTENT_DRAFT_CLAIM_NOT_IN_BODY, make each claim statement an exact excerpt of body.",
"If validationFeedback contains CONTENT_DRAFT_UNRESOLVED_CLAIM, use only evidence keys present in the supplied context.",
"If validationFeedback contains CONTENT_AUDIT_UNGROUNDED_STATEMENT, either add the exact factual sentence to factualClaims only when supplied evidence directly proves it, or remove/narrow it. Do not hide a verifiable claim in opinionStatements.",
"If validationFeedback contains CONTENT_AUDIT_UNSUPPORTED_CLAIM, remove or narrow the claim to the exact supplied evidence. Never override or argue with the auditor.",
"Mark personal analysis explicitly in opinionStatements. Do not turn an opinion into a fact.",
"The body is the complete ready-to-review post, including hook and CTA. Do not schedule or publish. Call submit_linkedin_draft exactly once.",
].join("\n"),
Expand All @@ -131,6 +138,7 @@ async function invokePipelineModel(input: Parameters<ModelInvoker>[0]) {
"You are Noosphere's bounded evidence auditor, independent from the writer.",
"Inspect the full draft sentence by sentence. Review every factual claim, number, capability and outcome against the exact supplied evidence excerpts.",
"A source key is not enough: mark unsupported when its excerpt does not prove the wording. Never repair, rewrite or excuse a claim.",
"Conversely, a factual claim that is a faithful verbatim excerpt of an active supplied source must be supported. Never return verdict unsupported with a reason saying the source proves or repeats the statement exactly.",
"List factual statements omitted from the writer's claim ledger as ungroundedStatements. Match forbidden topics exactly and conservatively.",
"Do not schedule or publish. Call submit_evidence_audit exactly once.",
].join("\n"),
Expand All @@ -144,6 +152,8 @@ async function invokePipelineModel(input: Parameters<ModelInvoker>[0]) {
system: [
"You are Noosphere's principal editorial critic, independent from the writer.",
"Reject interchangeable hooks, vague claims, fake intimacy, manufactured urgency, repetition of recent posts and CTA unrelated to the offer or objective.",
"The hook field is metadata copied from the opening of the complete body. Its exact presence at the start of body is required by contract and is not repetition; only flag repeated wording that occurs again later inside body.",
"Populate repeatedConcepts only for excessive or detrimental repetition that must block readiness. A necessary central term used coherently across the post is not a repeatedConcept, even when it appears several times.",
"A blocker means the draft must not become ready. Never rewrite the draft and never weaken an evidence audit.",
"Be demanding but concrete. Advice is allowed only for non-blocking polish. Do not schedule or publish.",
"Call submit_editorial_critique exactly once.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ import {
type StrategyModelInvoker = (input: {
readonly fields: ConstructorParameters<typeof ChatOpenAI>[0];
readonly grounding: EditorialStrategyGrounding;
readonly attempt: number;
readonly validationIssues: readonly string[];
}) => Promise<unknown>;

const promptVersion = "noosphere-editorial-strategy-v2";
const maxStructuredOutputAttempts = 2;

export class LangChainEditorialStrategyGenerator implements EditorialStrategyGenerator {
readonly #configuration: ReturnType<typeof resolveResearchModelConfigurationFromEnvironment>;

Expand All @@ -33,33 +38,77 @@ export class LangChainEditorialStrategyGenerator implements EditorialStrategyGen
const startedAt = performance.now();
const workspacePolicy = await this.modelPolicyReader?.find(input.workspaceId);
const model = workspacePolicy?.researchModels[0] ?? this.#configuration.researchModels[0]!;
const snapshot = editorialStrategySnapshotSchema.parse(await this.invokeModel({
fields: buildChatModelFields(this.#configuration, model, "max"),
grounding: input.grounding,
}));
const promptVersion = "noosphere-editorial-strategy-v1";
const aiRun = await this.aiRunRecorder?.record({
const fields = buildChatModelFields(this.#configuration, model, "max");
const inputHash = new Bun.CryptoHasher("sha256").update(JSON.stringify(input.grounding)).digest("hex");
let validationIssues: readonly string[] = [];

for (let attempt = 1; attempt <= maxStructuredOutputAttempts; attempt += 1) {
let rawOutput: unknown;
try {
rawOutput = await this.invokeModel({ fields, grounding: input.grounding, attempt, validationIssues });
} catch (error) {
if (!isRecoverableStructuredOutputError(error)) throw error;
validationIssues = [error instanceof Error ? error.message : "EDITORIAL_STRATEGY_TOOL_CALL_MISSING"];
if (attempt < maxStructuredOutputAttempts) continue;
await this.recordFailure({ workspaceId: input.workspaceId, model, inputHash, startedAt, validationIssues });
throw new Error("EDITORIAL_STRATEGY_OUTPUT_INVALID");
}

const parsed = editorialStrategySnapshotSchema.safeParse(rawOutput);
if (!parsed.success) {
validationIssues = parsed.error.issues.map((issue) => formatValidationIssue(issue));
if (attempt < maxStructuredOutputAttempts) continue;
await this.recordFailure({ workspaceId: input.workspaceId, model, inputHash, startedAt, validationIssues });
throw new Error("EDITORIAL_STRATEGY_OUTPUT_INVALID");
}

const aiRun = await this.aiRunRecorder?.record({
workspaceId: input.workspaceId,
purpose: "content_strategy",
provider: this.#configuration.provider,
model,
promptVersion,
shadow: false,
inputHash,
output: parsed.data,
status: "completed",
cost: null,
latencyMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
return {
snapshot: parsed.data,
metadata: {
provider: this.#configuration.provider,
model,
promptVersion,
aiRunId: aiRun?.id ?? null,
},
};
}

throw new Error("EDITORIAL_STRATEGY_OUTPUT_INVALID");
}

private async recordFailure(input: {
readonly workspaceId: string;
readonly model: string;
readonly inputHash: string;
readonly startedAt: number;
readonly validationIssues: readonly string[];
}) {
await this.aiRunRecorder?.record({
workspaceId: input.workspaceId,
purpose: "content_strategy",
provider: this.#configuration.provider,
model,
model: input.model,
promptVersion,
shadow: false,
inputHash: new Bun.CryptoHasher("sha256").update(JSON.stringify(input.grounding)).digest("hex"),
output: snapshot,
status: "completed",
inputHash: input.inputHash,
output: { errorCode: "EDITORIAL_STRATEGY_OUTPUT_INVALID", validationIssues: input.validationIssues },
status: "failed",
cost: null,
latencyMs: Math.max(0, Math.round(performance.now() - startedAt)),
latencyMs: Math.max(0, Math.round(performance.now() - input.startedAt)),
});
return {
snapshot,
metadata: {
provider: this.#configuration.provider,
model,
promptVersion,
aiRunId: aiRun?.id ?? null,
},
};
}
}

Expand All @@ -72,6 +121,12 @@ async function invokeStrategyModel(input: Parameters<StrategyModelInvoker>[0]) {
const authorizedClaims = input.grounding.offer.claims.filter((claim) =>
claim.validationStatus === "sourced" || claim.validationStatus === "validated"
);
const retryInstruction = input.validationIssues.length > 0
? `Your previous structured output was rejected (${input.validationIssues.join(", ")}). Return a complete corrected object and do not omit required fields.`
: null;
// Kimi K3 rejects a named tool choice while thinking is enabled. `auto` keeps
// max reasoning available; completeness is enforced by the bounded parse/retry
// loop in the generator instead of by a provider-specific request option.
const response = await new ChatOpenAI(input.fields).bindTools([submit], { tool_choice: "auto" }).invoke([
{
role: "system",
Expand All @@ -82,10 +137,14 @@ async function invokeStrategyModel(input: Parameters<StrategyModelInvoker>[0]) {
"Only IDs listed in authorizedClaims may appear in allowedClaimIds. Hypothesis and invalidated claims are forbidden.",
"Pillars must map a real ICP problem to the offer and name the proof type required before a factual post can be written.",
"Voice traits must be operational. Avoid generic B2B language, empty thought leadership, manufactured urgency and interchangeable hooks.",
"Keep each voice.traits item to 120 characters maximum and each voice.avoid item to 240 characters maximum. Use short imperatives, never paragraph-length style guides.",
"Return 3 to 6 pillars, 2 to 8 voice traits, 1 to 12 avoid rules, and only UUIDs supplied in authorizedClaims for allowedClaimIds.",
"Use linkedin_text as the only format unless the supplied constraints explicitly prove another format is available.",
"Cadence must be sustainable: default to three posts per week in Europe/Paris unless the inputs justify less.",
"Call submit_editorial_strategy exactly once.",
].join("\n"),
retryInstruction,
`Structured output attempt ${input.attempt} of ${maxStructuredOutputAttempts}.`,
].filter(Boolean).join("\n"),
},
{
role: "user",
Expand All @@ -96,3 +155,13 @@ async function invokeStrategyModel(input: Parameters<StrategyModelInvoker>[0]) {
if (!call) throw new Error("EDITORIAL_STRATEGY_TOOL_CALL_MISSING");
return call.args;
}

function isRecoverableStructuredOutputError(error: unknown): boolean {
return error instanceof Error && error.message === "EDITORIAL_STRATEGY_TOOL_CALL_MISSING";
}

function formatValidationIssue(issue: { readonly path: readonly PropertyKey[]; readonly code: string; readonly message: string }): string {
const path = issue.path.map(String).join(".") || "root";
const message = issue.message.replace(/\s+/g, " ").slice(0, 240);
return `${path}:${issue.code}:${message}`;
}
Loading
Loading