From 88b993352201d0921561d2daddae993e0ecefba5 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Fri, 11 Sep 2026 16:38:52 -0400 Subject: [PATCH] feat(ai): add model-aware skill runtime --- .../data/ai/fixtures/synthetic-layout.svg | 14 + .../data/ai/model-catalog-v1.json | 140 ++++ .../skills/accessibility-description-v1.json | 24 + .../ai/skills/metadata-extraction-v1.json | 47 ++ .../data/ai/skills/prompt-from-dna-v1.json | 24 + .../data/ai/skills/visual-dna-v1.json | 24 + crates/renderflow-core/src/ai/catalog.rs | 707 ++++++++++++++++ crates/renderflow-core/src/ai/mod.rs | 19 + .../src/ai/providers/ollama.rs | 53 +- .../src/ai/providers/openai.rs | 44 +- crates/renderflow-core/src/ai/request.rs | 36 + crates/renderflow-core/src/ai/runtime.rs | 780 ++++++++++++++++++ crates/renderflow-core/src/ai/skill.rs | 702 ++++++++++++++++ crates/renderflow-core/src/app.rs | 35 +- crates/renderflow-core/src/cli.rs | 86 +- crates/renderflow-core/src/commands/ai.rs | 215 ++++- docs/ai-guide/model-catalog-and-skills.md | 150 ++++ docs/ai-guide/overview.md | 10 +- docs/cli-reference/ai.md | 46 +- docs/user-guide/ai.md | 15 +- mkdocs.yml | 1 + .../renderflow-ai-execution-v1.schema.json | 18 + ...renderflow-ai-model-catalog-v1.schema.json | 72 ++ schemas/renderflow-ai-skill-v1.schema.json | 27 + 24 files changed, 3267 insertions(+), 22 deletions(-) create mode 100644 crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg create mode 100644 crates/renderflow-core/data/ai/model-catalog-v1.json create mode 100644 crates/renderflow-core/data/ai/skills/accessibility-description-v1.json create mode 100644 crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json create mode 100644 crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json create mode 100644 crates/renderflow-core/data/ai/skills/visual-dna-v1.json create mode 100644 crates/renderflow-core/src/ai/catalog.rs create mode 100644 crates/renderflow-core/src/ai/runtime.rs create mode 100644 crates/renderflow-core/src/ai/skill.rs create mode 100644 docs/ai-guide/model-catalog-and-skills.md create mode 100644 schemas/renderflow-ai-execution-v1.schema.json create mode 100644 schemas/renderflow-ai-model-catalog-v1.schema.json create mode 100644 schemas/renderflow-ai-skill-v1.schema.json diff --git a/crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg b/crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg new file mode 100644 index 0000000..02244aa --- /dev/null +++ b/crates/renderflow-core/data/ai/fixtures/synthetic-layout.svg @@ -0,0 +1,14 @@ + + Synthetic geometric layout fixture + Muted rectangles, circles, rules, and generic labels arranged on a grid. + + + SYNTHETIC LAYOUT + + + + + + + Original shapes only — redistribution-safe fixture + diff --git a/crates/renderflow-core/data/ai/model-catalog-v1.json b/crates/renderflow-core/data/ai/model-catalog-v1.json new file mode 100644 index 0000000..fd3df54 --- /dev/null +++ b/crates/renderflow-core/data/ai/model-catalog-v1.json @@ -0,0 +1,140 @@ +{ + "schema_version": "renderflow.ai-model-catalog/v1", + "revision": "2026-09-11.1", + "providers": [ + { + "id": "provider.ollama", + "adapter": "ollama", + "display_name": "Ollama local runtime", + "locality": "local", + "requires_network_permission": false, + "runtime": { + "id": "runtime.ollama", + "source": "https://github.com/ollama/ollama", + "license": "MIT", + "hardware_requirements": ["Model-specific RAM or accelerator capacity"], + "availability_probe": { + "kind": "http", + "bounded_timeout_ms": 1500, + "endpoint": "http://localhost:11434/api/tags" + } + }, + "models": [ + { + "id": "llama3.2", + "family": "llama3.2", + "input_modalities": ["text", "structured_json", "artifact_dna"], + "output_modalities": ["text", "structured_json", "metadata"], + "operations": ["generation", "extraction", "classification", "schema_constrained_output"], + "limits": {"context_tokens": 131072}, + "features": { + "native_json": true, + "json_schema": true, + "seed": true, + "sampler_controls": true, + "streaming": true, + "batch": false, + "tool_use": true, + "multi_input": false + }, + "determinism": "probabilistic", + "license": { + "model": "Llama 3.2 Community License", + "weights": "Llama 3.2 Community License", + "code": "Runtime-specific", + "commercial_use": "conditional", + "human_review_required": true + }, + "commercial_constraints": ["Verify the selected model revision and license before redistribution or commercial use"], + "advisory": {"cost_tier": 1, "quality_tier": 2, "latency_tier": 2, "observed_at": "2026-09-11"}, + "maturity": "experimental", + "conformance": "contract_fixture", + "availability": "unverified", + "availability_reason": "Catalog entry does not imply that model weights are installed locally", + "required_provenance_fields": ["runtime_revision", "runtime_digest", "model_revision", "weights_digest", "settings", "skill_version", "input_digest", "output_digest"] + }, + { + "id": "llava", + "family": "llava", + "input_modalities": ["text", "image", "structured_json", "artifact_dna"], + "output_modalities": ["text", "structured_json", "artifact_dna", "metadata"], + "operations": ["generation", "extraction", "classification", "multimodal_reasoning"], + "limits": {"max_images": 1}, + "features": { + "native_json": true, + "json_schema": false, + "seed": true, + "sampler_controls": true, + "streaming": true, + "batch": false, + "tool_use": false, + "multi_input": true + }, + "determinism": "probabilistic", + "license": { + "model": "Unknown until runtime discovery", + "commercial_use": "unknown", + "human_review_required": true + }, + "commercial_constraints": ["Resolve the exact model revision and upstream licenses before use"], + "advisory": {"cost_tier": 1, "quality_tier": 1, "latency_tier": 3, "observed_at": "2026-09-11"}, + "maturity": "experimental", + "conformance": "declared_only", + "availability": "unverified", + "availability_reason": "Catalog entry does not imply that model weights are installed locally", + "required_provenance_fields": ["runtime_revision", "model_revision", "weights_digest", "settings", "skill_version", "input_digest", "output_digest"] + } + ] + }, + { + "id": "provider.openai-compatible", + "adapter": "openai", + "display_name": "Explicit OpenAI-compatible endpoint", + "locality": "remote", + "requires_network_permission": true, + "runtime": { + "id": "runtime.openai-compatible", + "source": "Configured endpoint", + "license": "Provider-specific terms", + "availability_probe": { + "kind": "credential_and_endpoint", + "bounded_timeout_ms": 1500 + } + }, + "models": [ + { + "id": "gpt-4o-mini", + "family": "gpt-4o-mini", + "input_modalities": ["text", "image", "structured_json"], + "output_modalities": ["text", "structured_json", "artifact_dna", "metadata"], + "operations": ["generation", "editing", "extraction", "classification", "multimodal_reasoning", "schema_constrained_output"], + "limits": {"context_tokens": 128000, "output_tokens": 16384, "max_images": 20}, + "features": { + "native_json": true, + "json_schema": true, + "seed": true, + "sampler_controls": true, + "streaming": true, + "batch": true, + "tool_use": true, + "multi_input": true + }, + "determinism": "probabilistic", + "license": { + "model": "Hosted service; no model-weight license", + "provider_terms": "Endpoint-specific terms", + "commercial_use": "provider_terms_apply", + "human_review_required": true + }, + "commercial_constraints": ["Remote use requires explicit network, privacy, budget, and provider-terms approval"], + "advisory": {"cost_tier": 2, "quality_tier": 3, "latency_tier": 2, "observed_at": "2026-09-11"}, + "maturity": "experimental", + "conformance": "declared_only", + "availability": "unverified", + "availability_reason": "Availability depends on the configured endpoint, credentials, and provider terms", + "required_provenance_fields": ["endpoint_identity", "provider_terms_revision", "model_revision", "settings", "skill_version", "input_digest", "output_digest", "usage", "cost"] + } + ] + } + ] +} diff --git a/crates/renderflow-core/data/ai/skills/accessibility-description-v1.json b/crates/renderflow-core/data/ai/skills/accessibility-description-v1.json new file mode 100644 index 0000000..6d68c67 --- /dev/null +++ b/crates/renderflow-core/data/ai/skills/accessibility-description-v1.json @@ -0,0 +1,24 @@ +{ + "schema_version": "renderflow.ai-skill/v1", + "id": "skill.accessibility.describe-candidate", + "version": "1.0.0", + "purpose": "Draft a concise accessibility description from approved visual evidence for human review.", + "artifact_families": ["image", "publication", "accessibility"], + "operations": ["generation", "multimodal_reasoning"], + "input_modalities": ["text", "image", "structured_json"], + "output_modalities": ["structured_json", "metadata"], + "requires_json_schema": false, + "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["image_digest", "context"], "properties": {"image_digest": {"type": "string", "minLength": 8, "maxLength": 160}, "context": {"type": "string", "maxLength": 2000}}, "additionalProperties": false}, + "output_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["short_description", "long_description", "uncertainties", "review_required"], "properties": {"short_description": {"type": "string", "minLength": 1, "maxLength": 300}, "long_description": {"type": "string", "minLength": 1, "maxLength": 3000}, "uncertainties": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 240}}, "review_required": {"const": true}}, "additionalProperties": false}, + "templates": {"system": "You draft factual accessibility descriptions. Do not identify people, infer sensitive traits, or claim certainty about ambiguous content.", "instruction": "Return strict JSON, list uncertainties, and require human review before publication.", "prompt": "Describe the separately supplied approved image {{image_digest}} using this context: {{context}}"}, + "variables": [{"name": "image_digest", "required": true, "sensitive": false, "max_bytes": 160}, {"name": "context", "required": true, "sensitive": false, "max_bytes": 2000}], + "max_rendered_prompt_bytes": 5000, + "generation": {"temperature": 0.1, "max_tokens": 1500, "seed": 42, "top_p": 0.9}, + "budgets": {"network": false, "remote_execution": false, "max_input_bytes": 25000000, "max_output_bytes": 16384, "max_tokens": 1800, "max_duration_ms": 120000, "max_retries": 1}, + "hygiene": {"policy_id": "policy.ai.accessibility-safe/v1", "scan_secrets": true, "pii_action": "block", "protected_reference_action": "review", "protected_references": [{"term": "Example Franchise"}], "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.accessibility-human-review/v1"]}, + "provenance": {"cache_identity_fields": ["skill", "model", "runtime", "input_artifacts", "schemas", "settings", "hygiene"], "evidence_fields": ["provider", "runtime", "model", "skill", "input_artifacts", "digests", "usage", "candidate_state", "approvals"], "redact_raw_inputs": true, "redact_raw_prompts": true}, + "redistribution_notes": "The bundled fixture is synthetic and does not require a paid API or model download.", + "license_notes": "Accessibility drafts are candidates; factual accuracy and publication rights require human review.", + "fixture": {"image_digest": "sha256:d1c1808924bc17331ac936d83c918e3ee978c2baf0b708aa3cd883743768032f", "context": "The bundled redistribution-safe synthetic-layout.svg fixture."} +} diff --git a/crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json b/crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json new file mode 100644 index 0000000..3f114a3 --- /dev/null +++ b/crates/renderflow-core/data/ai/skills/metadata-extraction-v1.json @@ -0,0 +1,47 @@ +{ + "schema_version": "renderflow.ai-skill/v1", + "id": "skill.metadata.extract", + "version": "1.0.0", + "purpose": "Extract conservative publication metadata from approved text into a strict candidate record.", + "artifact_families": ["document", "publication", "metadata"], + "operations": ["extraction", "schema_constrained_output"], + "input_modalities": ["text", "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": ["source_text"], + "properties": { + "source_text": {"type": "string", "minLength": 1, "maxLength": 65536} + }, + "additionalProperties": false + }, + "output_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["title", "summary", "keywords", "review_required"], + "properties": { + "title": {"type": "string", "minLength": 1, "maxLength": 240}, + "summary": {"type": "string", "minLength": 1, "maxLength": 2000}, + "keywords": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 80}}, + "review_required": {"const": true} + }, + "additionalProperties": false + }, + "templates": { + "system": "You are a conservative publication metadata extractor. Return only JSON matching the supplied output contract.", + "instruction": "Describe only evidence present in the approved source. Do not infer ownership, rights clearance, or legal conclusions.", + "prompt": "Extract a title, summary, and keywords from this approved source. Mark review_required true.\n\n{{source_text}}" + }, + "variables": [{"name": "source_text", "required": true, "sensitive": false, "max_bytes": 65536}], + "max_rendered_prompt_bytes": 70000, + "generation": {"temperature": 0.1, "max_tokens": 1200, "seed": 42, "top_p": 0.9}, + "budgets": {"network": true, "remote_execution": true, "max_input_bytes": 65536, "max_output_bytes": 16384, "max_tokens": 1400, "max_duration_ms": 60000, "max_retries": 1, "max_cost_microunits": 50000}, + "hygiene": {"policy_id": "policy.ai.publication-safe/v1", "scan_secrets": true, "pii_action": "review", "protected_reference_action": "rewrite", "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "genre-specific 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"]}, + "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 and synthetic fixture contain no third-party source material.", + "license_notes": "Model and provider licenses remain independent evidence and require review.", + "fixture": {"source_text": "A small synthetic booklet explains mindful pauses through geometric shapes."} +} diff --git a/crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json b/crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json new file mode 100644 index 0000000..1df8420 --- /dev/null +++ b/crates/renderflow-core/data/ai/skills/prompt-from-dna-v1.json @@ -0,0 +1,24 @@ +{ + "schema_version": "renderflow.ai-skill/v1", + "id": "skill.prompt.from-sanitized-dna", + "version": "1.0.0", + "purpose": "Construct provider-independent prompt guidance from sanitized Artifact DNA.", + "artifact_families": ["artifact_dna", "prompt", "publication"], + "operations": ["generation", "schema_constrained_output"], + "input_modalities": ["artifact_dna", "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", "intent"], "properties": {"dna": {"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": ["prompt", "negative_guidance", "review_required"], "properties": {"prompt": {"type": "string", "minLength": 1, "maxLength": 8000}, "negative_guidance": {"type": "array", "maxItems": 24, "items": {"type": "string", "maxLength": 200}}, "review_required": {"const": true}}, "additionalProperties": false}, + "templates": {"system": "You construct provider-neutral generation guidance from sanitized descriptive properties. Never request imitation of a named creator, artist, brand, or franchise.", "instruction": "Use only the supplied DNA and intent. Return strict JSON and mark review_required true.", "prompt": "Intent: {{intent}}\n\nSanitized Artifact DNA:\n{{dna}}"}, + "variables": [{"name": "dna", "required": true, "sensitive": false, "max_bytes": 32000}, {"name": "intent", "required": true, "sensitive": false, "max_bytes": 2000}], + "max_rendered_prompt_bytes": 38000, + "generation": {"temperature": 0.2, "max_tokens": 1800, "seed": 42, "top_p": 0.9}, + "budgets": {"network": false, "remote_execution": false, "max_input_bytes": 34000, "max_output_bytes": 16384, "max_tokens": 2000, "max_duration_ms": 60000, "max_retries": 1}, + "hygiene": {"policy_id": "policy.ai.protected-reference-safe/v1", "scan_secrets": true, "pii_action": "block", "protected_reference_action": "rewrite", "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "weathered post-industrial design 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"]}, + "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 fixture is synthetic and provider independent.", + "license_notes": "Prompt sanitization reduces leakage risk but is not a legal determination.", + "fixture": {"dna": "Asymmetrical modular panels; muted mineral palette; worn metal texture; condensed geometric headings.", "intent": "Create an original editorial divider page."} +} diff --git a/crates/renderflow-core/data/ai/skills/visual-dna-v1.json b/crates/renderflow-core/data/ai/skills/visual-dna-v1.json new file mode 100644 index 0000000..a336913 --- /dev/null +++ b/crates/renderflow-core/data/ai/skills/visual-dna-v1.json @@ -0,0 +1,24 @@ +{ + "schema_version": "renderflow.ai-skill/v1", + "id": "skill.visual-dna.describe", + "version": "1.0.0", + "purpose": "Describe approved synthetic visual and layout evidence as neutral Visual DNA without imitation labels.", + "artifact_families": ["image", "publication", "artifact_dna"], + "operations": ["extraction", "multimodal_reasoning"], + "input_modalities": ["text", "image", "structured_json"], + "output_modalities": ["structured_json", "artifact_dna"], + "requires_json_schema": false, + "input_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["image_digest", "context"], "properties": {"image_digest": {"type": "string", "minLength": 8, "maxLength": 160}, "context": {"type": "string", "minLength": 1, "maxLength": 2000}}, "additionalProperties": false}, + "output_schema": {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["composition", "palette", "texture", "typography", "review_required"], "properties": {"composition": {"type": "array", "maxItems": 20, "items": {"type": "string", "maxLength": 160}}, "palette": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 80}}, "texture": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 120}}, "typography": {"type": "array", "maxItems": 16, "items": {"type": "string", "maxLength": 120}}, "review_required": {"const": true}}, "additionalProperties": false}, + "templates": {"system": "You describe observable visual and layout properties without naming artists, living creators, brands, franchises, or copyrighted characters.", "instruction": "Return strict JSON. Prefer geometry, spatial relationships, palette behavior, texture, and typographic characteristics. Never claim rights clearance.", "prompt": "Analyze the separately supplied approved image identified by {{image_digest}}. Context: {{context}}. Produce neutral Visual DNA and mark review_required true."}, + "variables": [{"name": "image_digest", "required": true, "sensitive": false, "max_bytes": 160}, {"name": "context", "required": true, "sensitive": false, "max_bytes": 2000}], + "max_rendered_prompt_bytes": 5000, + "generation": {"temperature": 0.1, "max_tokens": 1800, "seed": 42, "top_p": 0.9}, + "budgets": {"network": false, "remote_execution": false, "max_input_bytes": 25000000, "max_output_bytes": 32768, "max_tokens": 2000, "max_duration_ms": 120000, "max_retries": 1}, + "hygiene": {"policy_id": "policy.ai.protected-reference-safe/v1", "scan_secrets": true, "pii_action": "review", "protected_reference_action": "rewrite", "protected_references": [{"term": "Example Franchise", "descriptive_replacement": "weathered post-industrial design 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.visual-dna-review/v1"]}, + "provenance": {"cache_identity_fields": ["skill", "model", "runtime", "input_artifacts", "schemas", "settings", "hygiene"], "evidence_fields": ["provider", "runtime", "model", "skill", "input_artifacts", "digests", "usage", "candidate_state", "approvals"], "redact_raw_inputs": true, "redact_raw_prompts": true}, + "redistribution_notes": "Use only approved inputs. The bundled fixture references a synthetic image digest and contains no model weights.", + "license_notes": "Visual descriptions are not legal clearance; model, source, and output rights require review.", + "fixture": {"image_digest": "sha256:d1c1808924bc17331ac936d83c918e3ee978c2baf0b708aa3cd883743768032f", "context": "The bundled redistribution-safe synthetic-layout.svg fixture."} +} diff --git a/crates/renderflow-core/src/ai/catalog.rs b/crates/renderflow-core/src/ai/catalog.rs new file mode 100644 index 0000000..dc6852d --- /dev/null +++ b/crates/renderflow-core/src/ai/catalog.rs @@ -0,0 +1,707 @@ +//! Versioned provider/model compatibility catalog and deterministic resolver. + +use std::cmp::Ordering; +use std::fmt; +use std::path::Path; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +pub const AI_MODEL_CATALOG_SCHEMA_V1: &str = "renderflow.ai-model-catalog/v1"; +pub const AI_RESOLUTION_SCHEMA_V1: &str = "renderflow.ai-resolution/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiLocality { + Local, + Remote, + Hybrid, +} + +impl fmt::Display for AiLocality { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Local => "local", + Self::Remote => "remote", + Self::Hybrid => "hybrid", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiModality { + Text, + Image, + Audio, + Video, + Document, + StructuredJson, + ArtifactDna, + Embeddings, + Mask, + Metadata, +} + +impl fmt::Display for AiModality { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Text => "text", + Self::Image => "image", + Self::Audio => "audio", + Self::Video => "video", + Self::Document => "document", + Self::StructuredJson => "structured_json", + Self::ArtifactDna => "artifact_dna", + Self::Embeddings => "embeddings", + Self::Mask => "mask", + Self::Metadata => "metadata", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiOperation { + Generation, + Editing, + Extraction, + Classification, + Transcription, + Embeddings, + MultimodalReasoning, + SchemaConstrainedOutput, +} + +impl fmt::Display for AiOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Generation => "generation", + Self::Editing => "editing", + Self::Extraction => "extraction", + Self::Classification => "classification", + Self::Transcription => "transcription", + Self::Embeddings => "embeddings", + Self::MultimodalReasoning => "multimodal_reasoning", + Self::SchemaConstrainedOutput => "schema_constrained_output", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiDeterminism { + ByteDeterministic, + ConfigurationRepeatable, + Probabilistic, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiAvailability { + Available, + Unavailable, + Unverified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiRuntimeDescriptor { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hardware_requirements: Vec, + pub availability_probe: AiAvailabilityProbe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiAvailabilityProbe { + pub kind: String, + pub bounded_timeout_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiModelLimits { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_input_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_images: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_audio_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_video_seconds: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiModelFeatures { + pub native_json: bool, + pub json_schema: bool, + pub seed: bool, + pub sampler_controls: bool, + pub streaming: bool, + pub batch: bool, + pub tool_use: bool, + pub multi_input: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiLicenseEvidence { + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weights: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_terms: Option, + pub commercial_use: String, + pub human_review_required: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiAdvisoryHints { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_tier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality_tier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latency_tier: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiModelCatalogEntry { + pub id: String, + pub family: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quantization: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weights_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub configuration_digest: Option, + pub input_modalities: Vec, + pub output_modalities: Vec, + pub operations: Vec, + #[serde(default)] + pub limits: AiModelLimits, + #[serde(default)] + pub features: AiModelFeatures, + pub determinism: AiDeterminism, + pub license: AiLicenseEvidence, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub commercial_constraints: Vec, + #[serde(default)] + pub advisory: AiAdvisoryHints, + pub maturity: String, + pub conformance: String, + pub availability: AiAvailability, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub availability_reason: Option, + pub required_provenance_fields: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiProviderCatalogEntry { + pub id: String, + pub adapter: String, + pub display_name: String, + pub locality: AiLocality, + pub requires_network_permission: bool, + pub runtime: AiRuntimeDescriptor, + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiModelCatalog { + pub schema_version: String, + pub revision: String, + pub providers: Vec, +} + +impl AiModelCatalog { + pub fn bundled() -> Result { + Self::from_json(include_str!("../../data/ai/model-catalog-v1.json")) + } + + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path) + .with_context(|| format!("failed to read AI model catalog '{}'", path.display()))?; + Self::from_json(&contents) + .with_context(|| format!("invalid AI model catalog '{}'", path.display())) + } + + pub fn from_json(contents: &str) -> Result { + let catalog: Self = serde_json::from_str(contents).context("catalog is not valid JSON")?; + catalog.validate()?; + Ok(catalog) + } + + pub fn validate(&self) -> Result<()> { + if self.schema_version != AI_MODEL_CATALOG_SCHEMA_V1 { + anyhow::bail!( + "unsupported AI model catalog schema '{}'; expected '{}'", + self.schema_version, + AI_MODEL_CATALOG_SCHEMA_V1 + ); + } + if self.revision.trim().is_empty() { + anyhow::bail!("AI model catalog revision must not be empty"); + } + let mut provider_ids = std::collections::BTreeSet::new(); + for provider in &self.providers { + validate_stable_id("provider", &provider.id)?; + if !provider_ids.insert(provider.id.as_str()) { + anyhow::bail!("duplicate AI provider id '{}'", provider.id); + } + if provider.models.is_empty() { + anyhow::bail!( + "AI provider '{}' must declare at least one model", + provider.id + ); + } + let mut model_ids = std::collections::BTreeSet::new(); + for model in &provider.models { + validate_stable_id("model", &model.id)?; + if !model_ids.insert(model.id.as_str()) { + anyhow::bail!( + "duplicate AI model id '{}' for provider '{}'", + model.id, + provider.id + ); + } + if model.input_modalities.is_empty() + || model.output_modalities.is_empty() + || model.operations.is_empty() + { + anyhow::bail!( + "AI model '{}:{}' must declare model-specific modalities and operations", + provider.id, + model.id + ); + } + if model.required_provenance_fields.is_empty() { + anyhow::bail!( + "AI model '{}:{}' must declare required provenance fields", + provider.id, + model.id + ); + } + } + } + Ok(()) + } + + pub fn resolve(&self, request: &AiResolutionRequest) -> AiResolutionReport { + let mut assessments = Vec::new(); + for provider in &self.providers { + for model in &provider.models { + assessments.push(assess(provider, model, request)); + } + } + + let mut compatible: Vec = assessments + .iter() + .enumerate() + .filter_map(|(index, candidate)| candidate.reasons.is_empty().then_some(index)) + .collect(); + compatible.sort_by(|left, right| { + rank_candidate( + &assessments[*left], + &assessments[*right], + request.preference, + ) + }); + + let selected = compatible.first().map(|index| { + let candidate = &mut assessments[*index]; + candidate.status = AiCandidateStatus::Selected; + candidate.reasons.push(AiResolutionReason { + code: "resolver.selected".to_string(), + message: format!("Selected by deterministic '{}' ranking", request.preference), + }); + AiModelSelection { + provider_id: candidate.provider_id.clone(), + adapter: candidate.adapter.clone(), + runtime_id: candidate.runtime_id.clone(), + runtime_revision: candidate.runtime_revision.clone(), + runtime_digest: candidate.runtime_digest.clone(), + model_id: candidate.model_id.clone(), + model_revision: candidate.model_revision.clone(), + weights_digest: candidate.weights_digest.clone(), + locality: candidate.locality, + availability: candidate.availability, + determinism: candidate.determinism, + execution_ready: candidate.availability == AiAvailability::Available, + } + }); + + for index in compatible.into_iter().skip(1) { + assessments[index].status = AiCandidateStatus::Compatible; + assessments[index].reasons.push(AiResolutionReason { + code: "resolver.compatible_not_selected".to_string(), + message: "Compatible candidate ranked below the selected model".to_string(), + }); + } + + AiResolutionReport { + schema_version: AI_RESOLUTION_SCHEMA_V1.to_string(), + catalog_revision: self.revision.clone(), + request: request.clone(), + selected, + candidates: assessments, + } + } +} + +fn validate_stable_id(kind: &str, value: &str) -> Result<()> { + if value.is_empty() + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + { + anyhow::bail!("{kind} id '{value}' must use only ASCII letters, digits, '.', '_' or '-'"); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiExecutionPreferenceV1 { + LocalOnly, + RemoteOnly, + #[default] + LocalPreferred, + LowestCost, + HighestQuality, +} + +impl fmt::Display for AiExecutionPreferenceV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::LocalOnly => "local-only", + Self::RemoteOnly => "remote-only", + Self::LocalPreferred => "local-preferred", + Self::LowestCost => "lowest-cost", + Self::HighestQuality => "highest-quality", + }) + } +} + +impl FromStr for AiExecutionPreferenceV1 { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "local-only" => Ok(Self::LocalOnly), + "remote-only" => Ok(Self::RemoteOnly), + "local-preferred" => Ok(Self::LocalPreferred), + "lowest-cost" => Ok(Self::LowestCost), + "highest-quality" => Ok(Self::HighestQuality), + _ => anyhow::bail!( + "unknown AI execution preference '{value}'; expected local-only, remote-only, local-preferred, lowest-cost, or highest-quality" + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiResolutionRequest { + pub operations: Vec, + pub input_modalities: Vec, + pub output_modalities: Vec, + pub requires_json_schema: bool, + #[serde(default)] + pub preference: AiExecutionPreferenceV1, + #[serde(default)] + pub allow_remote: bool, + #[serde(default)] + pub remote_policy_allows: bool, + #[serde(default)] + pub allow_unverified: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiCandidateStatus { + Selected, + Compatible, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiResolutionReason { + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiCandidateAssessment { + pub provider_id: String, + pub adapter: String, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_digest: Option, + pub model_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weights_digest: Option, + pub locality: AiLocality, + pub availability: AiAvailability, + pub determinism: AiDeterminism, + pub status: AiCandidateStatus, + pub advisory: AiAdvisoryHints, + pub reasons: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiModelSelection { + pub provider_id: String, + pub adapter: String, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_digest: Option, + pub model_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weights_digest: Option, + pub locality: AiLocality, + pub availability: AiAvailability, + pub determinism: AiDeterminism, + pub execution_ready: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiResolutionReport { + pub schema_version: String, + pub catalog_revision: String, + pub request: AiResolutionRequest, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected: Option, + pub candidates: Vec, +} + +fn assess( + provider: &AiProviderCatalogEntry, + model: &AiModelCatalogEntry, + request: &AiResolutionRequest, +) -> AiCandidateAssessment { + let mut reasons = Vec::new(); + let remote = provider.locality == AiLocality::Remote; + if remote && !request.allow_remote { + reasons.push(reason( + "policy.remote_not_approved", + "Remote execution requires explicit --allow-remote approval", + )); + } + if (remote || provider.requires_network_permission) && !request.remote_policy_allows { + reasons.push(reason( + "policy.skill_remote_forbidden", + "The selected AI skill forbids network or remote execution", + )); + } + match request.preference { + AiExecutionPreferenceV1::LocalOnly if remote => reasons.push(reason( + "policy.local_only", + "Local-only policy forbids this remote model", + )), + AiExecutionPreferenceV1::RemoteOnly if provider.locality == AiLocality::Local => reasons + .push(reason( + "policy.remote_only", + "Remote-only policy excludes this local model", + )), + _ => {} + } + for operation in &request.operations { + if !model.operations.contains(operation) { + reasons.push(reason( + "capability.operation_missing", + &format!("Model does not support operation '{operation}'"), + )); + } + } + for modality in &request.input_modalities { + if !model.input_modalities.contains(modality) { + reasons.push(reason( + "capability.input_modality_missing", + &format!("Model does not accept '{modality}' input"), + )); + } + } + for modality in &request.output_modalities { + if !model.output_modalities.contains(modality) { + reasons.push(reason( + "capability.output_modality_missing", + &format!("Model does not produce '{modality}' output"), + )); + } + } + if request.requires_json_schema && !model.features.json_schema { + reasons.push(reason( + "capability.json_schema_missing", + "Model adapter does not support schema-constrained output", + )); + } + match model.availability { + AiAvailability::Unavailable => reasons.push(reason( + "availability.unavailable", + model + .availability_reason + .as_deref() + .unwrap_or("Model is unavailable"), + )), + AiAvailability::Unverified if !request.allow_unverified => reasons.push(reason( + "availability.unverified", + model + .availability_reason + .as_deref() + .unwrap_or("Model availability has not been verified"), + )), + _ => {} + } + AiCandidateAssessment { + provider_id: provider.id.clone(), + adapter: provider.adapter.clone(), + runtime_id: provider.runtime.id.clone(), + runtime_revision: provider.runtime.revision.clone(), + runtime_digest: provider.runtime.digest.clone(), + model_id: model.id.clone(), + model_revision: model.revision.clone(), + weights_digest: model.weights_digest.clone(), + locality: provider.locality, + availability: model.availability, + determinism: model.determinism, + status: AiCandidateStatus::Rejected, + advisory: model.advisory.clone(), + reasons, + } +} + +fn reason(code: &str, message: &str) -> AiResolutionReason { + AiResolutionReason { + code: code.to_string(), + message: message.to_string(), + } +} + +fn rank_candidate( + left: &AiCandidateAssessment, + right: &AiCandidateAssessment, + preference: AiExecutionPreferenceV1, +) -> Ordering { + let locality_rank = |locality| match locality { + AiLocality::Local => 0_u8, + AiLocality::Hybrid => 1, + AiLocality::Remote => 2, + }; + let ordering = match preference { + AiExecutionPreferenceV1::LowestCost => left + .advisory + .cost_tier + .unwrap_or(u8::MAX) + .cmp(&right.advisory.cost_tier.unwrap_or(u8::MAX)), + AiExecutionPreferenceV1::HighestQuality => right + .advisory + .quality_tier + .unwrap_or(0) + .cmp(&left.advisory.quality_tier.unwrap_or(0)), + AiExecutionPreferenceV1::LocalOnly | AiExecutionPreferenceV1::LocalPreferred => { + locality_rank(left.locality).cmp(&locality_rank(right.locality)) + } + AiExecutionPreferenceV1::RemoteOnly => Ordering::Equal, + }; + ordering + .then(left.provider_id.cmp(&right.provider_id)) + .then(left.model_id.cmp(&right.model_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_catalog_is_valid_and_model_specific() { + let catalog = AiModelCatalog::bundled().unwrap(); + assert_eq!(catalog.schema_version, AI_MODEL_CATALOG_SCHEMA_V1); + assert!(catalog.providers.iter().all(|provider| provider + .models + .iter() + .all(|model| !model.operations.is_empty()))); + } + + #[test] + fn local_only_never_falls_back_to_remote() { + let catalog = AiModelCatalog::bundled().unwrap(); + let report = catalog.resolve(&AiResolutionRequest { + operations: vec![AiOperation::SchemaConstrainedOutput], + input_modalities: vec![AiModality::Text], + output_modalities: vec![AiModality::StructuredJson], + requires_json_schema: true, + preference: AiExecutionPreferenceV1::LocalOnly, + allow_remote: true, + remote_policy_allows: true, + allow_unverified: true, + }); + assert!(report + .candidates + .iter() + .filter(|candidate| candidate.locality == AiLocality::Remote) + .all(|candidate| candidate.status == AiCandidateStatus::Rejected)); + } + + #[test] + fn remote_models_require_explicit_permission() { + let catalog = AiModelCatalog::bundled().unwrap(); + let report = catalog.resolve(&AiResolutionRequest { + operations: vec![AiOperation::Generation], + input_modalities: vec![AiModality::Text], + output_modalities: vec![AiModality::Text], + requires_json_schema: false, + preference: AiExecutionPreferenceV1::RemoteOnly, + allow_remote: false, + remote_policy_allows: true, + allow_unverified: true, + }); + assert!(report.selected.is_none()); + assert!(report.candidates.iter().any(|candidate| candidate + .reasons + .iter() + .any(|reason| reason.code == "policy.remote_not_approved"))); + } +} diff --git a/crates/renderflow-core/src/ai/mod.rs b/crates/renderflow-core/src/ai/mod.rs index 3c3eea3..2ee1554 100644 --- a/crates/renderflow-core/src/ai/mod.rs +++ b/crates/renderflow-core/src/ai/mod.rs @@ -34,19 +34,38 @@ //! println!("{}", response.content); //! ``` +pub mod catalog; pub mod metrics; pub mod output; pub mod provider; pub mod providers; pub mod request; pub mod retry; +pub mod runtime; +pub mod skill; +pub use catalog::{ + AiAvailability, AiCandidateAssessment, AiCandidateStatus, AiDeterminism, + AiExecutionPreferenceV1, AiLocality, AiModality, AiModelCatalog, AiModelCatalogEntry, + AiModelSelection, AiOperation, AiProviderCatalogEntry, AiResolutionReason, AiResolutionReport, + AiResolutionRequest, AI_MODEL_CATALOG_SCHEMA_V1, AI_RESOLUTION_SCHEMA_V1, +}; pub use metrics::{AiExecutionMetrics, SharedMetrics}; pub use output::validate_output; pub use provider::{AiCapabilities, AiCapability, AiExecutionPreference, AiModel, AiProvider}; pub use providers::{OllamaProvider, OpenAiProvider}; pub use request::{AiRequest, AiResponse, GenerationParameters, OutputFormat}; pub use retry::RetryConfig; +pub use runtime::{ + AiExecutionEvidence, AiHygieneEvidence, AiHygieneFindingEvidence, AiHygieneStatus, + AiInputArtifactEvidence, AiSkillExecutionOutcome, AiSkillExecutionRequest, AiSkillRuntime, + AI_EXECUTION_EVIDENCE_SCHEMA_V1, +}; +pub use skill::{ + validate_json_instance, validate_json_schema_definition, AiCandidateState, AiHygieneAction, + AiProtectedReferenceRule, AiSkillRegistry, AiSkillSpec, AiSkillValidationResult, + AI_SKILL_SCHEMA_V1, +}; // ── compute_ai_cache_key ────────────────────────────────────────────────────── diff --git a/crates/renderflow-core/src/ai/providers/ollama.rs b/crates/renderflow-core/src/ai/providers/ollama.rs index 1825291..6f59ffa 100644 --- a/crates/renderflow-core/src/ai/providers/ollama.rs +++ b/crates/renderflow-core/src/ai/providers/ollama.rs @@ -5,6 +5,7 @@ //! for local-first execution. use std::fmt; +use std::time::Duration; use anyhow::{Context, Result}; use serde_json::json; @@ -62,11 +63,36 @@ impl OllamaProvider { fn call_api(&self, request: &AiRequest) -> Result { let url = format!("{}/api/generate", self.endpoint.trim_end_matches('/')); - let body = json!({ + let mut body = json!({ "model": request.model, "prompt": request.prompt, "stream": false, }); + let body_object = body.as_object_mut().expect("Ollama request is an object"); + if let Some(schema) = &request.output_schema { + body_object.insert("format".to_string(), schema.clone()); + } else if request.output_format == Some(crate::ai::OutputFormat::Json) { + body_object.insert("format".to_string(), json!("json")); + } + let mut options = serde_json::Map::new(); + if let Some(temperature) = request.params.temperature { + options.insert("temperature".to_string(), json!(temperature)); + } + if let Some(max_tokens) = request.params.max_tokens { + options.insert("num_predict".to_string(), json!(max_tokens)); + } + if let Some(seed) = request.params.seed { + options.insert("seed".to_string(), json!(seed)); + } + if let Some(top_p) = request.params.top_p { + options.insert("top_p".to_string(), json!(top_p)); + } + if !request.params.stop.is_empty() { + options.insert("stop".to_string(), json!(request.params.stop)); + } + if !options.is_empty() { + body_object.insert("options".to_string(), serde_json::Value::Object(options)); + } debug!( provider = "ollama", @@ -75,7 +101,13 @@ impl OllamaProvider { "Sending request to Ollama" ); - let body_str = ureq::post(&url) + let mut agent_builder = ureq::AgentBuilder::new(); + if let Some(timeout_ms) = request.timeout_ms { + agent_builder = agent_builder.timeout(Duration::from_millis(timeout_ms)); + } + let agent = agent_builder.build(); + let body_str = agent + .post(&url) .set("Content-Type", "application/json") .send_json(body) .with_context(|| format!("Failed to POST to Ollama endpoint '{}'", url))? @@ -157,13 +189,18 @@ impl AiProvider for OllamaProvider { /// the connectivity problem. pub fn check_ollama_connectivity(endpoint: &str) -> Result<()> { let url = format!("{}/api/tags", endpoint.trim_end_matches('/')); - ureq::get(&url).call().with_context(|| { - format!( - "Ollama server is not reachable at '{}'. \ + ureq::AgentBuilder::new() + .timeout(Duration::from_millis(1_500)) + .build() + .get(&url) + .call() + .with_context(|| { + format!( + "Ollama server is not reachable at '{}'. \ Ensure Ollama is running: `ollama serve`", - endpoint - ) - })?; + endpoint + ) + })?; Ok(()) } diff --git a/crates/renderflow-core/src/ai/providers/openai.rs b/crates/renderflow-core/src/ai/providers/openai.rs index 8c2a2b1..2cacf1a 100644 --- a/crates/renderflow-core/src/ai/providers/openai.rs +++ b/crates/renderflow-core/src/ai/providers/openai.rs @@ -13,6 +13,7 @@ //! variables. Keys are **never logged** at any log level. use std::fmt; +use std::time::Duration; use anyhow::{Context, Result}; use serde_json::json; @@ -163,12 +164,51 @@ impl OpenAiProvider { "{}/v1/chat/completions", self.endpoint.trim_end_matches('/') ); - let body = json!({ + let mut body = json!({ "model": request.model, "messages": [{"role": "user", "content": request.prompt}], }); + let body_object = body.as_object_mut().expect("OpenAI request is an object"); + if let Some(temperature) = request.params.temperature { + body_object.insert("temperature".to_string(), json!(temperature)); + } + if let Some(max_tokens) = request.params.max_tokens { + body_object.insert("max_tokens".to_string(), json!(max_tokens)); + } + if let Some(seed) = request.params.seed { + body_object.insert("seed".to_string(), json!(seed)); + } + if let Some(top_p) = request.params.top_p { + body_object.insert("top_p".to_string(), json!(top_p)); + } + if !request.params.stop.is_empty() { + body_object.insert("stop".to_string(), json!(request.params.stop)); + } + if let Some(schema) = &request.output_schema { + body_object.insert( + "response_format".to_string(), + json!({ + "type": "json_schema", + "json_schema": { + "name": "renderflow_skill_output", + "strict": true, + "schema": schema, + } + }), + ); + } else if request.output_format == Some(crate::ai::OutputFormat::Json) { + body_object.insert( + "response_format".to_string(), + json!({"type": "json_object"}), + ); + } - let mut req = ureq::post(&url).set("Content-Type", "application/json"); + let mut agent_builder = ureq::AgentBuilder::new(); + if let Some(timeout_ms) = request.timeout_ms { + agent_builder = agent_builder.timeout(Duration::from_millis(timeout_ms)); + } + let agent = agent_builder.build(); + let mut req = agent.post(&url).set("Content-Type", "application/json"); if let Some(key) = self.resolve_api_key()? { req = req.set("Authorization", &format!("Bearer {}", key)); } diff --git a/crates/renderflow-core/src/ai/request.rs b/crates/renderflow-core/src/ai/request.rs index d021f9f..6f4251c 100644 --- a/crates/renderflow-core/src/ai/request.rs +++ b/crates/renderflow-core/src/ai/request.rs @@ -3,6 +3,8 @@ use std::fmt; +use serde_json::Value; + // ── OutputFormat ────────────────────────────────────────────────────────────── /// The structured output format a transform expects from the AI backend. @@ -50,6 +52,11 @@ pub struct GenerationParameters { pub temperature: Option, /// Maximum number of tokens to generate. pub max_tokens: Option, + /// Optional random seed. Supporting providers use it as a repeatability + /// control; it never upgrades a probabilistic model to byte-deterministic. + pub seed: Option, + /// Optional nucleus-sampling probability. + pub top_p: Option, /// Optional stop sequences that terminate generation early. pub stop: Vec, } @@ -67,6 +74,12 @@ impl GenerationParameters { if let Some(m) = self.max_tokens { parts.push(format!("max_tokens:{}", m)); } + if let Some(seed) = self.seed { + parts.push(format!("seed:{}", seed)); + } + if let Some(top_p) = self.top_p { + parts.push(format!("top_p:{:.6}", top_p)); + } if !self.stop.is_empty() { let mut sorted = self.stop.clone(); sorted.sort(); @@ -88,8 +101,13 @@ pub struct AiRequest { pub prompt: String, /// Optional expected output format used for post-response validation. pub output_format: Option, + /// Optional strict JSON Schema forwarded only by compatible adapters and + /// always revalidated by Renderflow after generation. + pub output_schema: Option, /// Optional generation parameters forwarded to the backend. pub params: GenerationParameters, + /// Optional wall-clock transport timeout in milliseconds. + pub timeout_ms: Option, /// Prompt template version string, included in cache keys so that changing /// the template invalidates existing cached responses. pub prompt_version: Option, @@ -102,7 +120,9 @@ impl AiRequest { model: model.into(), prompt: prompt.into(), output_format: None, + output_schema: None, params: GenerationParameters::default(), + timeout_ms: None, prompt_version: None, } } @@ -114,6 +134,13 @@ impl AiRequest { self } + /// Attach the strict structured-output JSON Schema. + #[must_use] + pub fn with_output_schema(mut self, schema: Value) -> Self { + self.output_schema = Some(schema); + self + } + /// Attach [`GenerationParameters`]. #[must_use] pub fn with_params(mut self, params: GenerationParameters) -> Self { @@ -121,6 +148,13 @@ impl AiRequest { self } + /// Attach a bounded provider transport timeout. + #[must_use] + pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.timeout_ms = Some(timeout_ms); + self + } + /// Attach a prompt template version string. #[must_use] pub fn with_prompt_version(mut self, version: impl Into) -> Self { @@ -204,6 +238,7 @@ mod tests { temperature: Some(0.7), max_tokens: Some(256), stop: vec!["END".to_string()], + ..Default::default() }; let key = p.cache_key_fragment(); assert!(key.contains("temp:")); @@ -232,6 +267,7 @@ mod tests { assert_eq!(r.model, "mistral"); assert_eq!(r.prompt, "hello"); assert!(r.output_format.is_none()); + assert!(r.output_schema.is_none()); assert!(r.prompt_version.is_none()); } diff --git a/crates/renderflow-core/src/ai/runtime.rs b/crates/renderflow-core/src/ai/runtime.rs new file mode 100644 index 0000000..096473c --- /dev/null +++ b/crates/renderflow-core/src/ai/runtime.rs @@ -0,0 +1,780 @@ +//! Artifact-native execution of schema-bound AI skills. + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use super::catalog::{ + AiAvailability, AiExecutionPreferenceV1, AiLocality, AiModelCatalog, AiResolutionReport, +}; +use super::provider::AiProvider; +use super::request::{AiRequest, OutputFormat}; +use super::retry::{execute_with_retry, RetryConfig}; +use super::skill::{ + validate_json_instance, AiCandidateState, AiHygieneAction, AiProtectedReferenceRule, + AiSkillRegistry, AiSkillSpec, +}; +use crate::artifact::{Artifact, ArtifactDescriptor, ArtifactStorageClass, ArtifactStore}; +use crate::graph::Format; + +pub const AI_EXECUTION_EVIDENCE_SCHEMA_V1: &str = "renderflow.ai-execution/v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiInputArtifactEvidence { + pub artifact_id: String, + pub digest: String, + pub media_type: String, + pub approved_for_ai: bool, +} + +#[derive(Debug, Clone)] +pub struct AiSkillExecutionRequest { + pub skill_id: String, + pub skill_version: Option, + pub input: Value, + pub input_artifacts: Vec, + pub preference: AiExecutionPreferenceV1, + pub allow_remote: bool, + pub allow_unverified: bool, + pub source_approved: bool, + pub privacy_approved_for_remote: bool, + pub additional_protected_references: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiHygieneStatus { + Passed, + Rewritten, + ReviewRequired, + Blocked, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiHygieneFindingEvidence { + pub code: String, + pub class: String, + pub action: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiHygieneEvidence { + pub policy_id: String, + pub status: AiHygieneStatus, + pub findings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiDigestEvidence { + pub algorithm: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiIdentityEvidence { + pub provider_id: String, + pub adapter: String, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_digest: Option, + pub model_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weights_digest: Option, + pub locality: AiLocality, + pub endpoint_identity: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiUsageEvidence { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost_microunits: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiExecutionEvidence { + pub schema_version: String, + pub execution_id: String, + pub occurred_at_unix_ms: u128, + pub identity: AiIdentityEvidence, + pub skill_id: String, + pub skill_version: String, + pub skill_digest: AiDigestEvidence, + pub input_digest: AiDigestEvidence, + pub input_artifacts: Vec, + pub instruction_digest: AiDigestEvidence, + pub prompt_digest: AiDigestEvidence, + pub input_schema_digest: AiDigestEvidence, + pub output_schema_digest: AiDigestEvidence, + pub settings_digest: AiDigestEvidence, + pub hygiene_policy_digest: AiDigestEvidence, + pub cache_identity: AiDigestEvidence, + pub cache_decision: String, + pub determinism: String, + pub usage: AiUsageEvidence, + pub output_artifact_id: String, + pub output_digest: AiDigestEvidence, + pub output_state: AiCandidateState, + pub approval_required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_reference: Option, + pub validators: Vec, + pub validation_status: String, + pub pre_prompt_hygiene: AiHygieneEvidence, + pub post_output_hygiene: AiHygieneEvidence, + pub raw_inputs_retained: bool, + pub raw_prompts_retained: bool, +} + +#[derive(Debug, Clone)] +pub struct AiSkillExecutionOutcome { + pub artifact: Artifact, + pub evidence: AiExecutionEvidence, + pub resolution: AiResolutionReport, +} + +pub struct AiSkillRuntime<'a> { + catalog: &'a AiModelCatalog, + skills: &'a AiSkillRegistry, + providers: Vec<&'a dyn AiProvider>, +} + +impl<'a> AiSkillRuntime<'a> { + pub fn new( + catalog: &'a AiModelCatalog, + skills: &'a AiSkillRegistry, + providers: Vec<&'a dyn AiProvider>, + ) -> Self { + Self { + catalog, + skills, + providers, + } + } + + pub fn resolve(&self, request: &AiSkillExecutionRequest) -> Result { + let skill = self.skill(request)?; + Ok(self.catalog.resolve(&skill.resolution_request( + request.preference, + request.allow_remote, + request.allow_unverified, + ))) + } + + pub fn execute( + &self, + request: &AiSkillExecutionRequest, + store: &ArtifactStore, + ) -> Result { + let skill = self.skill(request)?; + skill.validate()?; + validate_json_instance(&skill.input_schema, &request.input) + .context("AI skill input failed its declared schema")?; + let input_bytes = serde_json::to_vec(&request.input)?; + if input_bytes.len() > skill.budgets.max_input_bytes { + anyhow::bail!("AI skill input exceeds its declared byte budget"); + } + if !request.source_approved + || request + .input_artifacts + .iter() + .any(|artifact| !artifact.approved_for_ai) + { + anyhow::bail!("AI skill source material is not approved for model exposure"); + } + + let resolution = self.resolve(request)?; + let selected = resolution + .selected + .as_ref() + .context("no provider/model satisfies the AI skill and active policy")?; + if selected.availability != AiAvailability::Available { + anyhow::bail!( + "selected model '{}:{}' is not execution-ready; inspect structured resolution evidence", + selected.provider_id, + selected.model_id + ); + } + if selected.locality == AiLocality::Remote { + if !request.allow_remote || !skill.budgets.network || !skill.budgets.remote_execution { + anyhow::bail!( + "remote execution is not explicitly allowed by both request and skill" + ); + } + if contains_pii(&input_bytes) && !request.privacy_approved_for_remote { + anyhow::bail!( + "remote execution of detected PII requires explicit privacy approval" + ); + } + if !skill.hygiene.allow_private_remote_input + && skill.variables.iter().any(|variable| variable.sensitive) + { + anyhow::bail!("this skill forbids sensitive variables from remote execution"); + } + } + + let mut protected_references = skill.hygiene.protected_references.clone(); + for rule in &request.additional_protected_references { + if rule.term.trim().is_empty() || !rule.term.is_ascii() { + anyhow::bail!( + "additional protected-reference terms must be non-empty ASCII strings" + ); + } + if skill.hygiene.protected_reference_action == AiHygieneAction::Rewrite + && rule + .descriptive_replacement + .as_deref() + .is_none_or(|replacement| replacement.trim().is_empty()) + { + anyhow::bail!( + "additional protected-reference rewrites require descriptive replacements" + ); + } + } + protected_references.extend(request.additional_protected_references.clone()); + let (rendered_prompt, pre_prompt_hygiene) = + render_and_sanitize_prompt(skill, &request.input, &protected_references)?; + ensure_not_blocked(&pre_prompt_hygiene, "pre-prompt")?; + + let provider = self + .providers + .iter() + .find(|provider| provider.name() == selected.adapter) + .with_context(|| { + format!( + "resolved adapter '{}' is not configured in this runtime", + selected.adapter + ) + })?; + let max_attempts = skill.budgets.max_retries.saturating_add(1); + let per_attempt_timeout_ms = skill.budgets.max_duration_ms / u64::from(max_attempts); + let mut ai_request = AiRequest::new(&selected.model_id, &rendered_prompt) + .with_output_format(OutputFormat::Json) + .with_params(skill.generation.provider_neutral_parameters()) + .with_timeout_ms(per_attempt_timeout_ms) + .with_prompt_version(format!("{}@{}", skill.id, skill.version)); + if skill.requires_json_schema { + ai_request = ai_request.with_output_schema(skill.output_schema.clone()); + } + let started = Instant::now(); + let retry_config = RetryConfig { + max_attempts, + initial_delay_ms: 0, + max_delay_ms: 0, + ..RetryConfig::default() + }; + let response = + execute_with_retry(&retry_config, &skill.id, || provider.execute(&ai_request)) + .with_context(|| { + format!( + "AI skill '{}@{}' failed through '{}:{}'", + skill.id, skill.version, selected.provider_id, selected.model_id + ) + })?; + let observed_duration_ms = started.elapsed().as_millis() as u64; + if observed_duration_ms > skill.budgets.max_duration_ms { + anyhow::bail!("AI skill execution exceeded its declared time budget"); + } + if response.provider != selected.adapter || response.model != selected.model_id { + anyhow::bail!("AI provider returned identity that does not match the resolved model"); + } + if response + .output_tokens + .is_some_and(|tokens| tokens > skill.budgets.max_tokens) + { + anyhow::bail!("AI skill output exceeded its declared token budget"); + } + if response.content.len() > skill.budgets.max_output_bytes { + anyhow::bail!("AI skill output exceeds its declared byte budget"); + } + let mut output: Value = serde_json::from_str(&response.content) + .context("AI skill output is not valid structured JSON")?; + let post_output_hygiene = sanitize_json_output(&mut output, skill, &protected_references)?; + ensure_not_blocked(&post_output_hygiene, "post-output")?; + validate_json_instance(&skill.output_schema, &output) + .context("AI skill output failed its declared schema")?; + + let output_bytes = serde_json::to_vec_pretty(&output)?; + let artifact = store.put_bytes( + &output_bytes, + ArtifactDescriptor::for_format(Format::Json, ArtifactStorageClass::Intermediate) + .with_metadata("renderflow.ai.skill_id", skill.id.clone()) + .with_metadata("renderflow.ai.skill_version", skill.version.clone()) + .with_metadata("renderflow.ai.provider_id", selected.provider_id.clone()) + .with_metadata("renderflow.ai.model_id", selected.model_id.clone()) + .with_metadata("renderflow.ai.output_state", "candidate") + .with_metadata("renderflow.ai.review_required", true), + )?; + + let skill_digest = digest_json(skill)?; + let input_digest = digest_bytes(&input_bytes); + let instruction_digest = digest_bytes( + format!( + "{}\n{}", + skill.templates.system, skill.templates.instruction + ) + .as_bytes(), + ); + let prompt_digest = digest_bytes(rendered_prompt.as_bytes()); + let input_schema_digest = digest_json(&skill.input_schema)?; + let output_schema_digest = digest_json(&skill.output_schema)?; + let settings_digest = digest_json(&skill.generation)?; + let hygiene_policy_digest = digest_json(&skill.hygiene)?; + let cache_identity = digest_json(&serde_json::json!({ + "catalog_revision": self.catalog.revision, + "provider": selected.provider_id, + "runtime": selected.runtime_id, + "runtime_revision": selected.runtime_revision, + "runtime_digest": selected.runtime_digest, + "model": selected.model_id, + "model_revision": selected.model_revision, + "weights_digest": selected.weights_digest, + "skill": skill.id, + "skill_version": skill.version, + "skill_digest": skill_digest.value, + "input_digest": input_digest.value, + "input_schema_digest": input_schema_digest.value, + "output_schema_digest": output_schema_digest.value, + "settings_digest": settings_digest.value, + "hygiene_policy_digest": hygiene_policy_digest.value, + }))?; + let execution_id = format!("ai:sha256:{}", cache_identity.value); + let evidence = AiExecutionEvidence { + schema_version: AI_EXECUTION_EVIDENCE_SCHEMA_V1.to_string(), + execution_id, + occurred_at_unix_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + identity: AiIdentityEvidence { + provider_id: selected.provider_id.clone(), + adapter: selected.adapter.clone(), + runtime_id: selected.runtime_id.clone(), + runtime_revision: selected.runtime_revision.clone(), + runtime_digest: selected.runtime_digest.clone(), + model_id: selected.model_id.clone(), + model_revision: selected.model_revision.clone(), + weights_digest: selected.weights_digest.clone(), + locality: selected.locality, + endpoint_identity: selected.runtime_id.clone(), + }, + skill_id: skill.id.clone(), + skill_version: skill.version.clone(), + skill_digest, + input_digest, + input_artifacts: request.input_artifacts.clone(), + instruction_digest, + prompt_digest, + input_schema_digest, + output_schema_digest, + settings_digest, + hygiene_policy_digest, + cache_identity, + cache_decision: "not_requested".to_string(), + determinism: format!("{:?}", selected.determinism).to_lowercase(), + usage: AiUsageEvidence { + input_tokens: response.input_tokens, + output_tokens: response.output_tokens, + duration_ms: Some(response.duration_ms.unwrap_or(observed_duration_ms)), + cost_microunits: None, + }, + output_artifact_id: artifact.id().to_string(), + output_digest: AiDigestEvidence { + algorithm: artifact.digest().algorithm().to_string(), + value: artifact.digest().value().to_string(), + }, + output_state: AiCandidateState::Candidate, + approval_required: true, + approval_reference: None, + validators: skill.approval.validators.clone(), + validation_status: "valid_review_required".to_string(), + pre_prompt_hygiene, + post_output_hygiene, + raw_inputs_retained: false, + raw_prompts_retained: false, + }; + Ok(AiSkillExecutionOutcome { + artifact, + evidence, + resolution, + }) + } + + fn skill(&self, request: &AiSkillExecutionRequest) -> Result<&AiSkillSpec> { + self.skills + .get(&request.skill_id, request.skill_version.as_deref()) + .with_context(|| { + format!( + "AI skill '{}'{} is not registered", + request.skill_id, + request + .skill_version + .as_deref() + .map(|version| format!("@{version}")) + .unwrap_or_default() + ) + }) + } +} + +fn render_and_sanitize_prompt( + skill: &AiSkillSpec, + input: &Value, + protected_references: &[AiProtectedReferenceRule], +) -> Result<(String, AiHygieneEvidence)> { + let input = input + .as_object() + .context("AI skill input must be an object")?; + let mut prompt = format!( + "SYSTEM\n{}\n\nINSTRUCTION\n{}\n\nINPUT\n{}", + skill.templates.system, skill.templates.instruction, skill.templates.prompt + ); + for variable in &skill.variables { + let value = input.get(&variable.name); + if variable.required && value.is_none() { + anyhow::bail!("AI skill variable '{}' is required", variable.name); + } + let rendered = match value { + Some(Value::String(value)) => value.clone(), + Some(value) => serde_json::to_string(value)?, + None => String::new(), + }; + if rendered.len() > variable.max_bytes { + anyhow::bail!( + "AI skill variable '{}' exceeds its byte limit", + variable.name + ); + } + prompt = prompt.replace(&format!("{{{{{}}}}}", variable.name), &rendered); + } + if prompt.contains("{{") || prompt.contains("}}") { + anyhow::bail!("AI skill prompt contains unresolved template variables"); + } + if prompt.len() > skill.max_rendered_prompt_bytes { + anyhow::bail!("rendered AI skill prompt exceeds its byte budget"); + } + sanitize_text(&mut prompt, skill, protected_references, "prompt") + .map(|evidence| (prompt, evidence)) +} + +fn sanitize_json_output( + output: &mut Value, + skill: &AiSkillSpec, + protected_references: &[AiProtectedReferenceRule], +) -> Result { + let mut findings = Vec::new(); + visit_strings(output, &mut |text| { + let evidence = sanitize_text(text, skill, protected_references, "output")?; + findings.extend(evidence.findings); + Ok(()) + })?; + Ok(hygiene_evidence(skill, findings)) +} + +fn visit_strings( + value: &mut Value, + visitor: &mut impl FnMut(&mut String) -> Result<()>, +) -> Result<()> { + match value { + Value::String(text) => visitor(text), + Value::Array(values) => { + for value in values { + visit_strings(value, visitor)?; + } + Ok(()) + } + Value::Object(values) => { + for value in values.values_mut() { + visit_strings(value, visitor)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn sanitize_text( + text: &mut String, + skill: &AiSkillSpec, + protected_references: &[AiProtectedReferenceRule], + stage: &str, +) -> Result { + let mut findings = Vec::new(); + if skill.hygiene.scan_secrets && contains_secret(text.as_bytes()) { + findings.push(AiHygieneFindingEvidence { + code: format!("ai.hygiene.{stage}.secret"), + class: "credential".to_string(), + action: "block".to_string(), + }); + } + if contains_pii(text.as_bytes()) { + findings.push(AiHygieneFindingEvidence { + code: format!("ai.hygiene.{stage}.pii"), + class: "possible_contact_identifier".to_string(), + action: action_name(skill.hygiene.pii_action).to_string(), + }); + } + for rule in protected_references { + if contains_case_insensitive(text, &rule.term) { + let action = skill.hygiene.protected_reference_action; + findings.push(AiHygieneFindingEvidence { + code: format!("ai.hygiene.{stage}.protected_reference"), + class: "configured_protected_reference".to_string(), + action: action_name(action).to_string(), + }); + if action == AiHygieneAction::Rewrite { + let replacement = rule + .descriptive_replacement + .as_deref() + .context("protected-reference rewrite is missing a descriptive replacement")?; + *text = replace_case_insensitive(text, &rule.term, replacement); + } + } + } + Ok(hygiene_evidence(skill, findings)) +} + +fn hygiene_evidence( + skill: &AiSkillSpec, + findings: Vec, +) -> AiHygieneEvidence { + let blocked = findings.iter().any(|finding| finding.action == "block"); + let rewritten = findings.iter().any(|finding| finding.action == "rewrite"); + let review = findings.iter().any(|finding| finding.action == "review") + || (skill.hygiene.post_output_review && !findings.is_empty()); + AiHygieneEvidence { + policy_id: skill.hygiene.policy_id.clone(), + status: if blocked { + AiHygieneStatus::Blocked + } else if review { + AiHygieneStatus::ReviewRequired + } else if rewritten { + AiHygieneStatus::Rewritten + } else { + AiHygieneStatus::Passed + }, + findings, + } +} + +fn ensure_not_blocked(evidence: &AiHygieneEvidence, stage: &str) -> Result<()> { + if evidence.status == AiHygieneStatus::Blocked { + anyhow::bail!("AI {stage} hygiene blocked execution; raw findings were not retained"); + } + Ok(()) +} + +fn action_name(action: AiHygieneAction) -> &'static str { + match action { + AiHygieneAction::Block => "block", + AiHygieneAction::Rewrite => "rewrite", + AiHygieneAction::Review => "review", + } +} + +fn contains_secret(bytes: &[u8]) -> bool { + let text = String::from_utf8_lossy(bytes).to_ascii_lowercase(); + text.contains("-----begin private key-----") + || text.contains("github_pat_") + || text.contains("authorization: bearer ") + || text.contains("api_key=") + || text.contains("password=") + || text.split_whitespace().any(|word| { + word.strip_prefix("sk-") + .is_some_and(|tail| tail.len() >= 20) + }) +} + +fn contains_pii(bytes: &[u8]) -> bool { + String::from_utf8_lossy(bytes) + .split_whitespace() + .any(|word| { + let trimmed = word.trim_matches(|character: char| { + !character.is_ascii_alphanumeric() && !matches!(character, '@' | '.' | '_' | '-') + }); + let mut parts = trimmed.split('@'); + parts.next().is_some_and(|local| !local.is_empty()) + && parts + .next() + .is_some_and(|domain| domain.contains('.') && domain.len() >= 3) + && parts.next().is_none() + }) +} + +fn contains_case_insensitive(haystack: &str, needle: &str) -> bool { + !needle.is_empty() + && haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn replace_case_insensitive(haystack: &str, needle: &str, replacement: &str) -> String { + if needle.is_empty() { + return haystack.to_string(); + } + let mut output = String::new(); + let mut cursor = 0; + while let Some(relative) = haystack.as_bytes()[cursor..] + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) + { + let start = cursor + relative; + output.push_str(&haystack[cursor..start]); + output.push_str(replacement); + cursor = start + needle.len(); + } + output.push_str(&haystack[cursor..]); + output +} + +fn digest_json(value: &impl Serialize) -> Result { + Ok(digest_bytes(&serde_json::to_vec(value)?)) +} + +fn digest_bytes(bytes: &[u8]) -> AiDigestEvidence { + let mut hasher = Sha256::new(); + hasher.update(bytes); + AiDigestEvidence { + algorithm: "sha256".to_string(), + value: format!("{:x}", hasher.finalize()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai::catalog::{AiAvailabilityProbe, AiProviderCatalogEntry, AiRuntimeDescriptor}; + use crate::ai::{AiCapabilities, AiModel, AiResponse}; + + #[derive(Debug)] + struct FixtureProvider; + + impl AiProvider for FixtureProvider { + fn name(&self) -> &str { + "fixture" + } + + fn is_local(&self) -> bool { + true + } + + fn capabilities(&self) -> AiCapabilities { + AiCapabilities::new() + } + + fn models(&self) -> Vec { + vec![AiModel::new("fixture-model", true)] + } + + fn execute(&self, request: &AiRequest) -> Result { + assert!(request.output_schema.is_some()); + assert_eq!(request.params.seed, Some(42)); + assert_eq!(request.timeout_ms, Some(30_000)); + Ok(AiResponse::new( + r#"{"title":"Synthetic booklet","summary":"Mindful geometric pauses.","keywords":["mindfulness"],"review_required":true}"#, + "fixture-model", + "fixture", + )) + } + } + + fn fixture_catalog() -> AiModelCatalog { + let mut catalog = AiModelCatalog::bundled().unwrap(); + let mut model = catalog.providers[0].models[0].clone(); + model.id = "fixture-model".to_string(); + model.availability = AiAvailability::Available; + catalog.providers = vec![AiProviderCatalogEntry { + id: "provider.fixture".to_string(), + adapter: "fixture".to_string(), + display_name: "Fixture".to_string(), + locality: AiLocality::Local, + requires_network_permission: false, + runtime: AiRuntimeDescriptor { + id: "runtime.fixture".to_string(), + revision: Some("1".to_string()), + digest: Some("sha256:fixture".to_string()), + source: None, + license: Some("MIT".to_string()), + hardware_requirements: Vec::new(), + availability_probe: AiAvailabilityProbe { + kind: "fixture".to_string(), + bounded_timeout_ms: 1, + endpoint: None, + }, + }, + models: vec![model], + }]; + catalog + } + + #[test] + fn runtime_creates_validated_candidate_artifact_and_redacted_evidence() { + let catalog = fixture_catalog(); + let skills = AiSkillRegistry::bundled().unwrap(); + let provider = FixtureProvider; + let runtime = AiSkillRuntime::new(&catalog, &skills, vec![&provider]); + let directory = tempfile::tempdir().unwrap(); + let store = ArtifactStore::new(directory.path()).unwrap(); + let outcome = runtime + .execute( + &AiSkillExecutionRequest { + skill_id: "skill.metadata.extract".to_string(), + skill_version: None, + input: serde_json::json!({"source_text": "Synthetic mindful geometry."}), + input_artifacts: Vec::new(), + preference: AiExecutionPreferenceV1::LocalOnly, + allow_remote: false, + allow_unverified: false, + source_approved: true, + privacy_approved_for_remote: false, + additional_protected_references: Vec::new(), + }, + &store, + ) + .unwrap(); + assert_eq!(outcome.evidence.output_state, AiCandidateState::Candidate); + assert!(outcome.evidence.approval_required); + assert!(!outcome.evidence.raw_prompts_retained); + assert!(store.verify(&outcome.artifact).is_ok()); + } + + #[test] + fn protected_reference_is_rewritten_without_leaking_term_into_evidence() { + let skills = AiSkillRegistry::bundled().unwrap(); + let skill = skills.get("skill.prompt.from-sanitized-dna", None).unwrap(); + let (prompt, evidence) = render_and_sanitize_prompt( + skill, + &serde_json::json!({ + "dna": "Example Franchise mood", + "intent": "Original divider" + }), + &skill.hygiene.protected_references, + ) + .unwrap(); + assert!(!prompt.contains("Example Franchise")); + assert!(prompt.contains("weathered post-industrial")); + assert!(!serde_json::to_string(&evidence) + .unwrap() + .contains("Example Franchise")); + } +} diff --git a/crates/renderflow-core/src/ai/skill.rs b/crates/renderflow-core/src/ai/skill.rs new file mode 100644 index 0000000..07dc3a0 --- /dev/null +++ b/crates/renderflow-core/src/ai/skill.rs @@ -0,0 +1,702 @@ +//! Reviewed, versioned AI skill specifications and strict JSON contracts. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::catalog::{AiModality, AiOperation, AiResolutionRequest}; +use super::request::GenerationParameters; + +pub const AI_SKILL_SCHEMA_V1: &str = "renderflow.ai-skill/v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiCandidateState { + Candidate, + Approved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiHygieneAction { + Block, + Rewrite, + Review, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillVariable { + pub name: String, + pub required: bool, + #[serde(default)] + pub sensitive: bool, + pub max_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillTemplates { + pub system: String, + pub instruction: String, + pub prompt: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillGeneration { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stop: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub provider_extensions: BTreeMap, +} + +impl AiSkillGeneration { + pub fn provider_neutral_parameters(&self) -> GenerationParameters { + GenerationParameters { + temperature: self.temperature, + max_tokens: self.max_tokens, + seed: self.seed, + top_p: self.top_p, + stop: self.stop.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillBudgets { + pub network: bool, + pub remote_execution: bool, + pub max_input_bytes: usize, + pub max_output_bytes: usize, + pub max_tokens: u32, + pub max_duration_ms: u64, + pub max_retries: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_microunits: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiProtectedReferenceRule { + pub term: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub descriptive_replacement: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillHygienePolicy { + pub policy_id: String, + pub scan_secrets: bool, + pub pii_action: AiHygieneAction, + pub protected_reference_action: AiHygieneAction, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub protected_references: Vec, + pub allow_private_remote_input: bool, + pub retain_raw_prompts: bool, + pub post_output_review: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillApprovalPolicy { + pub initial_state: AiCandidateState, + pub human_review_required: bool, + pub validators: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillProvenancePolicy { + pub cache_identity_fields: Vec, + pub evidence_fields: Vec, + pub redact_raw_inputs: bool, + pub redact_raw_prompts: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillSpec { + pub schema_version: String, + pub id: String, + pub version: String, + pub purpose: String, + pub artifact_families: Vec, + pub operations: Vec, + pub input_modalities: Vec, + pub output_modalities: Vec, + pub requires_json_schema: bool, + pub input_schema: Value, + pub output_schema: Value, + pub templates: AiSkillTemplates, + pub variables: Vec, + pub max_rendered_prompt_bytes: usize, + pub generation: AiSkillGeneration, + pub budgets: AiSkillBudgets, + pub hygiene: AiSkillHygienePolicy, + pub approval: AiSkillApprovalPolicy, + pub provenance: AiSkillProvenancePolicy, + pub redistribution_notes: String, + pub license_notes: String, + pub fixture: Value, +} + +impl AiSkillSpec { + pub fn from_json(contents: &str) -> Result { + let skill: Self = serde_json::from_str(contents).context("skill is not valid JSON")?; + skill.validate()?; + Ok(skill) + } + + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = std::fs::read_to_string(path) + .with_context(|| format!("failed to read AI skill '{}'", path.display()))?; + Self::from_json(&contents).with_context(|| format!("invalid AI skill '{}'", path.display())) + } + + pub fn validate(&self) -> Result<()> { + if self.schema_version != AI_SKILL_SCHEMA_V1 { + anyhow::bail!( + "unsupported AI skill schema '{}'; expected '{}'", + self.schema_version, + AI_SKILL_SCHEMA_V1 + ); + } + validate_skill_id(&self.id)?; + if self.version.trim().is_empty() || self.purpose.trim().is_empty() { + anyhow::bail!("AI skill version and purpose must not be empty"); + } + if self.operations.is_empty() + || self.input_modalities.is_empty() + || self.output_modalities.is_empty() + { + anyhow::bail!("AI skill must declare operations and typed input/output modalities"); + } + validate_json_schema_definition(&self.input_schema, "input_schema")?; + validate_json_schema_definition(&self.output_schema, "output_schema")?; + require_closed_object_schema(&self.input_schema, "input_schema")?; + require_closed_object_schema(&self.output_schema, "output_schema")?; + validate_json_instance(&self.input_schema, &self.fixture) + .context("synthetic fixture does not satisfy input_schema")?; + if self.max_rendered_prompt_bytes == 0 + || self.budgets.max_input_bytes == 0 + || self.budgets.max_output_bytes == 0 + || self.budgets.max_duration_ms == 0 + { + anyhow::bail!("AI skill byte and time budgets must be greater than zero"); + } + if self.budgets.remote_execution && !self.budgets.network { + anyhow::bail!("AI skill cannot allow remote execution while network use is disabled"); + } + if self.generation.max_tokens.unwrap_or(0) > self.budgets.max_tokens { + anyhow::bail!("generation max_tokens exceeds the skill token budget"); + } + if self.budgets.max_retries > 10 { + anyhow::bail!("AI skill retry budget must not exceed 10 retries"); + } + if self.budgets.max_duration_ms < u64::from(self.budgets.max_retries.saturating_add(1)) { + anyhow::bail!("AI skill time budget must allocate at least 1 ms per attempt"); + } + if self + .generation + .temperature + .is_some_and(|temperature| !(0.0..=2.0).contains(&temperature)) + { + anyhow::bail!("AI skill temperature must be between 0.0 and 2.0"); + } + if self + .generation + .top_p + .is_some_and(|top_p| !(0.0..=1.0).contains(&top_p)) + { + anyhow::bail!("AI skill top_p must be between 0.0 and 1.0"); + } + if self.approval.initial_state == AiCandidateState::Approved + && self.approval.human_review_required + { + anyhow::bail!("a review-required AI skill must initially produce a candidate"); + } + if self.hygiene.retain_raw_prompts || !self.provenance.redact_raw_prompts { + anyhow::bail!("v1 AI skills must redact raw prompts from durable evidence"); + } + + let variable_names: BTreeSet<&str> = self + .variables + .iter() + .map(|variable| variable.name.as_str()) + .collect(); + if variable_names.len() != self.variables.len() { + anyhow::bail!("AI skill variable names must be unique"); + } + for variable in &self.variables { + validate_variable_name(&variable.name)?; + if variable.max_bytes == 0 { + anyhow::bail!( + "AI skill variable '{}' must have a positive byte limit", + variable.name + ); + } + } + let referenced = referenced_variables(&format!( + "{}\n{}\n{}", + self.templates.system, self.templates.instruction, self.templates.prompt + ))?; + for referenced_name in &referenced { + if !variable_names.contains(referenced_name.as_str()) { + anyhow::bail!( + "AI skill template references undeclared variable '{{{{{referenced_name}}}}}'" + ); + } + } + let input_properties = self.input_schema["properties"] + .as_object() + .context("input_schema.properties must be an object")?; + let required_properties: BTreeSet<&str> = self.input_schema["required"] + .as_array() + .context("input_schema.required must be an array")? + .iter() + .filter_map(Value::as_str) + .collect(); + for variable in &self.variables { + if !input_properties.contains_key(&variable.name) { + anyhow::bail!( + "AI skill variable '{}' is missing from input_schema.properties", + variable.name + ); + } + if variable.required != required_properties.contains(variable.name.as_str()) { + anyhow::bail!( + "AI skill variable '{}' required flag disagrees with input_schema.required", + variable.name + ); + } + if variable.required && !referenced.contains(&variable.name) { + anyhow::bail!( + "required AI skill variable '{}' is not used by its templates", + variable.name + ); + } + } + for rule in &self.hygiene.protected_references { + if rule.term.trim().is_empty() { + anyhow::bail!("protected-reference terms must not be empty"); + } + if !rule.term.is_ascii() { + anyhow::bail!("v1 protected-reference terms must be ASCII for stable matching"); + } + if self.hygiene.protected_reference_action == AiHygieneAction::Rewrite + && rule + .descriptive_replacement + .as_deref() + .is_none_or(|replacement| replacement.trim().is_empty()) + { + anyhow::bail!( + "rewrite policy requires a descriptive replacement for every protected reference" + ); + } + } + Ok(()) + } + + pub fn resolution_request( + &self, + preference: super::catalog::AiExecutionPreferenceV1, + allow_remote: bool, + allow_unverified: bool, + ) -> AiResolutionRequest { + AiResolutionRequest { + operations: self.operations.clone(), + input_modalities: self.input_modalities.clone(), + output_modalities: self.output_modalities.clone(), + requires_json_schema: self.requires_json_schema, + preference, + allow_remote, + remote_policy_allows: self.budgets.remote_execution && self.budgets.network, + allow_unverified, + } + } +} + +fn require_closed_object_schema(schema: &Value, path: &str) -> Result<()> { + if schema["type"] != "object" { + anyhow::bail!("{path} must declare type 'object'"); + } + if schema["additionalProperties"] != false { + anyhow::bail!("{path} must set additionalProperties to false"); + } + if !schema["properties"].is_object() { + anyhow::bail!("{path}.properties must be an object"); + } + if !schema["required"].is_array() { + anyhow::bail!("{path}.required must be an array"); + } + Ok(()) +} + +#[derive(Debug, Clone, Default)] +pub struct AiSkillRegistry { + skills: Vec, +} + +impl AiSkillRegistry { + pub fn bundled() -> Result { + let sources = [ + include_str!("../../data/ai/skills/metadata-extraction-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"), + ]; + let mut registry = Self::default(); + for source in sources { + registry.insert(AiSkillSpec::from_json(source)?)?; + } + Ok(registry) + } + + pub fn insert(&mut self, skill: AiSkillSpec) -> Result<()> { + skill.validate()?; + if self + .skills + .iter() + .any(|existing| existing.id == skill.id && existing.version == skill.version) + { + anyhow::bail!("duplicate AI skill '{}@{}'", skill.id, skill.version); + } + self.skills.push(skill); + self.skills + .sort_by(|left, right| (&left.id, &left.version).cmp(&(&right.id, &right.version))); + Ok(()) + } + + pub fn get(&self, id: &str, version: Option<&str>) -> Option<&AiSkillSpec> { + self.skills + .iter() + .rev() + .find(|skill| skill.id == id && version.is_none_or(|value| skill.version == value)) + } + + pub fn iter(&self) -> impl Iterator { + self.skills.iter() + } + + pub fn validate_all(&self) -> Vec { + self.skills + .iter() + .map(|skill| match skill.validate() { + Ok(()) => AiSkillValidationResult { + id: skill.id.clone(), + version: skill.version.clone(), + valid: true, + error: None, + }, + Err(error) => AiSkillValidationResult { + id: skill.id.clone(), + version: skill.version.clone(), + valid: false, + error: Some(error.to_string()), + }, + }) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AiSkillValidationResult { + pub id: String, + pub version: String, + pub valid: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +fn validate_skill_id(value: &str) -> Result<()> { + if !value.starts_with("skill.") + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') + }) + { + anyhow::bail!("AI skill id '{value}' must be a stable 'skill.*' identifier"); + } + Ok(()) +} + +fn validate_variable_name(value: &str) -> Result<()> { + if value.is_empty() + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + anyhow::bail!("AI skill variable '{value}' must be alphanumeric or underscore"); + } + Ok(()) +} + +fn referenced_variables(template: &str) -> Result> { + let mut names = BTreeSet::new(); + let mut remaining = template; + while let Some(start) = remaining.find("{{") { + let after_start = &remaining[start + 2..]; + let Some(end) = after_start.find("}}") else { + anyhow::bail!("AI skill template contains an unclosed variable expression"); + }; + let name = after_start[..end].trim(); + validate_variable_name(name)?; + names.insert(name.to_string()); + remaining = &after_start[end + 2..]; + } + if remaining.contains("}}") { + anyhow::bail!("AI skill template contains an unmatched closing variable expression"); + } + Ok(names) +} + +/// Validate the conservative JSON Schema subset supported by the v1 skill runtime. +/// +/// Rejecting unknown validation keywords keeps contracts strict: a skill cannot +/// appear validated while relying on a keyword the runtime silently ignores. +pub fn validate_json_schema_definition(schema: &Value, path: &str) -> Result<()> { + let object = schema + .as_object() + .with_context(|| format!("{path} must be a JSON Schema object"))?; + let supported = [ + "$schema", + "$id", + "title", + "description", + "type", + "const", + "enum", + "required", + "properties", + "additionalProperties", + "items", + "minLength", + "maxLength", + "minimum", + "maximum", + "minItems", + "maxItems", + ]; + for key in object.keys() { + if !supported.contains(&key.as_str()) { + anyhow::bail!("{path} uses unsupported JSON Schema keyword '{key}'"); + } + } + if let Some(schema_type) = object.get("type") { + let schema_type = schema_type + .as_str() + .with_context(|| format!("{path}.type must be a string"))?; + if ![ + "object", "array", "string", "number", "integer", "boolean", "null", + ] + .contains(&schema_type) + { + anyhow::bail!("{path}.type '{schema_type}' is not supported"); + } + } + if let Some(properties) = object.get("properties") { + for (name, property) in properties + .as_object() + .with_context(|| format!("{path}.properties must be an object"))? + { + validate_json_schema_definition(property, &format!("{path}.properties.{name}"))?; + } + } + if let Some(items) = object.get("items") { + validate_json_schema_definition(items, &format!("{path}.items"))?; + } + if let Some(required) = object.get("required") { + let required = required + .as_array() + .with_context(|| format!("{path}.required must be an array"))?; + if required.iter().any(|item| !item.is_string()) { + anyhow::bail!("{path}.required entries must be strings"); + } + } + if let Some(additional) = object.get("additionalProperties") { + if !additional.is_boolean() { + anyhow::bail!("{path}.additionalProperties must be a boolean"); + } + } + Ok(()) +} + +/// Validate a JSON value against the runtime's conservative schema subset. +pub fn validate_json_instance(schema: &Value, instance: &Value) -> Result<()> { + validate_instance_at(schema, instance, "$") +} + +fn validate_instance_at(schema: &Value, instance: &Value, path: &str) -> Result<()> { + let object = schema + .as_object() + .context("validated schema must be an object")?; + if let Some(expected) = object.get("const") { + if instance != expected { + anyhow::bail!("{path} does not match the schema const value"); + } + } + if let Some(allowed) = object.get("enum") { + let allowed = allowed.as_array().context("schema enum must be an array")?; + if !allowed.contains(instance) { + anyhow::bail!("{path} is not one of the allowed enum values"); + } + } + if let Some(expected_type) = object.get("type").and_then(Value::as_str) { + let valid_type = match expected_type { + "object" => instance.is_object(), + "array" => instance.is_array(), + "string" => instance.is_string(), + "number" => instance.is_number(), + "integer" => instance.as_i64().is_some() || instance.as_u64().is_some(), + "boolean" => instance.is_boolean(), + "null" => instance.is_null(), + _ => false, + }; + if !valid_type { + anyhow::bail!("{path} must be of type '{expected_type}'"); + } + } + if let Some(value) = instance.as_object() { + let properties = object.get("properties").and_then(Value::as_object); + if let Some(required) = object.get("required").and_then(Value::as_array) { + for name in required.iter().filter_map(Value::as_str) { + if !value.contains_key(name) { + anyhow::bail!("{path}.{name} is required"); + } + } + } + if object.get("additionalProperties").and_then(Value::as_bool) == Some(false) { + for name in value.keys() { + if !properties.is_some_and(|properties| properties.contains_key(name)) { + anyhow::bail!("{path}.{name} is not allowed by the schema"); + } + } + } + if let Some(properties) = properties { + for (name, child_schema) in properties { + if let Some(child) = value.get(name) { + validate_instance_at(child_schema, child, &format!("{path}.{name}"))?; + } + } + } + } + if let Some(value) = instance.as_array() { + if let Some(minimum) = object.get("minItems").and_then(Value::as_u64) { + if value.len() < minimum as usize { + anyhow::bail!("{path} contains fewer than {minimum} items"); + } + } + if let Some(maximum) = object.get("maxItems").and_then(Value::as_u64) { + if value.len() > maximum as usize { + anyhow::bail!("{path} contains more than {maximum} items"); + } + } + if let Some(item_schema) = object.get("items") { + for (index, child) in value.iter().enumerate() { + validate_instance_at(item_schema, child, &format!("{path}[{index}]"))?; + } + } + } + if let Some(value) = instance.as_str() { + if let Some(minimum) = object.get("minLength").and_then(Value::as_u64) { + if value.chars().count() < minimum as usize { + anyhow::bail!("{path} is shorter than {minimum} characters"); + } + } + if let Some(maximum) = object.get("maxLength").and_then(Value::as_u64) { + if value.chars().count() > maximum as usize { + anyhow::bail!("{path} is longer than {maximum} characters"); + } + } + } + if let Some(value) = instance.as_f64() { + if let Some(minimum) = object.get("minimum").and_then(Value::as_f64) { + if value < minimum { + anyhow::bail!("{path} is less than {minimum}"); + } + } + if let Some(maximum) = object.get("maximum").and_then(Value::as_f64) { + if value > maximum { + anyhow::bail!("{path} is greater than {maximum}"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + + #[test] + fn bundled_skills_are_valid_and_candidate_first() { + let registry = AiSkillRegistry::bundled().unwrap(); + assert_eq!(registry.iter().count(), 4); + assert!(registry.iter().all(|skill| { + skill.approval.initial_state == AiCandidateState::Candidate + && skill.approval.human_review_required + })); + } + + #[test] + fn visual_skill_fixtures_pin_the_bundled_synthetic_image() { + let digest = format!( + "sha256:{:x}", + Sha256::digest(include_bytes!( + "../../data/ai/fixtures/synthetic-layout.svg" + )) + ); + let registry = AiSkillRegistry::bundled().unwrap(); + for id in [ + "skill.visual-dna.describe", + "skill.accessibility.describe-candidate", + ] { + assert_eq!( + registry.get(id, None).unwrap().fixture["image_digest"], + digest + ); + } + } + + #[test] + fn strict_contract_rejects_unknown_fields() { + let schema = serde_json::json!({ + "type": "object", + "required": ["title"], + "properties": {"title": {"type": "string"}}, + "additionalProperties": false + }); + let error = validate_json_instance( + &schema, + &serde_json::json!({"title": "Safe", "surprise": true}), + ) + .unwrap_err(); + assert!(error.to_string().contains("surprise")); + } + + #[test] + fn unsupported_schema_keywords_are_rejected() { + let error = validate_json_schema_definition( + &serde_json::json!({"type": "string", "pattern": "unsafe"}), + "schema", + ) + .unwrap_err(); + assert!(error.to_string().contains("unsupported")); + } +} diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 30626c9..14e1a15 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -3,8 +3,8 @@ use clap::Parser; use tracing::info; use crate::cli::{ - AiCommands, Cli, Commands, EbookCommands, GraphCommands, LuluCommands, PluginCommands, - PublicationCommands, SpecCommands, ToolCommands, VideoCommands, + AiCommands, AiSkillCommands, Cli, Commands, EbookCommands, GraphCommands, LuluCommands, + PluginCommands, PublicationCommands, SpecCommands, ToolCommands, VideoCommands, }; use crate::video::HandBrakeLimits; use crate::{commands, transforms}; @@ -101,6 +101,37 @@ pub fn run_cli(cli: Cli) -> Result<()> { } } Some(Commands::Ai { subcommand }) => match subcommand { + AiCommands::Matrix { format, catalog } => { + commands::ai::run_matrix(&format, catalog.as_deref())? + } + AiCommands::Resolve { + skill, + skill_version, + execution_preference, + allow_remote, + allow_unverified, + format, + catalog, + } => commands::ai::run_resolve( + &skill, + skill_version.as_deref(), + &execution_preference, + allow_remote, + allow_unverified, + &format, + catalog.as_deref(), + )?, + AiCommands::Skills { subcommand } => match subcommand { + AiSkillCommands::List { format } => commands::ai::run_skills_list(&format)?, + AiSkillCommands::Inspect { + id, + version, + format, + } => commands::ai::run_skills_inspect(&id, version.as_deref(), &format)?, + AiSkillCommands::Validate { path, format } => { + commands::ai::run_skills_validate(path.as_deref(), &format)? + } + }, AiCommands::Providers => commands::ai::run_providers()?, AiCommands::Models => commands::ai::run_models()?, AiCommands::Doctor { ollama_endpoint } => commands::ai::run_doctor(&ollama_endpoint)?, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index de2f563..0b04135 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -218,7 +218,7 @@ pub enum Commands { subcommand: PluginCommands, }, - /// Manage and inspect AI providers and the AI transform cache + /// Resolve model-aware AI skills and inspect providers, policy, and cache state /// /// These commands help you discover configured AI providers, inspect /// available models, run connectivity diagnostics, and manage the AI @@ -227,8 +227,10 @@ pub enum Commands { subcommand_required = true, arg_required_else_help = true, after_help = "Examples:\n \ - renderflow ai providers List available AI providers\n \ - renderflow ai models List available models per provider\n \ + renderflow ai matrix Inspect model-specific compatibility\n \ + renderflow ai resolve --skill skill.metadata.extract\n \ + renderflow ai skills validate Validate bundled skill contracts\n \ + renderflow ai providers List legacy provider-wide capabilities\n \ renderflow ai doctor Run AI provider diagnostics\n \ renderflow ai cache Show AI cache statistics" )] @@ -555,6 +557,53 @@ pub enum ToolCommands { /// Subcommands for `renderflow ai`. #[derive(Subcommand)] pub enum AiCommands { + /// Inspect the versioned provider/model compatibility matrix + #[command( + after_help = "Examples:\n renderflow ai matrix\n renderflow ai matrix --format json" + )] + Matrix { + /// Output format: text (default), json, or yaml + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + /// Optional path to a versioned catalog JSON file + #[arg(long, value_name = "FILE")] + catalog: Option, + }, + + /// Resolve a reviewed AI skill to a compatible provider/model + #[command( + after_help = "Examples:\n renderflow ai resolve --skill skill.metadata.extract\n renderflow ai resolve --skill skill.metadata.extract --allow-unverified --format json" + )] + Resolve { + /// Stable skill ID + #[arg(long, value_name = "ID")] + skill: String, + /// Optional exact skill version + #[arg(long, value_name = "VERSION")] + skill_version: Option, + /// Execution preference + #[arg(long, default_value = "local-preferred", value_name = "PREFERENCE")] + execution_preference: String, + /// Explicitly allow remote candidates when the skill policy also permits them + #[arg(long)] + allow_remote: bool, + /// Permit planning against catalog entries whose live availability is unverified + #[arg(long)] + allow_unverified: bool, + /// Output format: text (default), json, or yaml + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + /// Optional path to a versioned catalog JSON file + #[arg(long, value_name = "FILE")] + catalog: Option, + }, + + /// Inspect and validate reviewed, versioned AI skills + Skills { + #[command(subcommand)] + subcommand: AiSkillCommands, + }, + /// List available AI providers and their capabilities /// /// Prints a table of all known providers (Ollama, OpenAI) with their @@ -596,6 +645,37 @@ pub enum AiCommands { }, } +/// Subcommands for versioned Renderflow AI skills. +#[derive(Subcommand)] +pub enum AiSkillCommands { + /// List bundled AI skills + List { + /// Output format: text (default), json, or yaml + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, + /// Inspect one bundled AI skill + Inspect { + /// Stable skill ID + id: String, + /// Optional exact skill version + #[arg(long, value_name = "VERSION")] + version: Option, + /// Output format: text (default), json, or yaml + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, + /// Validate all bundled skills or one external skill file + Validate { + /// Optional path to a skill JSON file + #[arg(long, value_name = "FILE")] + path: Option, + /// Output format: text (default), json, or yaml + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, +} + /// Subcommands for `renderflow graph`. #[derive(Subcommand)] pub enum GraphCommands { diff --git a/crates/renderflow-core/src/commands/ai.rs b/crates/renderflow-core/src/commands/ai.rs index 674c367..8a3de00 100644 --- a/crates/renderflow-core/src/commands/ai.rs +++ b/crates/renderflow-core/src/commands/ai.rs @@ -1,19 +1,232 @@ //! Handler for `renderflow ai` subcommands. //! //! Implements: +//! * `renderflow ai matrix` – inspect model-granular compatibility +//! * `renderflow ai resolve` – resolve a versioned skill against policy +//! * `renderflow ai skills` – inspect and validate reviewed skills //! * `renderflow ai providers` – list available providers //! * `renderflow ai models` – list available models per provider //! * `renderflow ai doctor` – connectivity diagnostics //! * `renderflow ai cache` – cache statistics -use anyhow::Result; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use serde::Serialize; use crate::ai::{ provider::AiProvider, providers::{OllamaProvider, OpenAiProvider}, + AiCandidateStatus, AiExecutionPreferenceV1, AiModelCatalog, AiSkillRegistry, AiSkillSpec, + AiSkillValidationResult, }; use crate::cache::{load_ai_cache, AiCache}; +fn load_catalog(path: Option<&str>) -> Result { + match path { + Some(path) => AiModelCatalog::load(path), + None => AiModelCatalog::bundled(), + } +} + +fn serialize_output(value: &impl Serialize, format: &str) -> Result { + match format { + "json" => Ok(format!("{}\n", serde_json::to_string_pretty(value)?)), + "yaml" => Ok(serde_yaml_ng::to_string(value)?), + _ => anyhow::bail!("unknown output format '{format}'; expected text, json, or yaml"), + } +} + +// ── matrix and resolution ──────────────────────────────────────────────────── + +/// Run `renderflow ai matrix`. +pub fn run_matrix(format: &str, path: Option<&str>) -> Result<()> { + let catalog = load_catalog(path)?; + if format != "text" { + print!("{}", serialize_output(&catalog, format)?); + return Ok(()); + } + println!( + "AI model compatibility matrix {} ({})", + catalog.schema_version, catalog.revision + ); + println!(); + println!( + " {:<30} {:<20} {:<9} {:<12} Operations", + "Provider", "Model", "Locality", "Availability" + ); + println!( + " {:-<30} {:-<20} {:-<9} {:-<12} {:-<36}", + "", "", "", "", "" + ); + for provider in &catalog.providers { + for model in &provider.models { + let locality = provider.locality.to_string(); + let availability = format!("{:?}", model.availability).to_lowercase(); + let operations = model + .operations + .iter() + .map(ToString::to_string) + .collect::>() + .join(","); + println!( + " {:<30} {:<20} {:<9} {:<12} {}", + provider.id, model.id, locality, availability, operations + ); + } + } + println!(); + println!("Availability is evidence, not an installation claim; use --format json for limits, licenses, and model-specific modalities."); + Ok(()) +} + +/// Run `renderflow ai resolve`. +#[allow(clippy::too_many_arguments)] +pub fn run_resolve( + skill_id: &str, + skill_version: Option<&str>, + preference: &str, + allow_remote: bool, + allow_unverified: bool, + format: &str, + catalog_path: Option<&str>, +) -> Result<()> { + let catalog = load_catalog(catalog_path)?; + let registry = AiSkillRegistry::bundled()?; + let skill = registry + .get(skill_id, skill_version) + .with_context(|| format!("AI skill '{skill_id}' was not found"))?; + let preference = AiExecutionPreferenceV1::from_str(preference)?; + let report = + catalog.resolve(&skill.resolution_request(preference, allow_remote, allow_unverified)); + if format != "text" { + print!("{}", serialize_output(&report, format)?); + return Ok(()); + } + println!("AI resolution for {}@{}", skill.id, skill.version); + println!(" preference: {}", preference); + match &report.selected { + Some(selected) => { + println!( + " selected: {}:{} ({}, {:?})", + selected.provider_id, selected.model_id, selected.locality, selected.availability + ); + println!(" execution ready: {}", selected.execution_ready); + } + None => println!(" selected: none"), + } + println!(); + for candidate in &report.candidates { + let marker = match candidate.status { + AiCandidateStatus::Selected => "selected", + AiCandidateStatus::Compatible => "compatible", + AiCandidateStatus::Rejected => "rejected", + }; + println!( + " [{}] {}:{}", + marker, candidate.provider_id, candidate.model_id + ); + for reason in &candidate.reasons { + println!(" - {}: {}", reason.code, reason.message); + } + } + Ok(()) +} + +// ── skills ─────────────────────────────────────────────────────────────────── + +/// Run `renderflow ai skills list`. +pub fn run_skills_list(format: &str) -> Result<()> { + let registry = AiSkillRegistry::bundled()?; + if format != "text" { + let skills = registry.iter().collect::>(); + print!("{}", serialize_output(&skills, format)?); + return Ok(()); + } + println!("Bundled Renderflow AI skills:"); + for skill in registry.iter() { + println!(" {}@{} — {}", skill.id, skill.version, skill.purpose); + } + Ok(()) +} + +/// Run `renderflow ai skills inspect`. +pub fn run_skills_inspect(id: &str, version: Option<&str>, format: &str) -> Result<()> { + let registry = AiSkillRegistry::bundled()?; + let skill = registry + .get(id, version) + .with_context(|| format!("AI skill '{id}' was not found"))?; + if format != "text" { + print!("{}", serialize_output(skill, format)?); + return Ok(()); + } + println!("{}@{}", skill.id, skill.version); + println!(" purpose: {}", skill.purpose); + println!( + " inputs: {}", + skill + .input_modalities + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ); + println!( + " outputs: {}", + skill + .output_modalities + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ); + println!(" hygiene policy: {}", skill.hygiene.policy_id); + println!(" initial state: {:?}", skill.approval.initial_state); + println!( + " human review required: {}", + skill.approval.human_review_required + ); + Ok(()) +} + +/// Run `renderflow ai skills validate`. +pub fn run_skills_validate(path: Option<&str>, format: &str) -> Result<()> { + let results = if let Some(path) = path { + let skill = AiSkillSpec::load(path)?; + vec![AiSkillValidationResult { + id: skill.id, + version: skill.version, + valid: true, + error: None, + }] + } else { + AiSkillRegistry::bundled()?.validate_all() + }; + if format != "text" { + print!("{}", serialize_output(&results, format)?); + return Ok(()); + } + for result in &results { + if result.valid { + println!("✓ {}@{}", result.id, result.version); + } else { + println!( + "✗ {}@{}: {}", + result.id, + result.version, + result + .error + .as_deref() + .unwrap_or("unknown validation error") + ); + } + } + if results.iter().any(|result| !result.valid) { + anyhow::bail!("one or more AI skills are invalid"); + } + Ok(()) +} + // ── providers ───────────────────────────────────────────────────────────────── /// Run `renderflow ai providers`. diff --git a/docs/ai-guide/model-catalog-and-skills.md b/docs/ai-guide/model-catalog-and-skills.md new file mode 100644 index 0000000..8aeb679 --- /dev/null +++ b/docs/ai-guide/model-catalog-and-skills.md @@ -0,0 +1,150 @@ +# Model catalog and AI skills + +Renderflow separates stable artifact intent from replaceable model adapters: + +```text +profile or artifact transform + -> versioned AI skill and JSON contracts + -> deterministic policy/capability resolver + -> model-specific catalog entry + -> configured local or explicitly approved remote adapter + -> validated candidate artifact and redacted execution evidence + -> human review and publication hygiene +``` + +The contracts are: + +- `renderflow.ai-model-catalog/v1` — providers, runtimes, individual models, + modalities, operations, limits, availability, licenses, determinism, and + advisory evidence; +- `renderflow.ai-skill/v1` — reviewed instructions, bounded variables, schemas, + budgets, hygiene, validation, provenance, and approval policy; +- `renderflow.ai-resolution/v1` — deterministic selection plus an explanation + for every rejected, unavailable, or lower-ranked candidate; +- `renderflow.ai-execution/v1` — redacted identities, digests, usage, + validators, hygiene outcomes, candidate state, and approval evidence. + +The canonical JSON Schemas live in `schemas/`. Built-in catalog and skill assets +live under `crates/renderflow-core/data/ai/` and are parsed through the same SDK +types exposed to callers. + +## Local-first resolution + +`local-preferred` is the default. A local model must still match every required +operation and input/output modality. `local-only` rejects every remote model and +never silently falls back. Remote execution requires all of the following: + +1. `--allow-remote` or the equivalent SDK request permission; +2. a skill whose network and remote-execution budgets permit it; +3. an available configured provider adapter; +4. explicit privacy approval when detected PII could leave the machine; +5. approval for every input artifact exposed to the model. + +The bundled catalog begins with Ollama and an OpenAI-compatible adapter, but the +contract can represent llama.cpp servers, vLLM, local OpenAI-compatible servers, +image graphs, and speech runtimes without changing profile contracts. Renderflow +does not bundle model weights. + +`unverified` means the catalog entry is structurally usable for planning but is +not execution-ready. Live discovery should replace it with bounded evidence for +the exact runtime, model revision, digests, source, and licenses. Unknown data +must remain unknown; never invent a digest, revision, commercial-use grant, or +provider-terms version. + +## Adding a provider or model + +To add an adapter, implement `AiProvider`, give the adapter a stable name, and +add a provider entry with locality, network requirements, runtime identity, and +a bounded availability probe. Add each model separately. Do not copy a +provider-wide capability set onto models that do not actually support it. + +Each model entry must include: + +- stable model and family IDs; +- typed input/output modalities and operations; +- structured JSON and JSON Schema behavior; +- limits, controls, multi-input, streaming, batch, and tool-use behavior; +- an honest determinism class; +- availability plus runtime/model/weight identity evidence; +- model, weight, code, and provider licensing evidence where known; +- commercial constraints and human-review requirements; +- timestamped cost, quality, and latency hints when supplied; +- maturity, conformance, and required provenance fields. + +Validate custom catalogs with `renderflow ai matrix --catalog FILE --format +json`. Catalog parsing rejects unknown fields and duplicate IDs. + +## Adding a skill + +A skill is an inspectable execution recipe, not an autonomous agent and not an +opaque provider prompt. Start from one of the bundled synthetic skills and: + +1. choose a stable `skill.*` ID and semantic version; +2. declare artifact families, operations, and typed modalities; +3. define strict input and output JSON Schemas; +4. declare every `{{variable}}`, sensitivity, and byte bound; +5. use provider-neutral generation settings; namespace optional adapter + extensions instead of leaking them into profile contracts; +6. set network, locality, byte, token, time, retry, and cost budgets; +7. configure secret, PII, protected-reference, and post-output hygiene; +8. keep generated output in `candidate` state and name its validators/review; +9. declare cache/evidence identity and redact raw inputs/prompts by default; +10. add a redistribution-safe synthetic fixture. + +The v1 runtime supports a deliberately conservative JSON Schema subset and +rejects unsupported schema keywords rather than pretending they were enforced. +Use `renderflow ai skills validate --path FILE` before registering an external +skill. + +## Execution, validation, and evidence + +The SDK `AiSkillRuntime` validates input, resolves the exact model, applies +pre-prompt hygiene, executes through `AiProvider`, parses and sanitizes the +structured response, validates the output schema, and stores it in the artifact +store as an intermediate candidate. It never marks generated content as +publication-approved. + +Cache and resume identity commits to the catalog revision, provider, runtime, +model and weight identities, skill version/content, input, schemas, settings, +and hygiene policy. Reusing that fingerprint means the configuration is +compatible; it does not claim byte-level reproducibility for a probabilistic +model. Execution evidence stores digests instead of raw private prompts or +source payloads, and includes usage/cost fields only when the adapter can report +them. + +Protected-reference rewrites replace configured imitation labels with reviewed, +descriptive characteristics before model exposure and again after generation. +Evidence records the finding class and action without publishing the blocked +term. Automated hygiene, model metadata, and provider terms are not legal +clearance. + +## Initial proving skills and downstream use + +The built-in fixtures require no paid API or bundled weights: + +- `skill.metadata.extract`; +- `skill.visual-dna.describe`; +- `skill.prompt.from-sanitized-dna`; +- `skill.accessibility.describe-candidate`. + +The visual skills reference the original geometric +`data/ai/fixtures/synthetic-layout.svg` asset by SHA-256 digest. Validation does +not download or execute a model. + +Visual DNA and accessibility skills may resolve to a compatible local +multimodal model when one is installed and discovered. Otherwise, resolution +returns structured unavailability evidence and deterministic/non-AI publication +paths remain usable. + +Artifact DNA (#387) can consume the visual-description and sanitized-prompt +contracts. The coloring-book profile (#348) can use the same layer for optional +line-art planning while retaining a deterministic non-AI path. Neither consumer +should copy provider prompts or treat an AI candidate as authoritative. + +## Rollback and reproduction + +Keep the source artifact, skill version, catalog revision, schemas, settings, +hygiene policy, model/runtime identity, and candidate evidence together. Roll +back by selecting an earlier reviewed candidate or disabling the optional AI +stage. Re-running the same fingerprint reproduces the configuration and audit +trail, but probabilistic byte output may differ and must be validated again. diff --git a/docs/ai-guide/overview.md b/docs/ai-guide/overview.md index 8efb555..7215fc4 100644 --- a/docs/ai-guide/overview.md +++ b/docs/ai-guide/overview.md @@ -1,6 +1,7 @@ # AI Guide Overview -Renderflow treats AI as just another transform backend. +Renderflow treats AI as an optional, governed artifact provider. Profiles and +transforms request capabilities; they do not need to name a hosted vendor. ## Supported scenarios @@ -20,3 +21,10 @@ Providers implement `AiProvider` and advertise: - execution method `OllamaProvider` is local-first; `OpenAiProvider` targets OpenAI-compatible APIs. + +Provider-wide capability claims are retained for compatibility, but planning +uses the model compatibility catalog. Image support on one model therefore does +not imply image support on every model behind the same endpoint. + +See [Model catalog and AI skills](model-catalog-and-skills.md) for the resolver, +schema contracts, hygiene gates, candidate artifacts, and provenance model. diff --git a/docs/cli-reference/ai.md b/docs/cli-reference/ai.md index a3e6744..bd8049f 100644 --- a/docs/cli-reference/ai.md +++ b/docs/cli-reference/ai.md @@ -1,9 +1,50 @@ # `renderflow ai` -Inspect built-in AI providers and cache state. +Inspect model-specific capabilities, resolve reviewed skills, diagnose providers, +and inspect cache state. Inspection commands do not execute a model. ## Subcommands +### `ai matrix` + +```bash +renderflow ai matrix [--catalog FILE] [--format text|json|yaml] +``` + +Displays the versioned compatibility catalog at model granularity, including +typed modalities, operations, structured-output support, availability, +determinism, licenses, runtime evidence, limits, and advisory cost/quality hints. +Bundled entries are `unverified`: a declaration never claims that weights are +installed or that a hosted endpoint is authorized. + +### `ai resolve` + +```bash +renderflow ai resolve \ + --skill skill.metadata.extract \ + --execution-preference local-preferred \ + --allow-unverified \ + --format json +``` + +Returns the selected provider/model plus explicit rejection or lower-ranking +reasons for every other catalog candidate. `--allow-remote` is an explicit +request-level permission; it cannot override a skill that forbids network or +remote execution. `local-only` never falls back to a hosted service. + +### `ai skills` + +```bash +renderflow ai skills list +renderflow ai skills inspect skill.visual-dna.describe --format json +renderflow ai skills validate +renderflow ai skills validate --path custom-skill.json --format json +``` + +Skills are versioned Renderflow recipes with strict input/output schemas, +reviewed templates, bounded variables, budgets, hygiene, provenance, validators, +and candidate/approval policy. They are not provider-specific prompt strings. + ### `ai providers` Prints provider name, locality, and declared capabilities. @@ -36,6 +77,9 @@ Reads the cache file and prints entry counts by model. The default path is `.ren ```bash renderflow ai providers renderflow ai models +renderflow ai matrix --format json +renderflow ai resolve --skill skill.metadata.extract --allow-unverified --format json +renderflow ai skills validate renderflow ai doctor --ollama-endpoint http://localhost:11434 renderflow ai cache --path .renderflow-ai-cache.json ``` diff --git a/docs/user-guide/ai.md b/docs/user-guide/ai.md index a35fff6..6bf2162 100644 --- a/docs/user-guide/ai.md +++ b/docs/user-guide/ai.md @@ -9,10 +9,10 @@ Renderflow supports AI-backed transforms through `AiTransform` and the `renderfl | `ollama` | `http://localhost:11434` | local-first provider, `POST /api/generate` | | `openai` | `https://api.openai.com` | OpenAI-compatible chat completions, `POST /v1/chat/completions` | -Built-in model lists shown by `renderflow ai models` include: - -- Ollama: `mistral`, `llava`, `llama3`, `gemma`, `phi` -- OpenAI: `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`, `gpt-3.5-turbo` +The legacy model lists shown by `renderflow ai models` are compatibility hints. +Use `renderflow ai matrix` for the versioned, model-specific capability contract +and `renderflow ai resolve` for a policy-aware selection. A catalog entry does +not imply that local weights are installed or a remote endpoint is approved. ## YAML example @@ -52,8 +52,15 @@ Renderflow substitutes `{input}` into the prompt template before sending the req - `renderflow ai providers` - `renderflow ai models` +- `renderflow ai matrix --format json` +- `renderflow ai resolve --skill skill.metadata.extract --format json` +- `renderflow ai skills validate` - `renderflow ai doctor --ollama-endpoint ...` - `renderflow ai cache --path .renderflow-ai-cache.json` +See [Model catalog and AI skills](../ai-guide/model-catalog-and-skills.md) for +local-only guarantees, remote opt-in, strict schemas, hygiene, provenance, and +candidate approval. + !!! warning AI transforms are optional and only run when configured. In fail-fast mode a backend outage aborts the build; in watch mode the transform is skipped and the original content continues through the pipeline. diff --git a/mkdocs.yml b/mkdocs.yml index f5efb22..a786350 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -148,6 +148,7 @@ nav: - AI Guide: - Overview: ai-guide/overview.md - Configuration: ai-guide/configuration.md + - Model catalog and skills: ai-guide/model-catalog-and-skills.md - Examples: - Hello World: examples/hello-world.md - Multi Output: examples/multi-output.md diff --git a/schemas/renderflow-ai-execution-v1.schema.json b/schemas/renderflow-ai-execution-v1.schema.json new file mode 100644 index 0000000..e390d3c --- /dev/null +++ b/schemas/renderflow-ai-execution-v1.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-ai-execution-v1.schema.json", + "title": "Renderflow AI execution evidence v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "execution_id", "occurred_at_unix_ms", "identity", "skill_id", "skill_version", "skill_digest", "input_digest", "input_artifacts", "instruction_digest", "prompt_digest", "input_schema_digest", "output_schema_digest", "settings_digest", "hygiene_policy_digest", "cache_identity", "cache_decision", "determinism", "usage", "output_artifact_id", "output_digest", "output_state", "approval_required", "validators", "validation_status", "pre_prompt_hygiene", "post_output_hygiene", "raw_inputs_retained", "raw_prompts_retained"], + "properties": { + "schema_version": {"const": "renderflow.ai-execution/v1"}, "execution_id": {"type": "string"}, "occurred_at_unix_ms": {"type": "integer"}, + "identity": {"type": "object"}, "skill_id": {"type": "string"}, "skill_version": {"type": "string"}, + "skill_digest": {"$ref": "#/$defs/digest"}, "input_digest": {"$ref": "#/$defs/digest"}, "input_artifacts": {"type": "array", "items": {"type": "object"}}, + "instruction_digest": {"$ref": "#/$defs/digest"}, "prompt_digest": {"$ref": "#/$defs/digest"}, "input_schema_digest": {"$ref": "#/$defs/digest"}, "output_schema_digest": {"$ref": "#/$defs/digest"}, "settings_digest": {"$ref": "#/$defs/digest"}, "hygiene_policy_digest": {"$ref": "#/$defs/digest"}, "cache_identity": {"$ref": "#/$defs/digest"}, + "cache_decision": {"type": "string"}, "determinism": {"type": "string"}, "usage": {"type": "object"}, "output_artifact_id": {"type": "string"}, "output_digest": {"$ref": "#/$defs/digest"}, + "output_state": {"enum": ["candidate", "approved"]}, "approval_required": {"type": "boolean"}, "approval_reference": {"type": "string"}, "validators": {"type": "array", "items": {"type": "string"}}, "validation_status": {"type": "string"}, + "pre_prompt_hygiene": {"type": "object"}, "post_output_hygiene": {"type": "object"}, "raw_inputs_retained": {"const": false}, "raw_prompts_retained": {"const": false} + }, + "$defs": {"digest": {"type": "object", "additionalProperties": false, "required": ["algorithm", "value"], "properties": {"algorithm": {"const": "sha256"}, "value": {"type": "string", "pattern": "^[a-f0-9]{64}$"}}}} +} diff --git a/schemas/renderflow-ai-model-catalog-v1.schema.json b/schemas/renderflow-ai-model-catalog-v1.schema.json new file mode 100644 index 0000000..111f094 --- /dev/null +++ b/schemas/renderflow-ai-model-catalog-v1.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-ai-model-catalog-v1.schema.json", + "title": "Renderflow AI provider/model compatibility catalog v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "revision", "providers"], + "properties": { + "schema_version": {"const": "renderflow.ai-model-catalog/v1"}, + "revision": {"type": "string", "minLength": 1}, + "providers": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/provider"}} + }, + "$defs": { + "locality": {"enum": ["local", "remote", "hybrid"]}, + "availability": {"enum": ["available", "unavailable", "unverified"]}, + "modality": {"enum": ["text", "image", "audio", "video", "document", "structured_json", "artifact_dna", "embeddings", "mask", "metadata"]}, + "operation": {"enum": ["generation", "editing", "extraction", "classification", "transcription", "embeddings", "multimodal_reasoning", "schema_constrained_output"]}, + "probe": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "bounded_timeout_ms"], + "properties": {"kind": {"type": "string", "minLength": 1}, "bounded_timeout_ms": {"type": "integer", "minimum": 1}, "endpoint": {"type": "string"}} + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["id", "availability_probe"], + "properties": { + "id": {"type": "string", "minLength": 1}, "revision": {"type": "string"}, "digest": {"type": "string"}, "source": {"type": "string"}, "license": {"type": "string"}, + "hardware_requirements": {"type": "array", "items": {"type": "string"}}, "availability_probe": {"$ref": "#/$defs/probe"} + } + }, + "limits": { + "type": "object", "additionalProperties": false, + "properties": {"context_tokens": {"type": "integer", "minimum": 1}, "output_tokens": {"type": "integer", "minimum": 1}, "max_input_bytes": {"type": "integer", "minimum": 1}, "max_images": {"type": "integer", "minimum": 1}, "max_audio_seconds": {"type": "integer", "minimum": 1}, "max_video_seconds": {"type": "integer", "minimum": 1}} + }, + "features": { + "type": "object", "additionalProperties": false, + "required": ["native_json", "json_schema", "seed", "sampler_controls", "streaming", "batch", "tool_use", "multi_input"], + "properties": {"native_json": {"type": "boolean"}, "json_schema": {"type": "boolean"}, "seed": {"type": "boolean"}, "sampler_controls": {"type": "boolean"}, "streaming": {"type": "boolean"}, "batch": {"type": "boolean"}, "tool_use": {"type": "boolean"}, "multi_input": {"type": "boolean"}} + }, + "license": { + "type": "object", "additionalProperties": false, + "required": ["model", "commercial_use", "human_review_required"], + "properties": {"model": {"type": "string"}, "weights": {"type": "string"}, "code": {"type": "string"}, "provider_terms": {"type": "string"}, "commercial_use": {"type": "string"}, "human_review_required": {"type": "boolean"}} + }, + "advisory": { + "type": "object", "additionalProperties": false, + "properties": {"cost_tier": {"type": "integer", "minimum": 0, "maximum": 255}, "quality_tier": {"type": "integer", "minimum": 0, "maximum": 255}, "latency_tier": {"type": "integer", "minimum": 0, "maximum": 255}, "observed_at": {"type": "string"}} + }, + "model": { + "type": "object", "additionalProperties": false, + "required": ["id", "family", "input_modalities", "output_modalities", "operations", "determinism", "license", "maturity", "conformance", "availability", "required_provenance_fields"], + "properties": { + "id": {"type": "string", "minLength": 1}, "family": {"type": "string", "minLength": 1}, "revision": {"type": "string"}, "quantization": {"type": "string"}, "weights_digest": {"type": "string"}, "configuration_digest": {"type": "string"}, + "input_modalities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/modality"}}, + "output_modalities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/modality"}}, + "operations": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/operation"}}, + "limits": {"$ref": "#/$defs/limits"}, "features": {"$ref": "#/$defs/features"}, + "determinism": {"enum": ["byte_deterministic", "configuration_repeatable", "probabilistic", "unknown"]}, "license": {"$ref": "#/$defs/license"}, + "commercial_constraints": {"type": "array", "items": {"type": "string"}}, "advisory": {"$ref": "#/$defs/advisory"}, + "maturity": {"type": "string"}, "conformance": {"type": "string"}, "availability": {"$ref": "#/$defs/availability"}, "availability_reason": {"type": "string"}, + "required_provenance_fields": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}} + } + }, + "provider": { + "type": "object", "additionalProperties": false, + "required": ["id", "adapter", "display_name", "locality", "requires_network_permission", "runtime", "models"], + "properties": {"id": {"type": "string", "minLength": 1}, "adapter": {"type": "string", "minLength": 1}, "display_name": {"type": "string", "minLength": 1}, "locality": {"$ref": "#/$defs/locality"}, "requires_network_permission": {"type": "boolean"}, "runtime": {"$ref": "#/$defs/runtime"}, "models": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/model"}}} + } + } +} diff --git a/schemas/renderflow-ai-skill-v1.schema.json b/schemas/renderflow-ai-skill-v1.schema.json new file mode 100644 index 0000000..7a4e566 --- /dev/null +++ b/schemas/renderflow-ai-skill-v1.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-ai-skill-v1.schema.json", + "title": "Renderflow reviewed AI skill specification v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "id", "version", "purpose", "artifact_families", "operations", "input_modalities", "output_modalities", "requires_json_schema", "input_schema", "output_schema", "templates", "variables", "max_rendered_prompt_bytes", "generation", "budgets", "hygiene", "approval", "provenance", "redistribution_notes", "license_notes", "fixture"], + "properties": { + "schema_version": {"const": "renderflow.ai-skill/v1"}, + "id": {"type": "string", "pattern": "^skill\\.[A-Za-z0-9._-]+$"}, + "version": {"type": "string", "minLength": 1}, "purpose": {"type": "string", "minLength": 1}, + "artifact_families": {"type": "array", "minItems": 1, "items": {"type": "string"}}, + "operations": {"type": "array", "minItems": 1, "items": {"enum": ["generation", "editing", "extraction", "classification", "transcription", "embeddings", "multimodal_reasoning", "schema_constrained_output"]}}, + "input_modalities": {"type": "array", "minItems": 1, "items": {"enum": ["text", "image", "audio", "video", "document", "structured_json", "artifact_dna", "embeddings", "mask", "metadata"]}}, + "output_modalities": {"type": "array", "minItems": 1, "items": {"enum": ["text", "image", "audio", "video", "document", "structured_json", "artifact_dna", "embeddings", "mask", "metadata"]}}, + "requires_json_schema": {"type": "boolean"}, "input_schema": {"type": "object"}, "output_schema": {"type": "object"}, + "templates": {"type": "object", "additionalProperties": false, "required": ["system", "instruction", "prompt"], "properties": {"system": {"type": "string"}, "instruction": {"type": "string"}, "prompt": {"type": "string"}}}, + "variables": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["name", "required", "max_bytes"], "properties": {"name": {"type": "string", "pattern": "^[A-Za-z0-9_]+$"}, "required": {"type": "boolean"}, "sensitive": {"type": "boolean"}, "max_bytes": {"type": "integer", "minimum": 1}}}}, + "max_rendered_prompt_bytes": {"type": "integer", "minimum": 1}, + "generation": {"type": "object", "additionalProperties": false, "properties": {"temperature": {"type": "number", "minimum": 0}, "max_tokens": {"type": "integer", "minimum": 1}, "seed": {"type": "integer", "minimum": 0}, "top_p": {"type": "number", "minimum": 0, "maximum": 1}, "stop": {"type": "array", "items": {"type": "string"}}, "provider_extensions": {"type": "object"}}}, + "budgets": {"type": "object", "additionalProperties": false, "required": ["network", "remote_execution", "max_input_bytes", "max_output_bytes", "max_tokens", "max_duration_ms", "max_retries"], "properties": {"network": {"type": "boolean"}, "remote_execution": {"type": "boolean"}, "max_input_bytes": {"type": "integer", "minimum": 1}, "max_output_bytes": {"type": "integer", "minimum": 1}, "max_tokens": {"type": "integer", "minimum": 1}, "max_duration_ms": {"type": "integer", "minimum": 1}, "max_retries": {"type": "integer", "minimum": 0}, "max_cost_microunits": {"type": "integer", "minimum": 0}}}, + "hygiene": {"type": "object", "additionalProperties": false, "required": ["policy_id", "scan_secrets", "pii_action", "protected_reference_action", "allow_private_remote_input", "retain_raw_prompts", "post_output_review"], "properties": {"policy_id": {"type": "string"}, "scan_secrets": {"type": "boolean"}, "pii_action": {"enum": ["block", "rewrite", "review"]}, "protected_reference_action": {"enum": ["block", "rewrite", "review"]}, "protected_references": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["term"], "properties": {"term": {"type": "string", "minLength": 1}, "descriptive_replacement": {"type": "string"}}}}, "allow_private_remote_input": {"type": "boolean"}, "retain_raw_prompts": {"const": false}, "post_output_review": {"type": "boolean"}}}, + "approval": {"type": "object", "additionalProperties": false, "required": ["initial_state", "human_review_required", "validators"], "properties": {"initial_state": {"enum": ["candidate", "approved"]}, "human_review_required": {"type": "boolean"}, "validators": {"type": "array", "minItems": 1, "items": {"type": "string"}}}}, + "provenance": {"type": "object", "additionalProperties": false, "required": ["cache_identity_fields", "evidence_fields", "redact_raw_inputs", "redact_raw_prompts"], "properties": {"cache_identity_fields": {"type": "array", "minItems": 1, "items": {"type": "string"}}, "evidence_fields": {"type": "array", "minItems": 1, "items": {"type": "string"}}, "redact_raw_inputs": {"type": "boolean"}, "redact_raw_prompts": {"const": true}}}, + "redistribution_notes": {"type": "string"}, "license_notes": {"type": "string"}, "fixture": {} + } +}