diff --git a/packages/application/src/content/content-generation.ts b/packages/application/src/content/content-generation.ts index 65910be..c0dc98a 100644 --- a/packages/application/src/content/content-generation.ts +++ b/packages/application/src/content/content-generation.ts @@ -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; @@ -75,6 +76,7 @@ export interface ContentGenerationRepository { startRun(input: { workspaceId: string; runId: string; now: Date }): Promise; saveBrief(input: { workspaceId: string; runId: string; brief: ContentBriefSnapshot; now: Date }): Promise; saveDraft(input: { workspaceId: string; runId: string; draft: ContentDraftSnapshot; now: Date }): Promise; + reviseDraftAfterAudit(input: { workspaceId: string; runId: string; draft: ContentDraftSnapshot; now: Date }): Promise; saveAudit(input: { workspaceId: string; runId: string; audit: ContentEvidenceAudit; now: Date }): Promise; completeRun(input: { workspaceId: string; runId: string; critique: ContentEditorialCritique; readiness: { ready: boolean; blockers: readonly string[] }; now: Date }): Promise; failRun(input: { workspaceId: string; runId: string; code: string; message: string; now: Date }): Promise; @@ -82,7 +84,10 @@ export interface ContentGenerationRepository { export interface ContentPipelineAgent { buildBrief(input: Pick): Promise; - write(input: Pick & { readonly brief: ContentBriefSnapshot }): Promise; + write(input: Pick & { + readonly brief: ContentBriefSnapshot; + readonly validationFeedback?: readonly string[]; + }): Promise; audit(input: Pick & { readonly brief: ContentBriefSnapshot; readonly draft: ContentDraftSnapshot }): Promise; critique(input: Pick & { readonly brief: ContentBriefSnapshot; readonly draft: ContentDraftSnapshot; readonly audit: ContentEvidenceAudit }): Promise; } @@ -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"); @@ -157,6 +168,45 @@ export class ContentGenerationJobProcessor { } } +async function writeGroundedDraft( + agent: ContentPipelineAgent, + input: Parameters[0], + initialValidationFeedback: readonly string[] = [], +): Promise { + 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); diff --git a/packages/application/src/content/content-ideas.ts b/packages/application/src/content/content-ideas.ts index e73a69d..ff1a1be 100644 --- a/packages/application/src/content/content-ideas.ts +++ b/packages/application/src/content/content-ideas.ts @@ -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; diff --git a/packages/application/src/content/content-publications.ts b/packages/application/src/content/content-publications.ts index f74e1f9..2feb119 100644 --- a/packages/application/src/content/content-publications.ts +++ b/packages/application/src/content/content-publications.ts @@ -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" diff --git a/packages/infrastructure/src/content/langchain-content-pipeline-agent.ts b/packages/infrastructure/src/content/langchain-content-pipeline-agent.ts index 734e1ac..acfbcc5 100644 --- a/packages/infrastructure/src/content/langchain-content-pipeline-agent.ts +++ b/packages/infrastructure/src/content/langchain-content-pipeline-agent.ts @@ -88,6 +88,7 @@ function boundedContext(input: Partial & Record[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"), @@ -131,6 +138,7 @@ async function invokePipelineModel(input: Parameters[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"), @@ -144,6 +152,8 @@ async function invokePipelineModel(input: Parameters[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.", diff --git a/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts b/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts index 06c42bf..72f1baa 100644 --- a/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts +++ b/packages/infrastructure/src/content/langchain-editorial-strategy-generator.ts @@ -15,8 +15,13 @@ import { type StrategyModelInvoker = (input: { readonly fields: ConstructorParameters[0]; readonly grounding: EditorialStrategyGrounding; + readonly attempt: number; + readonly validationIssues: readonly string[]; }) => Promise; +const promptVersion = "noosphere-editorial-strategy-v2"; +const maxStructuredOutputAttempts = 2; + export class LangChainEditorialStrategyGenerator implements EditorialStrategyGenerator { readonly #configuration: ReturnType; @@ -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, - }, - }; } } @@ -72,6 +121,12 @@ async function invokeStrategyModel(input: Parameters[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", @@ -82,10 +137,14 @@ async function invokeStrategyModel(input: Parameters[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", @@ -96,3 +155,13 @@ async function invokeStrategyModel(input: Parameters[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}`; +} diff --git a/packages/infrastructure/src/content/postgres-content-generation-repository.ts b/packages/infrastructure/src/content/postgres-content-generation-repository.ts index 987bd9a..e72e1f6 100644 --- a/packages/infrastructure/src/content/postgres-content-generation-repository.ts +++ b/packages/infrastructure/src/content/postgres-content-generation-repository.ts @@ -6,7 +6,10 @@ import type { ContentGenerationRepository, ContentGenerationRunView, } from "@outbound/application/content/content-generation"; -import { CONTENT_GENERATION_JOB_TYPE } from "@outbound/application/content/content-generation"; +import { + CONTENT_GENERATION_JOB_PRIORITY, + CONTENT_GENERATION_JOB_TYPE, +} from "@outbound/application/content/content-generation"; import type { ContentIdeaEvidence, ContentIdeaView } from "@outbound/application/content/content-ideas"; import type { ContentIdeaStatus } from "@outbound/domain/content/content-idea"; import type { ContentGenerationStage, ContentGenerationStatus } from "@outbound/domain/content/content-asset"; @@ -27,6 +30,7 @@ import { contentIdeaSources, contentIdeas, contentOperationRequests, + contentPublications, editorialStrategyVersions, jobs, outboxEvents, @@ -111,7 +115,7 @@ export class PostgresContentGenerationRepository implements ContentGenerationRep await tx.insert(jobs).values({ id: crypto.randomUUID(), workspaceId: input.workspaceId, type: CONTENT_GENERATION_JOB_TYPE, payload: { runId }, idempotencyKey: `content-generation:${runId}:v1`, correlationId: `content-generation:${runId}`, - maxAttempts: 4, priority: 10, availableAt: input.now, createdAt: input.now, updatedAt: input.now, + maxAttempts: 4, priority: CONTENT_GENERATION_JOB_PRIORITY, availableAt: input.now, createdAt: input.now, updatedAt: input.now, }); await appendEvent(tx, { workspaceId: input.workspaceId, userId: input.userId, runId, eventType: "ContentGenerationScheduled", changes: { ideaId: idea.id, assetId: asset.id, operation: input.operation } }); return toRun(run); @@ -149,15 +153,28 @@ export class PostgresContentGenerationRepository implements ContentGenerationRep const current = rows[0]; if (!current) throw new Error("CONTENT_GENERATION_RUN_NOT_FOUND"); const sourceRows = await this.database.select().from(contentIdeaSources).where(and(eq(contentIdeaSources.workspaceId, input.workspaceId), eq(contentIdeaSources.ideaId, current.idea.id))).orderBy(desc(contentIdeaSources.collectedAt)); - const recent = await this.database.select({ body: contentAssetVersions.body }).from(contentAssetVersions) - .where(eq(contentAssetVersions.workspaceId, input.workspaceId)).orderBy(desc(contentAssetVersions.createdAt)).limit(12); + const recent = await this.database.select({ + body: contentAssetVersions.body, + publishedAt: contentPublications.publishedAt, + }).from(contentAssetVersions) + .innerJoin(contentPublications, and( + eq(contentPublications.workspaceId, contentAssetVersions.workspaceId), + eq(contentPublications.assetVersionId, contentAssetVersions.id), + )) + .where(and( + eq(contentAssetVersions.workspaceId, input.workspaceId), + eq(contentPublications.status, "published"), + )) + .orderBy(desc(contentPublications.publishedAt)) + .limit(24); const evidence = sourceRows.map(toEvidence); + const recentBodies = [...new Set(recent.map((item) => item.body))].slice(0, 12); return { run: toRun(current.run), idea: toIdea(current.idea, evidence), strategy: editorialStrategySnapshotSchema.parse(current.strategy), evidence, - recentBodies: recent.map((item) => item.body), + recentBodies, brief: current.run.briefSnapshot ? contentBriefSnapshotSchema.parse(current.run.briefSnapshot) : null, draft: current.run.draftSnapshot ? contentDraftSnapshotSchema.parse(current.run.draftSnapshot) : null, audit: current.run.auditSnapshot ? contentEvidenceAuditSchema.parse(current.run.auditSnapshot) : null, @@ -191,6 +208,10 @@ export class PostgresContentGenerationRepository implements ContentGenerationRep await this.advance(input.workspaceId, input.runId, "writer", { draftSnapshot: input.draft, stage: "audit", updatedAt: input.now }, "ContentDraftWritten", input.now); } + async reviseDraftAfterAudit(input: Parameters[0]): Promise { + await this.advance(input.workspaceId, input.runId, "audit", { draftSnapshot: input.draft, auditSnapshot: null, updatedAt: input.now }, "ContentDraftRepairedAfterAudit", input.now, "audit"); + } + async saveAudit(input: Parameters[0]): Promise { await this.advance(input.workspaceId, input.runId, "audit", { auditSnapshot: input.audit, stage: "critic", updatedAt: input.now }, "ContentEvidenceAudited", input.now); } @@ -228,13 +249,13 @@ export class PostgresContentGenerationRepository implements ContentGenerationRep }).where(and(eq(contentGenerationRuns.workspaceId, input.workspaceId), eq(contentGenerationRuns.id, input.runId), sql`${contentGenerationRuns.status} in ('queued', 'running')`)); } - private async advance(workspaceId: string, runId: string, expected: ContentGenerationStage, values: Record, eventType: string, now: Date): Promise { + private async advance(workspaceId: string, runId: string, expected: ContentGenerationStage, values: Record, eventType: string, now: Date, resultingStage?: ContentGenerationStage): Promise { await this.database.transaction(async (tx) => { const run = (await tx.select().from(contentGenerationRuns).where(and(eq(contentGenerationRuns.workspaceId, workspaceId), eq(contentGenerationRuns.id, runId))).limit(1).for("update"))[0]; if (!run) throw new Error("CONTENT_GENERATION_RUN_NOT_FOUND"); if (stageAfter(run.stage as ContentGenerationStage, expected)) return; if (run.stage !== expected) throw new Error("CONTENT_GENERATION_STAGE_CONFLICT"); - await tx.update(contentGenerationRuns).set(values).where(and(eq(contentGenerationRuns.workspaceId, workspaceId), eq(contentGenerationRuns.id, runId))); + await tx.update(contentGenerationRuns).set({ ...values, ...(resultingStage ? { stage: resultingStage } : {}) }).where(and(eq(contentGenerationRuns.workspaceId, workspaceId), eq(contentGenerationRuns.id, runId))); await appendEvent(tx, { workspaceId, userId: null, runId, eventType, changes: { at: now.toISOString() } }); }); } diff --git a/packages/infrastructure/src/content/postgres-content-idea-repository.ts b/packages/infrastructure/src/content/postgres-content-idea-repository.ts index 8fec437..97d1357 100644 --- a/packages/infrastructure/src/content/postgres-content-idea-repository.ts +++ b/packages/infrastructure/src/content/postgres-content-idea-repository.ts @@ -6,7 +6,10 @@ import type { ContentIdeaRepository, ContentIdeaView, } from "@outbound/application/content/content-ideas"; -import { CONTENT_IDEA_DISCOVERY_JOB_TYPE } from "@outbound/application/content/content-ideas"; +import { + CONTENT_IDEA_DISCOVERY_JOB_PRIORITY, + CONTENT_IDEA_DISCOVERY_JOB_TYPE, +} from "@outbound/application/content/content-ideas"; import type { ContentIdeaStatus } from "@outbound/domain/content/content-idea"; import { normalizeIdeaConcept } from "@outbound/domain/content/content-idea"; import { editorialStrategySnapshotSchema } from "@outbound/contracts/content"; @@ -125,7 +128,7 @@ export class PostgresContentIdeaRepository implements ContentIdeaRepository { idempotencyKey: `ideas:${runId}:v1`, correlationId: `content-ideas:${runId}`, maxAttempts: 5, - priority: 10, + priority: CONTENT_IDEA_DISCOVERY_JOB_PRIORITY, availableAt: input.now, createdAt: input.now, updatedAt: input.now, diff --git a/packages/infrastructure/src/content/postgres-content-publication-repository.ts b/packages/infrastructure/src/content/postgres-content-publication-repository.ts index ed73718..49d1b7c 100644 --- a/packages/infrastructure/src/content/postgres-content-publication-repository.ts +++ b/packages/infrastructure/src/content/postgres-content-publication-repository.ts @@ -9,7 +9,10 @@ import type { ContentPublicationView, SocialPublishingAccountResolver, } from "@outbound/application/content/content-publications"; -import { CONTENT_PUBLICATION_JOB_TYPE } from "@outbound/application/content/content-publications"; +import { + CONTENT_PUBLICATION_JOB_PRIORITY, + CONTENT_PUBLICATION_JOB_TYPE, +} from "@outbound/application/content/content-publications"; import { textFingerprint, type ContentPublicationReconciliationView } from "@outbound/application/content/content-publication-reconciliation"; import type { Database } from "@outbound/infrastructure/database/client"; import { @@ -146,7 +149,7 @@ export class PostgresContentPublicationRepository implements ContentPublicationR idempotencyKey: `content-publication:${publicationId}:v1`, correlationId: `content-publication:${publicationId}`, maxAttempts: 4, - priority: 10, + priority: CONTENT_PUBLICATION_JOB_PRIORITY, availableAt: input.scheduledFor, createdAt: input.now, updatedAt: input.now, diff --git a/packages/interface/src/http/content-generation-handler.ts b/packages/interface/src/http/content-generation-handler.ts index f7f4981..d064189 100644 --- a/packages/interface/src/http/content-generation-handler.ts +++ b/packages/interface/src/http/content-generation-handler.ts @@ -7,6 +7,7 @@ import { RequestAuthenticationError, WorkspaceAccessDeniedError, WorkspaceContex const uuid = z.string().uuid(); export function isContentGenerationRoute(pathname: string): boolean { + if (pathname === "/api/v1/content/ideas/discover") return false; return /^\/api\/v1\/content\/ideas\/[^/]+(?:\/brief)?$/.test(pathname) || /^\/api\/v1\/content\/assets\/[^/]+\/improve$/.test(pathname) || /^\/api\/v1\/content\/generation-runs\/[^/]+$/.test(pathname); diff --git a/packages/interface/src/http/content-strategy-handler.ts b/packages/interface/src/http/content-strategy-handler.ts index bd8e8f8..1bce151 100644 --- a/packages/interface/src/http/content-strategy-handler.ts +++ b/packages/interface/src/http/content-strategy-handler.ts @@ -56,6 +56,13 @@ export function createContentStrategyHttpHandler(input: { if (code === "EDITORIAL_STRATEGY_ICP_REQUIRED") return problem(409, code, "Publish an ICP before deriving the strategy"); if (code === "EDITORIAL_STRATEGY_NOT_FOUND") return problem(404, code, "No editorial strategy exists for this workspace"); if (code === "EDITORIAL_STRATEGY_UNAUTHORIZED_CLAIM") return problem(422, code, "The strategy references an unauthorized offer claim"); + if (code === "EDITORIAL_STRATEGY_OUTPUT_INVALID") return problem(502, code, "The AI returned an invalid editorial strategy after a bounded retry. Retry without changing your product brief"); + console.error(JSON.stringify({ + event: "content_strategy_http_error", + path: new URL(request.url).pathname, + method: request.method, + error: code || "UNKNOWN_ERROR", + })); return problem(500, "INTERNAL_ERROR", "An unexpected error occurred"); } }; diff --git a/tests/http/content-generation-http.test.ts b/tests/http/content-generation-http.test.ts index 273ac7e..57bc301 100644 --- a/tests/http/content-generation-http.test.ts +++ b/tests/http/content-generation-http.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createContentGenerationHttpHandler } from "@outbound/interface/http/content-generation-handler"; +import { createContentGenerationHttpHandler, isContentGenerationRoute } from "@outbound/interface/http/content-generation-handler"; const workspaceId = "31000000-0000-4000-8000-000000000001"; const userId = "31000000-0000-4000-8000-000000000002"; @@ -7,6 +7,11 @@ const ideaId = "31000000-0000-4000-8000-000000000003"; const assetId = "31000000-0000-4000-8000-000000000004"; describe("Noosphere content generation HTTP", () => { + test("never captures the reserved idea discovery route as an idea identifier", () => { + expect(isContentGenerationRoute("/api/v1/content/ideas/discover")).toBe(false); + expect(isContentGenerationRoute(`/api/v1/content/ideas/${ideaId}`)).toBe(true); + }); + test("derives tenant and user from the session and rejects body impersonation", async () => { const calls: unknown[] = []; const handler = createContentGenerationHttpHandler({ contextResolver: context("operator"), application: { async generate(input: unknown) { calls.push(input); return run(); } } as never }); diff --git a/tests/http/content-strategy-http.test.ts b/tests/http/content-strategy-http.test.ts index b1127f3..ba0d2d0 100644 --- a/tests/http/content-strategy-http.test.ts +++ b/tests/http/content-strategy-http.test.ts @@ -38,6 +38,16 @@ describe("Noosphere content strategy HTTP", () => { expect(response.status).toBe(409); expect((await response.json()).code).toBe("EDITORIAL_STRATEGY_OFFER_REQUIRED"); }); + + test("reports invalid model output as a recoverable upstream failure", async () => { + const handler = createContentStrategyHttpHandler({ + contextResolver: context("owner"), + application: { async derive() { throw new Error("EDITORIAL_STRATEGY_OUTPUT_INVALID"); } } as never, + }); + const response = await handler(request("/api/v1/content/strategy/derive", "POST", { requestKey: "derive:request:invalid-model" })); + expect(response.status).toBe(502); + expect((await response.json()).code).toBe("EDITORIAL_STRATEGY_OUTPUT_INVALID"); + }); }); function context(role: "viewer" | "operator" | "owner") { return { async resolve() { return { workspaceId, userId, role }; } }; } diff --git a/tests/integration/content-generation.test.ts b/tests/integration/content-generation.test.ts index abd4a7a..c682a78 100644 --- a/tests/integration/content-generation.test.ts +++ b/tests/integration/content-generation.test.ts @@ -142,7 +142,9 @@ databaseDescribe("CNT-101 durable content generation", () => { const first = await repository.createGeneration({ workspaceId, userId, ideaId, operation: "asset.generate", requestKey: "content:integration:1", now }); const replay = await repository.createGeneration({ workspaceId, userId, ideaId, operation: "asset.generate", requestKey: "content:integration:1", now }); expect(replay.id).toBe(first.id); - expect((await database.client<{ count: number }[]>`select count(*)::int as count from jobs where workspace_id = ${workspaceId} and type = 'content.asset.generate'`)[0]?.count).toBe(1); + const generationJobs = await database.client<{ count: number; priority: number }[]>`select count(*)::int as count, max(priority)::int as priority from jobs where workspace_id = ${workspaceId} and type = 'content.asset.generate'`; + expect(generationJobs[0]?.count).toBe(1); + expect(generationJobs[0]?.priority).toBe(60); const context = await repository.loadContext({ workspaceId, runId: first.id }); const sourceKey = context.evidence[0]!.key; const brief = { objective: "explain" as const, audience: "Équipes juridiques", problem: "Les preuves sont dispersées dans les dossiers juridiques.", angle: "Relier une recherche documentaire à une décision commerciale.", format: "linkedin_text" as const, evidenceKeys: [sourceKey], allowedClaimIds: [claimId], callToAction: "Comment vérifiez-vous vos preuves ?", constraints: ["Aucun fait sans preuve"] }; @@ -202,14 +204,19 @@ databaseDescribe("CNT-101 durable content generation", () => { now, }); expect(scheduledReplay.id).toBe(scheduled.id); + expect((await database.client<{ priority: number }[]>`select priority from jobs where workspace_id = ${workspaceId} and payload->>'publicationId' = ${scheduled.id}`)[0]?.priority).toBe(70); expect(await publicationRepository.find({ workspaceId: otherWorkspaceId, publicationId: scheduled.id })).toBeNull(); const moved = await publicationRepository.reschedule({ workspaceId, userId, publicationId: scheduled.id, requestKey: "publication:move:1", scheduledFor: new Date(now.getTime() + 1_000), now }); expect(moved.scheduledFor).toEqual(new Date(now.getTime() + 1_000)); const improved = await repository.createGeneration({ workspaceId, userId, assetId: asset!.id, operation: "asset.improve", requestKey: "content:integration:2", instruction: "Un hook plus concret", now: new Date(now.getTime() + 1_000) }); + expect((await repository.loadContext({ workspaceId, runId: improved.id })).recentBodies).toEqual([]); await repository.startRun({ workspaceId, runId: improved.id, now }); await repository.saveBrief({ workspaceId, runId: improved.id, brief, now }); await repository.saveDraft({ workspaceId, runId: improved.id, draft: { ...draft, hook: "Le précédent n’est utile que s’il est retrouvable." }, now }); + const auditRepairedDraft = { ...draft, hook: "Une preuve auditée reste résoluble." }; + await repository.reviseDraftAfterAudit({ workspaceId, runId: improved.id, draft: auditRepairedDraft, now }); + expect((await repository.loadContext({ workspaceId, runId: improved.id })).draft?.hook).toBe(auditRepairedDraft.hook); await repository.saveAudit({ workspaceId, runId: improved.id, audit, now }); await repository.completeRun({ workspaceId, runId: improved.id, critique, readiness: { ready: true, blockers: [] }, now }); expect((await repository.findAssetByIdea({ workspaceId, ideaId }))?.latestVersion).toBe(2); @@ -230,6 +237,7 @@ databaseDescribe("CNT-101 durable content generation", () => { await publicationRepository.claimExecution({ workspaceId, publicationId: publishable.id, currentAccountId: "linkedin-account-fixture", executionToken: publishToken, now }); await publicationRepository.markPublished({ workspaceId, publicationId: publishable.id, executionToken: publishToken, result: { providerPostId: "provider-post-fixture", socialId: "social-fixture", url: "https://www.linkedin.com/feed/update/fixture", publishedAt: now }, now }); expect(await publicationRepository.find({ workspaceId, publicationId: publishable.id })).toMatchObject({ status: "published", providerPostId: "provider-post-fixture", providerUrl: "https://www.linkedin.com/feed/update/fixture" }); + expect((await repository.loadContext({ workspaceId, runId: improved.id })).recentBodies).toContain(draft.body); await expectRejected(() => publicationRepository.markFailed({ workspaceId, publicationId: publishable.id, code: "STALE_WORKER", message: "A stale preflight must not overwrite success", now }), "CONTENT_PUBLICATION_EXECUTION_CONFLICT"); expect((await publicationRepository.find({ workspaceId, publicationId: publishable.id }))?.status).toBe("published"); diff --git a/tests/integration/content-idea-discovery.test.ts b/tests/integration/content-idea-discovery.test.ts index 4b67a3e..b3a1295 100644 --- a/tests/integration/content-idea-discovery.test.ts +++ b/tests/integration/content-idea-discovery.test.ts @@ -87,8 +87,9 @@ databaseDescribe("IDE-101 durable content idea discovery", () => { const first = await repository.createDiscovery({ workspaceId, userId, requestKey: "ideas:integration:1", trigger: "manual", now }); const replay = await repository.createDiscovery({ workspaceId, userId, requestKey: "ideas:integration:1", trigger: "manual", now }); expect(replay.id).toBe(first.id); - const queued = await database.client<{ count: number }[]>`select count(*)::int as count from jobs where workspace_id = ${workspaceId} and type = 'content.ideas.discover'`; + const queued = await database.client<{ count: number; priority: number }[]>`select count(*)::int as count, max(priority)::int as priority from jobs where workspace_id = ${workspaceId} and type = 'content.ideas.discover'`; expect(queued[0]?.count).toBe(1); + expect(queued[0]?.priority).toBe(60); const context = await repository.loadDiscoveryContext({ workspaceId, runId: first.id }); expect(context.strategy.allowedClaimIds).toEqual([claimId]); diff --git a/tests/unit/content-generation.test.ts b/tests/unit/content-generation.test.ts index 62a29a0..8073c70 100644 --- a/tests/unit/content-generation.test.ts +++ b/tests/unit/content-generation.test.ts @@ -28,6 +28,37 @@ describe("CNT-101 grounded content pipeline", () => { expect(readiness.blockers).toContain("generic_language"); }); + test("repairs one deterministically rejected writer draft with explicit feedback", async () => { + const calls: string[] = []; + const feedback: Array = []; + const context = pipelineContext("writer"); + const repository = { + async loadContext() { return context; }, + async startRun() { calls.push("start"); }, + async saveDraft() { calls.push("draft_saved"); }, + async saveAudit() { calls.push("audit_saved"); }, + async completeRun() { calls.push("ready"); }, + async failRun() {}, + } as unknown as ContentGenerationRepository; + const queue = { async acknowledge() { calls.push("ack"); } } as unknown as JobQueue; + let writerAttempt = 0; + const processor = new ContentGenerationJobProcessor(repository, { + async buildBrief() { throw new Error("brief must not replay"); }, + async write(input) { + feedback.push(input.validationFeedback); + writerAttempt += 1; + return writerAttempt === 1 ? { ...draft(), body: `${draft().body} 42% des équipes y arrivent.` } : draft(); + }, + async audit() { calls.push("audit"); return audit(); }, + async critique() { calls.push("critic"); return critique(); }, + }, queue); + + await processor.process(job(context.run.workspaceId, context.run.id)); + + expect(feedback).toEqual([undefined, ["CONTENT_DRAFT_UNSOURCED_NUMBER"]]); + expect(calls).toEqual(["start", "draft_saved", "audit", "audit_saved", "critic", "ready", "ack"]); + }); + test("resumes from the audit checkpoint and acknowledges only after an immutable version is finalized", async () => { const calls: string[] = []; const context = pipelineContext("audit"); @@ -48,12 +79,43 @@ describe("CNT-101 grounded content pipeline", () => { await processor.process(job(context.run.workspaceId, context.run.id)); expect(calls).toEqual(["start", "audit", "audit_saved", "critic", "ready", "ack"]); }); + + test("repairs one audit-rejected draft before the critic sees it", async () => { + const calls: string[] = []; + const feedback: Array = []; + const context = pipelineContext("audit"); + const repository = { + async loadContext() { return context; }, + async startRun() { calls.push("start"); }, + async reviseDraftAfterAudit() { calls.push("draft_repaired"); }, + async saveAudit() { calls.push("audit_saved"); }, + async completeRun(input: { readiness: { ready: boolean } }) { calls.push(input.readiness.ready ? "ready" : "blocked"); }, + async failRun() {}, + } as unknown as ContentGenerationRepository; + const queue = { async acknowledge() { calls.push("ack"); } } as unknown as JobQueue; + let auditAttempt = 0; + const processor = new ContentGenerationJobProcessor(repository, { + async buildBrief() { throw new Error("brief must not replay"); }, + async write(input) { calls.push("writer_repair"); feedback.push(input.validationFeedback); return draft(); }, + async audit() { + calls.push("audit"); + auditAttempt += 1; + return auditAttempt === 1 ? { ...audit(), ungroundedStatements: ["Le hook factuel manque au registre."] } : audit(); + }, + async critique() { calls.push("critic"); return critique(); }, + }, queue); + + await processor.process(job(context.run.workspaceId, context.run.id)); + + expect(feedback).toEqual([["CONTENT_AUDIT_UNGROUNDED_STATEMENT: Le hook factuel manque au registre."]]); + expect(calls).toEqual(["start", "audit", "writer_repair", "draft_repaired", "audit", "audit_saved", "critic", "ready", "ack"]); + }); }); function draft() { return { hook: "Une clause introuvable coûte plus qu’une recherche.", body: "Une clause introuvable coûte plus qu’une recherche. Les équipes juridiques ont besoin d’une preuve résoluble avant de décider. Noosphere relie le contenu aux conversations.", callToAction: "Comment vérifiez-vous vos preuves ?", factualClaims: [{ statement: "Noosphere relie le contenu aux conversations.", sourceKeys: ["proof:1"] }], opinionStatements: ["Une clause introuvable coûte plus qu’une recherche."] }; } function audit() { return { reviewedClaims: [{ statement: "Noosphere relie le contenu aux conversations.", sourceKeys: ["proof:1"], verdict: "supported" as const, reason: "La source le dit explicitement." }], ungroundedStatements: [], forbiddenTopicMatches: [] }; } function critique() { return { genericPhrases: [], repeatedConcepts: [], callToActionAligned: true, distinctFromHistory: true, issues: [], summary: "Texte spécifique, étayé et aligné." }; } function brief() { return { objective: "explain" as const, audience: "Équipes juridiques", problem: "Les preuves sont dispersées dans les dossiers juridiques.", angle: "Relier une recherche documentaire à une décision commerciale.", format: "linkedin_text" as const, evidenceKeys: ["proof:1"], allowedClaimIds: [], callToAction: "Comment vérifiez-vous vos preuves ?", constraints: ["Aucun fait sans preuve"] }; } -function pipelineContext(stage: "audit") { const workspaceId = crypto.randomUUID(); const runId = crypto.randomUUID(); return { run: { id: runId, workspaceId, ideaId: crypto.randomUUID(), assetId: crypto.randomUUID(), assetVersionId: null, status: "running" as const, stage, instruction: null, lastErrorCode: null, lastErrorMessage: null, createdAt: new Date(), completedAt: null }, idea: { id: crypto.randomUUID(), workspaceId, strategyVersionId: crypto.randomUUID(), status: "briefed" as const, angle: "Recherche documentaire prouvée", rationale: "Un angle précis pour les juristes.", audience: "Équipes juridiques", pillar: "Recherche", priority: 90, freshnessUntil: new Date(Date.now() + 60_000), firstSeenAt: new Date(), lastSeenAt: new Date(), sources: [evidence()] }, strategy: { audience: { name: "Équipes juridiques", summary: "Juristes avec des preuves dispersées", awareness: "problem_aware" as const }, pillars: [{ name: "Recherche", promise: "Retrouver les preuves", proofTypes: ["claim"] }, { name: "Sécurité", promise: "Contrôler", proofTypes: ["audit"] }, { name: "Adoption", promise: "Déployer", proofTypes: ["chronologie"] }], voice: { traits: ["direct", "précis"], avoid: ["générique"] }, formats: ["linkedin_text" as const], cadence: { postsPerWeek: 3, preferredDays: [1, 3, 5], timezone: "Europe/Paris" }, callsToAction: ["Comment vérifiez-vous vos preuves ?"], allowedClaimIds: [], forbiddenTopics: [] }, evidence: [evidence()], recentBodies: [], brief: brief(), draft: draft(), audit: null, critique: null }; } +function pipelineContext(stage: "writer" | "audit") { const workspaceId = crypto.randomUUID(); const runId = crypto.randomUUID(); return { run: { id: runId, workspaceId, ideaId: crypto.randomUUID(), assetId: crypto.randomUUID(), assetVersionId: null, status: "running" as const, stage, instruction: null, lastErrorCode: null, lastErrorMessage: null, createdAt: new Date(), completedAt: null }, idea: { id: crypto.randomUUID(), workspaceId, strategyVersionId: crypto.randomUUID(), status: "briefed" as const, angle: "Recherche documentaire prouvée", rationale: "Un angle précis pour les juristes.", audience: "Équipes juridiques", pillar: "Recherche", priority: 90, freshnessUntil: new Date(Date.now() + 60_000), firstSeenAt: new Date(), lastSeenAt: new Date(), sources: [evidence()] }, strategy: { audience: { name: "Équipes juridiques", summary: "Juristes avec des preuves dispersées", awareness: "problem_aware" as const }, pillars: [{ name: "Recherche", promise: "Retrouver les preuves", proofTypes: ["claim"] }, { name: "Sécurité", promise: "Contrôler", proofTypes: ["audit"] }, { name: "Adoption", promise: "Déployer", proofTypes: ["chronologie"] }], voice: { traits: ["direct", "précis"], avoid: ["générique"] }, formats: ["linkedin_text" as const], cadence: { postsPerWeek: 3, preferredDays: [1, 3, 5], timezone: "Europe/Paris" }, callsToAction: ["Comment vérifiez-vous vos preuves ?"], allowedClaimIds: [], forbiddenTopics: [] }, evidence: [evidence()], recentBodies: [], brief: brief(), draft: stage === "audit" ? draft() : null, audit: null, critique: null }; } function evidence() { return { key: "proof:1", type: "public_web" as const, sourceRef: "https://example.com", canonicalUrl: "https://example.com", title: "Preuve", excerpt: "Noosphere relie le contenu aux conversations.", contentHash: "proof", collectedAt: new Date() }; } function job(workspaceId: string, runId: string): LeasedJob { const now = new Date(); return { id: crypto.randomUUID(), workspaceId, type: "content.asset.generate", payload: { runId }, idempotencyKey: "content", correlationId: "content:test", attempts: 1, maxAttempts: 4, availableAt: now, lockedBy: "worker", lockedUntil: new Date(now.getTime() + 60_000) }; } diff --git a/tests/unit/langchain-editorial-strategy-generator.test.ts b/tests/unit/langchain-editorial-strategy-generator.test.ts new file mode 100644 index 0000000..8d1b9d2 --- /dev/null +++ b/tests/unit/langchain-editorial-strategy-generator.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { LangChainEditorialStrategyGenerator } from "@outbound/infrastructure/content/langchain-editorial-strategy-generator"; +import type { EditorialStrategyGrounding } from "@outbound/application/content/editorial-strategy"; + +describe("LangChainEditorialStrategyGenerator", () => { + test("retries one rejected structured output and records only the valid result", async () => { + const invocations: Array<{ attempt: number; validationIssues: readonly string[] }> = []; + const recorded: Array<{ status: string; output: unknown; promptVersion: string }> = []; + const generator = new LangChainEditorialStrategyGenerator( + { AI_PROVIDER: "kimi-code", KIMI_CODE_API_KEY: "test-key" }, + { async find() { return { researchModels: ["k3"], synthesisModels: ["k3"] }; } }, + { async record(input) { recorded.push(input); return { id: "30000000-0000-4000-8000-000000000001" }; } }, + async ({ attempt, validationIssues }) => { + invocations.push({ attempt, validationIssues }); + return attempt === 1 ? {} : snapshot(); + }, + ); + + const result = await generator.generate({ workspaceId: crypto.randomUUID(), grounding: grounding() }); + + expect(invocations).toHaveLength(2); + expect(invocations[0]).toEqual({ attempt: 1, validationIssues: [] }); + expect(invocations[1]!.validationIssues.length).toBeGreaterThan(0); + expect(result.snapshot.pillars).toHaveLength(3); + expect(result.metadata).toMatchObject({ provider: "kimi-code", model: "k3", promptVersion: "noosphere-editorial-strategy-v2" }); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ status: "completed", output: snapshot(), promptVersion: "noosphere-editorial-strategy-v2" }); + }); + + test("fails with a stable error and a sanitized AI run after the bounded retry", async () => { + const recorded: Array<{ status: string; output: unknown }> = []; + const generator = new LangChainEditorialStrategyGenerator( + { AI_PROVIDER: "kimi-code", KIMI_CODE_API_KEY: "test-key" }, + undefined, + { async record(input) { recorded.push(input); return { id: crypto.randomUUID() }; } }, + async () => ({ audience: { name: "incomplete" }, secretModelText: "must-not-be-recorded" }), + ); + + await expect(generator.generate({ workspaceId: crypto.randomUUID(), grounding: grounding() })) + .rejects.toThrow("EDITORIAL_STRATEGY_OUTPUT_INVALID"); + expect(recorded).toHaveLength(1); + expect(recorded[0]!.status).toBe("failed"); + expect(JSON.stringify(recorded[0]!.output)).not.toContain("must-not-be-recorded"); + }); +}); + +function grounding(): EditorialStrategyGrounding { + return { + offer: { + id: crypto.randomUUID(), versionId: crypto.randomUUID(), name: "IgnitionRAG", category: "licence", + valueProposition: "Déployer une IA documentaire isolée pour les connaissances sensibles.", + targetAudience: "Cabinets juridiques et équipes conformité", pricing: {}, commercialRules: {}, constraints: {}, objections: [], + claims: [{ id: "30000000-0000-4000-8000-000000000010", claim: "Déploiement isolé", validationStatus: "sourced", evidenceUri: "https://example.test/proof" }], + }, + icp: { + id: crypto.randomUUID(), versionId: crypto.randomUUID(), name: "Cabinets juridiques", criteria: {}, buyingCommittee: {}, + problems: ["Les preuves sont dispersées"], signals: [], exclusions: [], + }, + }; +} + +function snapshot() { + return { + audience: { name: "Cabinets juridiques", summary: "Équipes qui traitent des connaissances sensibles.", awareness: "problem_aware" as const }, + pillars: [ + { name: "Recherche", promise: "Retrouver une preuve", proofTypes: ["source produit"] }, + { name: "Isolation", promise: "Garder le contrôle", proofTypes: ["architecture"] }, + { name: "Adoption", promise: "Déployer sans rupture", proofTypes: ["retour terrain"] }, + ], + voice: { traits: ["direct", "précis"], avoid: ["générique"] }, + formats: ["linkedin_text" as const], + cadence: { postsPerWeek: 3, preferredDays: [1, 3, 5], timezone: "Europe/Paris" }, + callsToAction: ["Comment retrouvez-vous vos preuves ?"], + allowedClaimIds: ["30000000-0000-4000-8000-000000000010"], + forbiddenTopics: ["chiffres non sourcés"], + }; +}