feat: add organization policy center

This commit is contained in:
xz
2026-09-01 13:22:04 +08:00
parent a9c8eea271
commit 1d4154dd20
12 changed files with 1565 additions and 12 deletions
+41 -5
View File
@@ -18,6 +18,7 @@ import { dispatchNotificationEvent } from "./notifications.mjs";
import { registerJobArtifacts } from "./media-artifacts.mjs";
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
import { evaluateGenerationPolicy, recordPolicyEvaluation } from "./organization-policies.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
@@ -375,21 +376,38 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
const kind = String(body.kind || "自定义生成任务").trim();
const knowledge = resolveKnowledgeContextForJob(context, body);
const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null;
const organizationPolicyEvaluation = preflight.organizationPolicy || evaluateGenerationPolicy(context, { body, adapter, routing, shot });
const organizationPolicy = organizationPolicyEvaluation?.effective || null;
const inputs = { ...(body.inputs || {}) };
if (knowledge) {
inputs.knowledgeContext = knowledge.promptContext;
inputs.knowledgeCitations = knowledge.citations;
inputs.knowledgeChunkIds = knowledge.chunkIds;
}
const blockedTerms = styleKitForbiddenTerms(styleKit);
const negativeTerms = styleKitNegativeTerms(styleKit);
const blockedTerms = [...new Set([...styleKitForbiddenTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
const negativeTerms = [...new Set([...styleKitNegativeTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked });
if (organizationPolicyEvaluation?.blockers?.length) {
throw httpError(422, "organization_policy_blocked", "生成请求未通过组织策略", { blockers: organizationPolicyEvaluation.blockers });
}
if (shot && kind.includes("视频") && shot.transitionFromPrevious !== "episode-start" && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame))) {
throw httpError(422, "actual_last_frame_required", "连续视频镜头必须先登记上一段实际末帧作为首帧输入", { shotId: shot.id });
}
const policySnapshot = organizationPolicy ? {
organizationId: organizationPolicy.organizationId,
generatedAt: organizationPolicy.generatedAt,
model: organizationPolicy.model,
generation: organizationPolicy.generation,
voice: organizationPolicy.voice,
delivery: organizationPolicy.delivery,
compliance: organizationPolicy.compliance,
security: organizationPolicy.security,
retention: organizationPolicy.retention
} : null;
const currentCostMode = routing?.connectorCostMode || adapter.costMode || "local";
return {
schema: "ai-drama.job.v1",
createdAt: now(),
@@ -412,6 +430,14 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
preflight,
knowledge,
styleKit,
organizationPolicy: policySnapshot,
policyEvaluation: organizationPolicyEvaluation ? {
result: organizationPolicyEvaluation.result,
blockers: organizationPolicyEvaluation.blockers,
approvals: organizationPolicyEvaluation.approvals,
warnings: organizationPolicyEvaluation.warnings,
checks: organizationPolicyEvaluation.checks
} : null,
shot,
inputs,
constraints: {
@@ -419,15 +445,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
imageOutputCount: 1,
batchSize: 1,
requireActualLastFrame: true,
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
localOnly: currentCostMode === "local" && Boolean(organizationPolicy?.model?.defaultLocalOnly || (routing ? routing.policyMode === "local-only" : adapter.costMode === "local")),
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired || organizationPolicyEvaluation?.approvals?.length),
blockedTerms,
negativePromptTerms: negativeTerms,
styleEnforcement: styleKit?.source || "default-contract",
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
fps: styleKit?.deliverySpec?.fps || 24,
voicePolicy: styleKit?.voicePolicy || null,
voicePolicy: styleKit?.voicePolicy || organizationPolicy?.voice || null,
subtitlePreset: styleKit?.subtitlePreset || null
}
};
@@ -1045,6 +1071,16 @@ export async function createGenerationJob(context, body) {
}
attachModelRouteApprovalToJob(context, approvalGrant, jobId);
});
if (contract.policyEvaluation) {
recordPolicyEvaluation(context, {
...contract.policyEvaluation,
organizationId: context.organization.id,
workspaceId: context.workspace.id,
projectId: context.project.id,
subjectType: "generation_job",
subjectId: jobId
});
}
addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null, knowledgeCitations: contract.knowledge?.citations?.length || 0 } });
addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null } });
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };