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
80 changes: 80 additions & 0 deletions crates/renderflow-core/data/ai/skills/magazine-candidates-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"schema_version": "renderflow.ai-skill/v1",
"id": "skill.magazine.candidates",
"version": "1.0.0",
"purpose": "Propose schema-bound magazine asset-brief and metadata enhancements from sanitized Artifact DNA and a deterministic candidate.",
"artifact_families": ["artifact_dna", "publication", "metadata", "asset_brief"],
"operations": ["generation", "extraction", "schema_constrained_output"],
"input_modalities": ["structured_json"],
"output_modalities": ["structured_json", "metadata"],
"requires_json_schema": true,
"input_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["dna", "deterministic_candidate", "intent"],
"properties": {
"dna": {"type": "string", "minLength": 1, "maxLength": 48000},
"deterministic_candidate": {"type": "string", "minLength": 1, "maxLength": 32000},
"intent": {"type": "string", "minLength": 1, "maxLength": 2000}
},
"additionalProperties": false
},
"output_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["asset_brief", "metadata", "review_required"],
"properties": {
"asset_brief": {
"type": "object",
"required": ["layout_guidance", "palette_guidance", "typography_guidance", "accessibility_guidance", "negative_guidance"],
"properties": {
"layout_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}},
"palette_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}},
"typography_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}},
"accessibility_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}},
"negative_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}}
},
"additionalProperties": false
},
"metadata": {
"type": "object",
"required": ["descriptive_tags", "accessibility_notes"],
"properties": {
"descriptive_tags": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 120}},
"accessibility_notes": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 400}}
},
"additionalProperties": false
},
"review_required": {"const": true}
},
"additionalProperties": false
},
"templates": {
"system": "You propose conservative, provider-neutral magazine asset guidance. Never generate publication copy, story content, artwork, or named-style imitation. Return only strict JSON.",
"instruction": "Use only sanitized evidence. Preserve the deterministic candidate as the baseline, omit creator, brand, franchise, protected-work, and verbatim-content references, and mark the result review_required true.",
"prompt": "Intent: {{intent}}\n\nValidated sanitized Artifact DNA:\n{{dna}}\n\nDeterministic baseline candidate:\n{{deterministic_candidate}}"
},
"variables": [
{"name": "dna", "required": true, "sensitive": false, "max_bytes": 48000},
{"name": "deterministic_candidate", "required": true, "sensitive": false, "max_bytes": 32000},
{"name": "intent", "required": true, "sensitive": false, "max_bytes": 2000}
],
"max_rendered_prompt_bytes": 88000,
"generation": {"temperature": 0.1, "max_tokens": 1800, "seed": 42, "top_p": 0.9},
"budgets": {"network": true, "remote_execution": true, "max_input_bytes": 84000, "max_output_bytes": 24000, "max_tokens": 2000, "max_duration_ms": 60000, "max_retries": 1, "max_cost_microunits": 50000},
"hygiene": {
"policy_id": "policy.ai.magazine-candidates.public-safe/v1",
"scan_secrets": true,
"pii_action": "block",
"protected_reference_action": "rewrite",
"protected_references": [{"term": "Example Franchise", "descriptive_replacement": "source-independent visual characteristics"}],
"allow_private_remote_input": false,
"retain_raw_prompts": false,
"post_output_review": true
},
"approval": {"initial_state": "candidate", "human_review_required": true, "validators": ["validator.json-contract/v1", "validator.ai-hygiene/v1", "validator.prompt-hygiene/v1", "validator.magazine-candidates/v1"]},
"provenance": {"cache_identity_fields": ["skill", "model", "runtime", "input", "schemas", "settings", "hygiene"], "evidence_fields": ["provider", "runtime", "model", "skill", "digests", "usage", "candidate_state", "approvals"], "redact_raw_inputs": true, "redact_raw_prompts": true},
"redistribution_notes": "The specification contains no publication or comic content and its fixture is synthetic.",
"license_notes": "Generated guidance remains a candidate; model and provider licenses require independent review.",
"fixture": {"dna": "{\"schema_version\":\"renderflow.artifact-dna/v1\",\"observations\":[]}", "deterministic_candidate": "{\"asset_brief\":{},\"metadata\":{}}", "intent": "Propose original cover-layout guidance without generating content."}
}
3 changes: 2 additions & 1 deletion crates/renderflow-core/src/ai/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ impl AiSkillRegistry {
pub fn bundled() -> Result<Self> {
let sources = [
include_str!("../../data/ai/skills/metadata-extraction-v1.json"),
include_str!("../../data/ai/skills/magazine-candidates-v1.json"),
include_str!("../../data/ai/skills/visual-dna-v1.json"),
include_str!("../../data/ai/skills/prompt-from-dna-v1.json"),
include_str!("../../data/ai/skills/accessibility-description-v1.json"),
Expand Down Expand Up @@ -647,7 +648,7 @@ mod tests {
#[test]
fn bundled_skills_are_valid_and_candidate_first() {
let registry = AiSkillRegistry::bundled().unwrap();
assert_eq!(registry.iter().count(), 4);
assert_eq!(registry.iter().count(), 5);
assert!(registry.iter().all(|skill| {
skill.approval.initial_state == AiCandidateState::Candidate
&& skill.approval.human_review_required
Expand Down
29 changes: 29 additions & 0 deletions crates/renderflow-core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,35 @@ pub fn run_cli(cli: Cli) -> Result<()> {
EbookCommands::Capabilities { format } => commands::ebook::run_capabilities(&format)?,
},
Some(Commands::Publication { subcommand }) => match subcommand {
PublicationCommands::MagazineCandidates {
config,
asset_role,
output,
format,
ai,
ai_catalog,
ai_preference,
allow_remote,
allow_unverified,
source_approved_for_ai,
privacy_approved_for_remote,
openai_endpoint,
openai_api_key_env,
} => commands::publication::run_magazine_candidates(
&config,
&asset_role,
output.as_deref(),
&format,
ai,
ai_catalog.as_deref(),
&ai_preference,
allow_remote,
allow_unverified,
source_approved_for_ai,
privacy_approved_for_remote,
openai_endpoint.as_deref(),
&openai_api_key_env,
)?,
PublicationCommands::Lulu { subcommand } => match subcommand {
LuluCommands::Rules { format, output } => {
commands::publication::run_lulu_rules(&format, output.as_deref())?
Expand Down
42 changes: 42 additions & 0 deletions crates/renderflow-core/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,48 @@ pub enum VideoCommands {

#[derive(Subcommand)]
pub enum PublicationCommands {
/// Produce candidate-only magazine briefs and metadata from Artifact DNA.
MagazineCandidates {
/// Renderflow v2 publication specification.
#[arg(long, default_value = "renderflow.yaml", value_name = "FILE")]
config: String,
/// Artwork role whose Artifact DNA sidecar should be consumed.
#[arg(long, value_name = "ROLE")]
asset_role: String,
/// Optional candidate output file.
#[arg(long, value_name = "FILE")]
output: Option<String>,
/// Output format: json (default) or yaml.
#[arg(long, default_value = "json", value_name = "FORMAT")]
format: String,
/// Request an optional model-generated candidate through the AI skill runtime.
#[arg(long)]
ai: bool,
/// AI model catalog with observed availability and exact model provenance.
#[arg(long, value_name = "FILE")]
ai_catalog: Option<String>,
/// Provider-neutral model selection preference.
#[arg(long, default_value = "local-only", value_name = "PREFERENCE")]
ai_preference: String,
/// Explicitly permit selection of a remote provider.
#[arg(long)]
allow_remote: bool,
/// Permit selection of catalog entries whose availability is unverified.
#[arg(long)]
allow_unverified: bool,
/// Confirm that source and rights evidence permit model exposure.
#[arg(long)]
source_approved_for_ai: bool,
/// Confirm that privacy review permits remote exposure.
#[arg(long)]
privacy_approved_for_remote: bool,
/// Optional OpenAI-compatible endpoint; used only after remote opt-in.
#[arg(long, value_name = "URL")]
openai_endpoint: Option<String>,
/// Environment variable containing an OpenAI-compatible API key.
#[arg(long, default_value = "OPENAI_API_KEY", value_name = "NAME")]
openai_api_key_env: String,
},
/// Evaluate candidates with the bundled, offline Lulu provider pack.
Lulu {
#[command(subcommand)]
Expand Down
118 changes: 118 additions & 0 deletions crates/renderflow-core/src/commands/publication.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,130 @@
use std::fs;
use std::path::Path;
use std::str::FromStr;

use anyhow::{Context, Result};
use serde::Serialize;

use crate::ai::{
AiExecutionPreferenceV1, AiModelCatalog, AiSkillRegistry, AiSkillRuntime, OllamaProvider,
OpenAiProvider,
};
use crate::artifact::ArtifactStore;
use crate::dna::ArtifactDna;
use crate::publication::lulu::{
evaluate_request, LuluConformanceReport, LuluEligibility, LuluRulePack,
};
use crate::publication::magazine::{
build_magazine_candidates, create_magazine_ai_request, MagazineCandidatePolicy,
};
use crate::spec::load_spec;

#[allow(clippy::too_many_arguments)]
pub fn run_magazine_candidates(
config: &str,
asset_role: &str,
output: Option<&str>,
format: &str,
use_ai: bool,
ai_catalog: Option<&str>,
ai_preference: &str,
allow_remote: bool,
allow_unverified: bool,
source_approved_for_ai: bool,
privacy_approved_for_remote: bool,
openai_endpoint: Option<&str>,
openai_api_key_env: &str,
) -> Result<()> {
if allow_remote && !use_ai {
anyhow::bail!("--allow-remote requires --ai");
}
if privacy_approved_for_remote && !allow_remote {
anyhow::bail!("--privacy-approved-for-remote requires --allow-remote");
}
let loaded = load_spec(config)?;
let publication = loaded
.spec
.publication
.as_ref()
.context("magazine candidates require a publication contract")?;
let asset = publication
.artwork
.iter()
.find(|asset| asset.role == asset_role)
.with_context(|| format!("publication artwork role '{asset_role}' was not found"))?;
let dna_reference = asset.artifact_dna.as_deref().with_context(|| {
format!("publication artwork role '{asset_role}' does not reference artifact_dna")
})?;
let config_directory = Path::new(config).parent().unwrap_or_else(|| Path::new("."));
let dna_path = config_directory.join(dna_reference);
let dna = ArtifactDna::load(&dna_path)?;
let hygiene = loaded
.spec
.execution
.hygiene_policy
.as_deref()
.and_then(|id| loaded.spec.hygiene.get(id));
let policy = MagazineCandidatePolicy {
protected_references: hygiene
.map(|policy| policy.protected_references.terms.clone())
.unwrap_or_default(),
reject_pii: true,
reject_secrets: hygiene
.map(|policy| policy.secrets.enabled && policy.secrets.block)
.unwrap_or(true),
secret_markers: hygiene
.map(|policy| policy.secrets.markers.clone())
.unwrap_or_default(),
};
let mut candidate = build_magazine_candidates(publication, asset, &dna, &policy)?;

if use_ai {
let preference = AiExecutionPreferenceV1::from_str(ai_preference)?;
if preference == AiExecutionPreferenceV1::RemoteOnly && !allow_remote {
anyhow::bail!("remote-only AI preference requires --allow-remote");
}
let catalog = match ai_catalog {
Some(path) => AiModelCatalog::load(path)?,
None => AiModelCatalog::bundled()?,
};
let skills = AiSkillRegistry::bundled()?;
let ollama = OllamaProvider::default_local();
let mut openai = OpenAiProvider::new().with_api_key_env(openai_api_key_env);
if let Some(endpoint) = openai_endpoint {
openai = openai.with_endpoint(endpoint);
}
let runtime = AiSkillRuntime::new(&catalog, &skills, vec![&ollama, &openai]);
let rights_approved = publication.rights.reviewed
&& asset
.approval_reference
.as_deref()
.is_some_and(|reference| !reference.trim().is_empty());
let request = create_magazine_ai_request(
&candidate,
&dna,
&policy,
preference,
allow_remote,
allow_unverified,
source_approved_for_ai && rights_approved,
privacy_approved_for_remote,
)?;
let store_root = std::env::temp_dir()
.join("renderflow")
.join("magazine-ai-candidates");
let store = ArtifactStore::new(store_root)?;
match runtime.execute(&request, &store) {
Ok(outcome) => candidate.attach_ai_outcome(outcome, &store)?,
Err(error) => {
candidate.mark_ai_unavailable();
eprintln!("Optional AI candidate unavailable: {error:#}");
}
}
}

candidate.validate()?;
emit(&candidate, format, output)
}

pub fn run_lulu_rules(format: &str, output: Option<&str>) -> Result<()> {
emit(&LuluRulePack::builtin()?, format, output)
Expand Down
5 changes: 5 additions & 0 deletions crates/renderflow-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ pub use intake::{
IntakeSignalKind, ProvenanceValue, ProviderInspection, ResolvedArtifactProfile,
INTAKE_SCHEMA_V1,
};
pub use publication::magazine::{
build_magazine_candidates, create_magazine_ai_request, MagazineAiCandidate, MagazineAiStatus,
MagazineCandidateEnvelope, MagazineCandidatePolicy, MAGAZINE_CANDIDATE_SCHEMA_V1,
MAGAZINE_CANDIDATE_SKILL_ID_V1,
};
pub use sdk::{
ArtifactProfile, CancellationToken, DiagnosticReport, Engine, EngineBuilder, ExecutionRequest,
ExecutionResult, InspectionRequest, PlanRequest, ProgressEvent, ProgressReporter,
Expand Down
6 changes: 6 additions & 0 deletions crates/renderflow-core/src/publication.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Provider-neutral publication contracts and deterministic release metadata.

pub mod lulu;
pub mod magazine;

use std::collections::BTreeMap;
use std::fs;
Expand Down Expand Up @@ -52,6 +53,11 @@ pub struct PublicationContributor {
pub struct PublicationAsset {
pub role: String,
pub path: String,
/// Optional validated Artifact DNA sidecar used to derive candidate-only
/// visual and layout guidance. The path is relative to the publication
/// specification.
#[serde(default)]
pub artifact_dna: Option<String>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default)]
Expand Down
Loading
Loading