From 1363a59a8e2fda272e72141a0674fafff644cc78 Mon Sep 17 00:00:00 2001 From: insome Date: Thu, 30 Apr 2026 09:18:22 +0800 Subject: [PATCH] feat(backend): add AIGC generation job API skeleton --- 04-backend/harness-core/src/bin/gateway.rs | 121 ++ 04-backend/harness-core/src/lib.rs | 1 + 04-backend/harness-core/src/module_audit.rs | 18 +- .../harness-core/src/module_generation.rs | 1357 +++++++++++++++++ 04-backend/openapi.yaml | 684 ++++++++- 5 files changed, 2177 insertions(+), 4 deletions(-) create mode 100644 04-backend/harness-core/src/module_generation.rs diff --git a/04-backend/harness-core/src/bin/gateway.rs b/04-backend/harness-core/src/bin/gateway.rs index 0ebcb764..323d8609 100644 --- a/04-backend/harness-core/src/bin/gateway.rs +++ b/04-backend/harness-core/src/bin/gateway.rs @@ -33,6 +33,11 @@ use insomeos_harness_core::{ ModuleFileMetadata, ModuleFileNode, ModuleFileService, MoveFileRequest, ShareFileRequest, ShareFileResponse, UpdateFileContentRequest, UpdateModuleFileRequest, }, + module_generation::{ + GenerationActionRequest, GenerationArtifactsResponse, GenerationInput, GenerationJob, + GenerationJobListResponse, GenerationJobQuery, GenerationReviewRequest, + ModuleGenerationService, + }, module_lifecycle::{ ApprovalDecisionRequest, CreateModuleTransactionRequest, ModuleLifecycleService, ModuleTransaction, ModuleTransitionRequest, TransactionListQuery, @@ -48,6 +53,7 @@ struct AppState { router: Arc, cfg: Arc, files: ModuleFileService, + generation: ModuleGenerationService, lifecycle: ModuleLifecycleService, audit: Arc, } @@ -108,12 +114,14 @@ async fn main() -> Result<()> { let audit = Arc::new(ModuleAuditService::new()); let files = ModuleFileService::new(Arc::clone(&audit)); + let generation = ModuleGenerationService::new(Arc::clone(&audit)); let lifecycle = ModuleLifecycleService::new(Arc::clone(&audit)); let state = AppState { router, cfg: Arc::new(cfg.clone()), files, + generation, lifecycle, audit, }; @@ -170,6 +178,38 @@ async fn main() -> Result<()> { post(reject_transaction_handler), ) .route("/v1/audit-events", get(list_audit_events_handler)) + .route( + "/v1/generation/jobs", + get(list_generation_jobs_handler).post(create_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}", + get(get_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}/plan", + post(plan_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}/run", + post(run_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}/review", + post(review_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}/approve", + post(approve_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}/reject", + post(reject_generation_job_handler), + ) + .route( + "/v1/generation/jobs/{job_id}/artifacts", + get(list_generation_artifacts_handler), + ) .with_state(state) .layer(cors) .layer(TraceLayer::new_for_http()); @@ -374,6 +414,87 @@ async fn list_audit_events_handler( })) } +async fn list_generation_jobs_handler( + State(state): State, + Query(query): Query, +) -> Result> { + let page = state.generation.list_jobs(&query)?; + Ok(Json(GenerationJobListResponse { + total: page.items.len(), + jobs: page.items, + page_info: page.page_info, + })) +} + +async fn create_generation_job_handler( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json)> { + let job = state.generation.create_job(req)?; + Ok((StatusCode::CREATED, Json(job))) +} + +async fn get_generation_job_handler( + State(state): State, + Path(job_id): Path, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.get_job(job_id).map(Json) +} + +async fn plan_generation_job_handler( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.plan_job(job_id, req).map(Json) +} + +async fn run_generation_job_handler( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.run_job(job_id, req).map(Json) +} + +async fn review_generation_job_handler( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.review_job(job_id, req).map(Json) +} + +async fn approve_generation_job_handler( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.approve_job(job_id, req).map(Json) +} + +async fn reject_generation_job_handler( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.reject_job(job_id, req).map(Json) +} + +async fn list_generation_artifacts_handler( + State(state): State, + Path(job_id): Path, +) -> Result> { + let job_id = parse_uuid(&job_id, "job_id")?; + state.generation.list_artifacts(job_id).map(Json) +} + fn parse_uuid(value: &str, field: &str) -> Result { value .parse() diff --git a/04-backend/harness-core/src/lib.rs b/04-backend/harness-core/src/lib.rs index 16a863cb..84072137 100644 --- a/04-backend/harness-core/src/lib.rs +++ b/04-backend/harness-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod error; pub mod inference; pub mod module_audit; pub mod module_files; +pub mod module_generation; pub mod module_lifecycle; pub mod module_pagination; pub mod module_registry; diff --git a/04-backend/harness-core/src/module_audit.rs b/04-backend/harness-core/src/module_audit.rs index 83f44b67..3870fe07 100644 --- a/04-backend/harness-core/src/module_audit.rs +++ b/04-backend/harness-core/src/module_audit.rs @@ -17,7 +17,7 @@ use crate::{ module_registry::normalize_module_id, }; -/// Auditable action emitted by file, lifecycle, approval, and future workflow services. +/// Auditable action emitted by file, lifecycle, approval, generation, and workflow services. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuditEventKind { @@ -43,6 +43,22 @@ pub enum AuditEventKind { TransactionApproved, /// A lifecycle transaction was rejected. TransactionRejected, + /// A generation job was created. + GenerationJobCreated, + /// A generation job was planned. + GenerationJobPlanned, + /// A generation job mock pipeline was run. + GenerationJobRun, + /// A generation job was reviewed. + GenerationJobReviewed, + /// A generation job was approved. + GenerationJobApproved, + /// A generation job was rejected. + GenerationJobRejected, + /// A generation artifact was created. + GenerationArtifactCreated, + /// A generation pipeline stage completed. + GenerationStageCompleted, } /// Append-only audit event exposed through `GET /v1/audit-events`. diff --git a/04-backend/harness-core/src/module_generation.rs b/04-backend/harness-core/src/module_generation.rs new file mode 100644 index 00000000..4ba15b5d --- /dev/null +++ b/04-backend/harness-core/src/module_generation.rs @@ -0,0 +1,1357 @@ +//! AI-native multimodal engineering generation service. +//! +//! This is an in-memory API skeleton for `ArchIToken` AIGC generation and +//! conversion jobs. It establishes the backend contract for frontend and +//! third-party callers without connecting to real model providers, databases, +//! or object storage. + +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + error::{HarnessError, Result}, + module_audit::{AuditEventInput, AuditEventKind, ModuleAuditService}, + module_pagination::{ListPage, PageInfo, paginate}, + module_registry::normalize_module_id, +}; + +const DEFAULT_ACTOR: &str = "system"; +const MOCK_SKILL_VERSION: &str = "0.1.0"; + +/// Supported AIGC generation and conversion modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationMode { + /// Generate an image from text. + TextToImage, + /// Generate a document from text. + TextToDocument, + /// Generate a spreadsheet from text. + TextToSpreadsheet, + /// Generate a PDF from text. + TextToPdf, + /// Generate a presentation from text. + TextToPpt, + /// Generate a mindmap from text. + TextToMindmap, + /// Generate a flowchart from text. + TextToFlowchart, + /// Generate a Gantt chart from text. + TextToGantt, + /// Generate a floorplan from text. + TextToFloorplan, + /// Generate CAD drawings from text. + TextToCad, + /// Generate a BIM model from text. + TextToBim, + /// Generate a digital twin from text. + TextToDigitalTwin, + /// Generate a video from images. + ImageToVideo, + /// Generate a PDF drawing from images. + ImageToPdfDrawing, + /// Generate CAD drawings from images. + ImageToCad, + /// Generate a BIM model from images. + ImageToBim, + /// Generate a digital twin from images. + ImageToDigitalTwin, + /// Generate a BIM model from video. + VideoToBim, + /// Generate a digital twin from video. + VideoToDigitalTwin, + /// Generate a point cloud from video. + VideoToPointCloud, + /// Convert CAD drawings into a BIM model. + CadToBim, + /// Convert CAD drawings into a digital twin. + CadToDigitalTwin, + /// Convert PDF drawings into a BIM model. + PdfDrawingToBim, + /// Convert PDF drawings into a digital twin. + PdfDrawingToDigitalTwin, + /// Export drawings to images. + DrawingToImage, + /// Export drawings to PDF. + DrawingToPdf, + /// Export model data to tables. + ModelToTable, + /// Export model views to drawings. + ModelToDrawing, + /// Export model views to images. + ModelToImage, +} + +impl GenerationMode { + /// Complete conversion matrix required by the API contract. + pub const ALL: [Self; 29] = [ + Self::TextToImage, + Self::TextToDocument, + Self::TextToSpreadsheet, + Self::TextToPdf, + Self::TextToPpt, + Self::TextToMindmap, + Self::TextToFlowchart, + Self::TextToGantt, + Self::TextToFloorplan, + Self::TextToCad, + Self::TextToBim, + Self::TextToDigitalTwin, + Self::ImageToVideo, + Self::ImageToPdfDrawing, + Self::ImageToCad, + Self::ImageToBim, + Self::ImageToDigitalTwin, + Self::VideoToBim, + Self::VideoToDigitalTwin, + Self::VideoToPointCloud, + Self::CadToBim, + Self::CadToDigitalTwin, + Self::PdfDrawingToBim, + Self::PdfDrawingToDigitalTwin, + Self::DrawingToImage, + Self::DrawingToPdf, + Self::ModelToTable, + Self::ModelToDrawing, + Self::ModelToImage, + ]; + + const fn output_kind(self) -> ArtifactKind { + match self { + Self::TextToImage | Self::DrawingToImage | Self::ModelToImage => ArtifactKind::Image, + Self::TextToDocument => ArtifactKind::Document, + Self::TextToSpreadsheet => ArtifactKind::Spreadsheet, + Self::TextToPdf | Self::DrawingToPdf => ArtifactKind::Pdf, + Self::TextToPpt => ArtifactKind::Ppt, + Self::TextToMindmap => ArtifactKind::Mindmap, + Self::TextToFlowchart => ArtifactKind::Flowchart, + Self::TextToGantt => ArtifactKind::Gantt, + Self::TextToFloorplan => ArtifactKind::Floorplan, + Self::TextToCad | Self::ImageToCad => ArtifactKind::Cad, + Self::TextToBim + | Self::ImageToBim + | Self::VideoToBim + | Self::CadToBim + | Self::PdfDrawingToBim => ArtifactKind::Bim, + Self::TextToDigitalTwin + | Self::ImageToDigitalTwin + | Self::VideoToDigitalTwin + | Self::CadToDigitalTwin + | Self::PdfDrawingToDigitalTwin => ArtifactKind::DigitalTwin, + Self::ImageToVideo => ArtifactKind::Video, + Self::ImageToPdfDrawing => ArtifactKind::PdfDrawing, + Self::VideoToPointCloud => ArtifactKind::PointCloud, + Self::ModelToTable => ArtifactKind::Table, + Self::ModelToDrawing => ArtifactKind::Drawing, + } + } + + const fn skill_id(self) -> &'static str { + match self { + Self::TextToImage => "text_to_image_mock_skill", + Self::TextToDocument => "text_to_document_mock_skill", + Self::TextToSpreadsheet => "text_to_spreadsheet_mock_skill", + Self::TextToPdf => "text_to_pdf_mock_skill", + Self::TextToPpt => "text_to_ppt_mock_skill", + Self::TextToMindmap => "text_to_mindmap_mock_skill", + Self::TextToFlowchart => "text_to_flowchart_mock_skill", + Self::TextToGantt => "text_to_gantt_mock_skill", + Self::TextToFloorplan => "text_to_floorplan_mock_skill", + Self::TextToCad => "text_to_cad_mock_skill", + Self::TextToBim => "text_to_bim_mock_skill", + Self::TextToDigitalTwin => "text_to_digital_twin_mock_skill", + Self::ImageToVideo => "image_to_video_mock_skill", + Self::ImageToPdfDrawing => "image_to_pdf_drawing_mock_skill", + Self::ImageToCad => "image_to_cad_mock_skill", + Self::ImageToBim => "image_to_bim_mock_skill", + Self::ImageToDigitalTwin => "image_to_digital_twin_mock_skill", + Self::VideoToBim => "video_to_bim_mock_skill", + Self::VideoToDigitalTwin => "video_to_digital_twin_mock_skill", + Self::VideoToPointCloud => "video_to_point_cloud_mock_skill", + Self::CadToBim => "cad_to_bim_mock_skill", + Self::CadToDigitalTwin => "cad_to_digital_twin_mock_skill", + Self::PdfDrawingToBim => "pdf_drawing_to_bim_mock_skill", + Self::PdfDrawingToDigitalTwin => "pdf_drawing_to_digital_twin_mock_skill", + Self::DrawingToImage => "drawing_to_image_mock_skill", + Self::DrawingToPdf => "drawing_to_pdf_mock_skill", + Self::ModelToTable => "model_to_table_mock_skill", + Self::ModelToDrawing => "model_to_drawing_mock_skill", + Self::ModelToImage => "model_to_image_mock_skill", + } + } +} + +/// Artifact kind accepted or produced by generation jobs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + /// Text prompt or extracted text. + Text, + /// Raster image. + Image, + /// Video asset. + Video, + /// Rich text document. + Document, + /// Spreadsheet. + Spreadsheet, + /// PDF document. + Pdf, + /// Presentation deck. + Ppt, + /// Mindmap. + Mindmap, + /// Flowchart. + Flowchart, + /// Gantt chart. + Gantt, + /// Floorplan. + Floorplan, + /// CAD drawing. + Cad, + /// BIM model. + Bim, + /// Digital twin scene. + DigitalTwin, + /// PDF drawing. + PdfDrawing, + /// Point cloud. + PointCloud, + /// Generic drawing export. + Drawing, + /// Tabular model export. + Table, + /// Generic engineering model input. + Model, +} + +/// Lifecycle state for generated artifacts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactStatus { + /// Input artifact supplied by caller. + Input, + /// Preview artifact, not valid for production use. + Preview, + /// Draft artifact awaiting approval. + Draft, + /// Approved artifact. + Approved, + /// Rejected artifact. + Rejected, + /// Archived artifact. + Archived, +} + +/// File or object reference used by a generation job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Artifact { + /// Artifact id. + pub id: Uuid, + /// Artifact kind. + pub kind: ArtifactKind, + /// Artifact lifecycle status. + pub status: ArtifactStatus, + /// Optional object-store URI. Current skeleton uses `memory://` URIs. + pub object_uri: Option, + /// Stable file-system reference for frontend and third-party callers. + pub file_reference: String, + /// Artifact schema reference. + pub schema_ref: String, + /// Artifact version. + pub version: u32, + /// Optional content hash. + pub hash: Option, + /// Small structured metadata for previews and tests. + pub metadata: serde_json::Value, +} + +/// Input used to create a generation job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationInput { + /// Active module id or accepted legacy alias. + pub module_id: String, + /// Requested conversion mode. + pub mode: GenerationMode, + /// Natural-language prompt or task brief. + pub prompt: String, + /// Actor creating the job. + pub actor: Option, + /// Optional input artifacts. + pub input_artifacts: Option>, + /// Optional constraints passed to planner and mock skill. + pub constraints: Option, +} + +/// Output summary produced by the mock generation pipeline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationOutput { + /// Output artifacts created by the mock generator. + pub artifacts: Vec, + /// Human-readable output summary. + pub summary: String, + /// Distinct generator id. + pub generator_id: String, + /// Distinct evaluator id. + pub evaluator_id: String, + /// Whether deterministic rule checking passed. + pub rule_check_passed: bool, + /// Whether schema validation passed. + pub schema_validation_passed: bool, +} + +/// Skill registry contract embedded in a generation job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillSpec { + /// Stable skill id. + pub id: String, + /// Skill version. + pub version: String, + /// Skill purpose. + pub description: String, + /// Input schema reference. + pub input_schema: String, + /// Output schema reference. + pub output_schema: String, + /// Tool sandbox profile. + pub sandbox_profile: String, + /// Commercial license policy attached to this skill. + pub license_policy: String, +} + +/// MCP tool contract selected by the mock `WorkflowRouter`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpToolSpec { + /// MCP tool name. + pub name: String, + /// MCP tool version. + pub version: String, + /// Tool capability. + pub capability: String, + /// Input schema reference. + pub input_schema: String, + /// Output schema reference. + pub output_schema: String, + /// Permission scope required for invocation. + pub permission_scope: String, +} + +/// Model routing decision made by the mock `WorkflowRouter`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelRoute { + /// Provider id. The skeleton always uses `mock`. + pub provider: String, + /// Model id. The skeleton never calls the model. + pub model: String, + /// Routing reason. + pub reason: String, + /// Privacy tier. + pub privacy_tier: String, + /// Cost tier. + pub cost_tier: String, +} + +/// Required generation pipeline stage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationStage { + /// Planner stage. + Planner, + /// Generator stage. + Generator, + /// Independent evaluator stage. + Evaluator, + /// Deterministic rule checker stage. + RuleChecker, + /// Schema validator stage. + SchemaValidator, + /// Approver stage. + Approver, +} + +/// Review decision produced after evaluator output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationReviewDecision { + /// Review accepts the draft for approval. + Approved, + /// Review rejects the generated artifact. + Rejected, + /// Review requests another debug or generation pass. + NeedsChanges, +} + +/// Human or active-review record for a generation job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationReview { + /// Review id. + pub id: Uuid, + /// Reviewer id. + pub reviewer: String, + /// Review decision. + pub decision: GenerationReviewDecision, + /// Review comment. + pub comment: Option, + /// Whether this was active review rather than final approval. + pub active_review: bool, + /// Review timestamp. + pub created_at: DateTime, +} + +/// Trace entry emitted for each pipeline stage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationTrace { + /// Trace id. + pub id: Uuid, + /// Pipeline stage. + pub stage: GenerationStage, + /// Actor, skill, tool, or system component that emitted the trace. + pub actor: String, + /// Human-readable trace summary. + pub summary: String, + /// Structured stage metadata. + pub metadata: serde_json::Value, + /// Trace timestamp. + pub created_at: DateTime, +} + +/// Generation job lifecycle status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GenerationJobStatus { + /// Job accepted but not planned. + Queued, + /// Planner completed. + Planned, + /// Mock generator/evaluator/checkers are running. + Running, + /// Waiting for active review. + PendingReview, + /// Waiting for final approval. + PendingApproval, + /// Approved and usable by downstream modules. + Approved, + /// Rejected and not usable by downstream modules. + Rejected, + /// Failed due to validation or internal error. + Failed, + /// Archived job. + Archived, +} + +/// Generation job returned by API handlers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationJob { + /// Job id. + pub id: Uuid, + /// Active module id after alias normalization. + pub module_id: String, + /// Requested conversion mode. + pub mode: GenerationMode, + /// Current job status. + pub status: GenerationJobStatus, + /// Original caller input. + pub input: GenerationInput, + /// Output from the mock pipeline. + pub output: Option, + /// Selected skill contract. + pub skill: SkillSpec, + /// Selected MCP tools. + pub mcp_tools: Vec, + /// Selected model route. + pub model_route: ModelRoute, + /// Ordered pipeline traces. + pub traces: Vec, + /// Active-review records. + pub reviews: Vec, + /// Input and output artifacts. + pub artifacts: Vec, + /// Actor that created the job. + pub actor: String, + /// Creation timestamp. + pub created_at: DateTime, + /// Last update timestamp. + pub updated_at: DateTime, +} + +/// Query shape used by `GET /v1/generation/jobs`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct GenerationJobQuery { + /// Optional module id or accepted legacy alias. + pub module_id: Option, + /// Optional job status filter. + pub status: Option, + /// Optional generation mode filter. + pub mode: Option, + /// Optional page size. + pub limit: Option, + /// Optional numeric cursor offset. + pub cursor: Option, +} + +/// Generic action request used by plan, run, approve, and reject. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationActionRequest { + /// Actor performing the action. + pub actor: Option, + /// Optional action comment. + pub comment: Option, +} + +/// Review request for active review after mock evaluation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationReviewRequest { + /// Reviewer id. + pub reviewer: String, + /// Review decision. + pub decision: GenerationReviewDecision, + /// Optional review comment. + pub comment: Option, +} + +/// List response used by `GET /v1/generation/jobs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationJobListResponse { + /// Jobs included in this page. + pub jobs: Vec, + /// Number of jobs in this page. + pub total: usize, + /// Pagination metadata. + pub page_info: PageInfo, +} + +/// Artifact list response for one generation job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerationArtifactsResponse { + /// Related job id. + pub job_id: Uuid, + /// Artifacts associated with this job. + pub artifacts: Vec, +} + +/// In-memory generation service. +#[derive(Debug, Clone)] +pub struct ModuleGenerationService { + jobs: Arc>>, + audit: Arc, +} + +impl ModuleGenerationService { + /// Create an empty generation service. + #[must_use] + pub fn new(audit: Arc) -> Self { + Self { + jobs: Arc::new(RwLock::new(HashMap::new())), + audit, + } + } + + /// Create a queued generation job. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the module id cannot be normalized + /// and [`HarnessError::InvalidInput`] when the prompt is empty. + pub fn create_job(&self, mut input: GenerationInput) -> Result { + let module_id = normalize_module_id(&input.module_id) + .ok_or_else(|| HarnessError::NotFound(format!("module_id={}", input.module_id)))?; + if input.prompt.trim().is_empty() { + return Err(HarnessError::InvalidInput("prompt is required".to_owned())); + } + + module_id.as_str().clone_into(&mut input.module_id); + let actor = input + .actor + .clone() + .unwrap_or_else(|| DEFAULT_ACTOR.to_owned()); + let now = Utc::now(); + let artifacts = normalize_input_artifacts(input.input_artifacts.take()); + input.input_artifacts = Some(artifacts.clone()); + + let job = GenerationJob { + id: Uuid::new_v4(), + module_id: module_id.as_str().to_owned(), + mode: input.mode, + status: GenerationJobStatus::Queued, + skill: skill_for(input.mode), + mcp_tools: vec![mcp_tool_for(input.mode)], + model_route: model_route_for(input.mode), + input, + output: None, + traces: Vec::new(), + reviews: Vec::new(), + artifacts, + actor, + created_at: now, + updated_at: now, + }; + self.jobs.write().insert(job.id, job.clone()); + self.audit_job( + &job, + AuditEventKind::GenerationJobCreated, + job.actor.clone(), + "generation job created", + json!({ "mode": job.mode }), + ); + Ok(job) + } + + /// List generation jobs with optional filters. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the optional module id cannot be normalized. + pub fn list_jobs(&self, query: &GenerationJobQuery) -> Result> { + let normalized = query + .module_id + .as_deref() + .map(|id| { + normalize_module_id(id) + .ok_or_else(|| HarnessError::NotFound(format!("module_id={id}"))) + }) + .transpose()?; + let items: Vec = self + .jobs + .read() + .values() + .filter(|job| { + normalized + .as_ref() + .is_none_or(|module_id| job.module_id == module_id.as_str()) + }) + .filter(|job| query.status.is_none_or(|status| job.status == status)) + .filter(|job| query.mode.is_none_or(|mode| job.mode == mode)) + .cloned() + .collect(); + paginate(&items, query.limit, query.cursor.as_deref()) + } + + /// Get one generation job. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown. + pub fn get_job(&self, job_id: Uuid) -> Result { + self.jobs + .read() + .get(&job_id) + .cloned() + .ok_or_else(|| HarnessError::NotFound(format!("generation_job_id={job_id}"))) + } + + /// Run the planner stage. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown and + /// [`HarnessError::InvalidInput`] when the job is not queued. + pub fn plan_job(&self, job_id: Uuid, req: GenerationActionRequest) -> Result { + self.mutate_job(job_id, |job| { + ensure_status(job, &[GenerationJobStatus::Queued])?; + job.status = GenerationJobStatus::Planned; + append_trace( + job, + GenerationStage::Planner, + req.actor.as_deref().unwrap_or(DEFAULT_ACTOR), + "planner created a deterministic mock execution plan", + json!({ + "mode": job.mode, + "skillId": job.skill.id, + "comment": req.comment, + "sequence": [ + "planner", + "generator", + "evaluator", + "rule_checker", + "schema_validator", + "approver" + ] + }), + ); + let actor = req.actor.unwrap_or_else(|| DEFAULT_ACTOR.to_owned()); + Ok(vec![ + AuditSpec::new( + AuditEventKind::GenerationJobPlanned, + actor.clone(), + "generation job planned", + json!({ "stage": GenerationStage::Planner }), + ), + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor, + "generation planner stage completed", + json!({ "stage": GenerationStage::Planner }), + ), + ]) + }) + } + + /// Run the mock generator, evaluator, rule checker, and schema validator. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown and + /// [`HarnessError::InvalidInput`] when the job was not planned. + pub fn run_job(&self, job_id: Uuid, req: GenerationActionRequest) -> Result { + self.mutate_job(job_id, |job| { + ensure_status(job, &[GenerationJobStatus::Planned])?; + job.status = GenerationJobStatus::Running; + let actor = req.actor.as_deref().unwrap_or(DEFAULT_ACTOR); + append_trace( + job, + GenerationStage::Generator, + "mock_generator_v1", + "mock generator produced an artifact reference", + json!({ "actor": actor, "selfEvaluation": false }), + ); + + let artifact = generated_artifact(job); + job.artifacts.push(artifact.clone()); + append_trace( + job, + GenerationStage::Evaluator, + "mock_evaluator_v1", + "independent mock evaluator reviewed the generated artifact", + json!({ + "generatorId": "mock_generator_v1", + "evaluatorId": "mock_evaluator_v1", + "generatorSelfEvaluated": false + }), + ); + append_trace( + job, + GenerationStage::RuleChecker, + "mock_rule_checker_v1", + "deterministic rule checker passed", + json!({ "passed": true }), + ); + append_trace( + job, + GenerationStage::SchemaValidator, + "mock_schema_validator_v1", + "artifact schema validator passed", + json!({ "schemaRef": artifact.schema_ref, "passed": true }), + ); + job.output = Some(GenerationOutput { + artifacts: vec![artifact], + summary: "mock generation completed without external model calls".to_owned(), + generator_id: "mock_generator_v1".to_owned(), + evaluator_id: "mock_evaluator_v1".to_owned(), + rule_check_passed: true, + schema_validation_passed: true, + }); + job.status = GenerationJobStatus::PendingReview; + let actor = req.actor.unwrap_or_else(|| DEFAULT_ACTOR.to_owned()); + Ok(vec![ + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor.clone(), + "generation generator stage completed", + json!({ "stage": GenerationStage::Generator }), + ), + AuditSpec::new( + AuditEventKind::GenerationArtifactCreated, + actor.clone(), + "generation artifact created", + json!({ "mode": job.mode, "artifactCount": 1 }), + ), + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor.clone(), + "generation evaluator stage completed", + json!({ "stage": GenerationStage::Evaluator, "generatorSelfEvaluated": false }), + ), + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor.clone(), + "generation rule checker stage completed", + json!({ "stage": GenerationStage::RuleChecker, "passed": true }), + ), + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor.clone(), + "generation schema validator stage completed", + json!({ "stage": GenerationStage::SchemaValidator, "passed": true }), + ), + AuditSpec::new( + AuditEventKind::GenerationJobRun, + actor, + "generation mock pipeline completed", + json!({ + "mode": job.mode, + "artifactCount": job.artifacts.len(), + "requiresReview": true + }), + ), + ]) + }) + } + + /// Append an active-review record after evaluator output. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown and + /// [`HarnessError::InvalidInput`] when review is not allowed. + pub fn review_job(&self, job_id: Uuid, req: GenerationReviewRequest) -> Result { + self.mutate_job(job_id, |job| { + ensure_status(job, &[GenerationJobStatus::PendingReview])?; + let review = GenerationReview { + id: Uuid::new_v4(), + reviewer: req.reviewer.clone(), + decision: req.decision, + comment: req.comment.clone(), + active_review: true, + created_at: Utc::now(), + }; + job.reviews.push(review); + job.status = match req.decision { + GenerationReviewDecision::Approved => GenerationJobStatus::PendingApproval, + GenerationReviewDecision::NeedsChanges => GenerationJobStatus::PendingReview, + GenerationReviewDecision::Rejected => GenerationJobStatus::Rejected, + }; + Ok(vec![AuditSpec::new( + AuditEventKind::GenerationJobReviewed, + req.reviewer, + "generation job active review completed", + json!({ "decision": req.decision, "comment": req.comment }), + )]) + }) + } + + /// Approve a reviewed generation job. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown and + /// [`HarnessError::InvalidInput`] when approval is not allowed. + pub fn approve_job(&self, job_id: Uuid, req: GenerationActionRequest) -> Result { + self.mutate_job(job_id, |job| { + ensure_status(job, &[GenerationJobStatus::PendingApproval])?; + job.status = GenerationJobStatus::Approved; + set_generated_artifact_status(job, ArtifactStatus::Approved); + append_trace( + job, + GenerationStage::Approver, + req.actor.as_deref().unwrap_or(DEFAULT_ACTOR), + "approver accepted generated artifacts", + json!({ "comment": req.comment }), + ); + let actor = req.actor.unwrap_or_else(|| DEFAULT_ACTOR.to_owned()); + Ok(vec![ + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor.clone(), + "generation approver stage completed", + json!({ "stage": GenerationStage::Approver, "decision": "approved" }), + ), + AuditSpec::new( + AuditEventKind::GenerationJobApproved, + actor, + "generation job approved", + json!({ "status": job.status }), + ), + ]) + }) + } + + /// Reject a generation job. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown. + pub fn reject_job(&self, job_id: Uuid, req: GenerationActionRequest) -> Result { + self.mutate_job(job_id, |job| { + if matches!( + job.status, + GenerationJobStatus::Approved | GenerationJobStatus::Archived + ) { + return Err(HarnessError::InvalidInput(format!( + "cannot reject generation job from {:?}", + job.status + ))); + } + job.status = GenerationJobStatus::Rejected; + set_generated_artifact_status(job, ArtifactStatus::Rejected); + append_trace( + job, + GenerationStage::Approver, + req.actor.as_deref().unwrap_or(DEFAULT_ACTOR), + "approver rejected generated artifacts", + json!({ "comment": req.comment }), + ); + let actor = req.actor.unwrap_or_else(|| DEFAULT_ACTOR.to_owned()); + Ok(vec![ + AuditSpec::new( + AuditEventKind::GenerationStageCompleted, + actor.clone(), + "generation approver stage completed", + json!({ "stage": GenerationStage::Approver, "decision": "rejected" }), + ), + AuditSpec::new( + AuditEventKind::GenerationJobRejected, + actor, + "generation job rejected", + json!({ "status": job.status }), + ), + ]) + }) + } + + /// List artifacts attached to one generation job. + /// + /// # Errors + /// Returns [`HarnessError::NotFound`] when the job id is unknown. + pub fn list_artifacts(&self, job_id: Uuid) -> Result { + let job = self.get_job(job_id)?; + Ok(GenerationArtifactsResponse { + job_id, + artifacts: job.artifacts, + }) + } + + fn mutate_job(&self, job_id: Uuid, mutate: F) -> Result + where + F: FnOnce(&mut GenerationJob) -> Result>, + { + let (job, audit_specs) = { + let mut jobs = self.jobs.write(); + let job = jobs + .get_mut(&job_id) + .ok_or_else(|| HarnessError::NotFound(format!("generation_job_id={job_id}")))?; + let audit_specs = mutate(job)?; + job.updated_at = Utc::now(); + let job = job.clone(); + drop(jobs); + (job, audit_specs) + }; + for audit in audit_specs { + self.audit_job( + &job, + audit.action, + audit.actor, + audit.summary, + audit.metadata, + ); + } + Ok(job) + } + + fn audit_job( + &self, + job: &GenerationJob, + action: AuditEventKind, + actor: String, + summary: &str, + metadata: serde_json::Value, + ) { + let _event = self.audit.append(AuditEventInput { + module_id: job.module_id.clone(), + actor, + action, + target_type: "generation_job".to_owned(), + target_id: job.id.to_string(), + summary: summary.to_owned(), + metadata, + }); + } +} + +struct AuditSpec { + action: AuditEventKind, + actor: String, + summary: &'static str, + metadata: serde_json::Value, +} + +impl AuditSpec { + const fn new( + action: AuditEventKind, + actor: String, + summary: &'static str, + metadata: serde_json::Value, + ) -> Self { + Self { + action, + actor, + summary, + metadata, + } + } +} + +fn ensure_status(job: &GenerationJob, allowed: &[GenerationJobStatus]) -> Result<()> { + if allowed.contains(&job.status) { + return Ok(()); + } + Err(HarnessError::InvalidInput(format!( + "generation job status {:?} does not allow this action", + job.status + ))) +} + +fn append_trace( + job: &mut GenerationJob, + stage: GenerationStage, + actor: &str, + summary: &str, + metadata: serde_json::Value, +) { + job.traces.push(GenerationTrace { + id: Uuid::new_v4(), + stage, + actor: actor.to_owned(), + summary: summary.to_owned(), + metadata, + created_at: Utc::now(), + }); +} + +fn normalize_input_artifacts(artifacts: Option>) -> Vec { + artifacts + .unwrap_or_default() + .into_iter() + .map(|mut artifact| { + artifact.status = ArtifactStatus::Input; + artifact + }) + .collect() +} + +fn generated_artifact(job: &GenerationJob) -> Artifact { + let kind = job.mode.output_kind(); + let id = Uuid::new_v4(); + Artifact { + id, + kind, + status: generated_status(kind), + object_uri: Some(format!("memory://generation/{}/{}", job.id, id)), + file_reference: format!("generation://files/{id}"), + schema_ref: schema_ref_for(kind).to_owned(), + version: 1, + hash: Some(format!("mock-{}-{id}", job.mode.skill_id())), + metadata: json!({ + "mode": job.mode, + "sourceJobId": job.id, + "storage": "in_memory_stub", + "modelCalls": 0, + "generator": "mock_generator_v1", + "evaluator": "mock_evaluator_v1" + }), + } +} + +const fn generated_status(kind: ArtifactKind) -> ArtifactStatus { + match kind { + ArtifactKind::Cad + | ArtifactKind::Bim + | ArtifactKind::DigitalTwin + | ArtifactKind::PointCloud => ArtifactStatus::Preview, + _ => ArtifactStatus::Draft, + } +} + +fn set_generated_artifact_status(job: &mut GenerationJob, status: ArtifactStatus) { + for artifact in &mut job.artifacts { + if artifact.status != ArtifactStatus::Input { + artifact.status = status; + } + } + if let Some(output) = &mut job.output { + for artifact in &mut output.artifacts { + artifact.status = status; + } + } +} + +fn skill_for(mode: GenerationMode) -> SkillSpec { + let output_kind = mode.output_kind(); + SkillSpec { + id: mode.skill_id().to_owned(), + version: MOCK_SKILL_VERSION.to_owned(), + description: "mock AIGC engineering generation skill; no external model call".to_owned(), + input_schema: "generation.input.schema.v1".to_owned(), + output_schema: schema_ref_for(output_kind).to_owned(), + sandbox_profile: "mock_tool_sandbox_no_network".to_owned(), + license_policy: + "MIT/Apache-2.0/BSD preferred; GPL/AGPL/LGPL/SSPL/BUSL/Commons Clause denied".to_owned(), + } +} + +fn mcp_tool_for(mode: GenerationMode) -> McpToolSpec { + McpToolSpec { + name: "mock_generation_tool".to_owned(), + version: MOCK_SKILL_VERSION.to_owned(), + capability: mode.skill_id().trim_end_matches("_mock_skill").to_owned(), + input_schema: "generation.input.schema.v1".to_owned(), + output_schema: schema_ref_for(mode.output_kind()).to_owned(), + permission_scope: "generation:write".to_owned(), + } +} + +fn model_route_for(mode: GenerationMode) -> ModelRoute { + ModelRoute { + provider: "mock".to_owned(), + model: "mock-aigc-generator-v1".to_owned(), + reason: format!( + "WorkflowRouter selected local stub for {}; no external model API is called", + mode.skill_id() + ), + privacy_tier: "local_stub".to_owned(), + cost_tier: "zero".to_owned(), + } +} + +const fn schema_ref_for(kind: ArtifactKind) -> &'static str { + match kind { + ArtifactKind::Text => "artifact.text.schema.v1", + ArtifactKind::Image => "artifact.image.schema.v1", + ArtifactKind::Video => "artifact.video.schema.v1", + ArtifactKind::Document => "artifact.document.schema.v1", + ArtifactKind::Spreadsheet | ArtifactKind::Table => "artifact.table.schema.v1", + ArtifactKind::Pdf | ArtifactKind::PdfDrawing => "artifact.pdf.schema.v1", + ArtifactKind::Ppt => "artifact.presentation.schema.v1", + ArtifactKind::Mindmap => "artifact.mindmap.schema.v1", + ArtifactKind::Flowchart => "artifact.flowchart.schema.v1", + ArtifactKind::Gantt => "artifact.gantt.schema.v1", + ArtifactKind::Floorplan => "artifact.floorplan.schema.v1", + ArtifactKind::Cad | ArtifactKind::Drawing => "artifact.cad.schema.v1", + ArtifactKind::Bim | ArtifactKind::Model => "artifact.ifc.schema.v1", + ArtifactKind::DigitalTwin => "artifact.digital_twin.schema.v1", + ArtifactKind::PointCloud => "artifact.point_cloud.schema.v1", + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + use uuid::Uuid; + + use crate::module_audit::{AuditEventKind, AuditEventQuery, ModuleAuditService}; + + use super::{ + Artifact, ArtifactKind, ArtifactStatus, GenerationActionRequest, GenerationInput, + GenerationJobQuery, GenerationJobStatus, GenerationMode, GenerationReviewDecision, + GenerationReviewRequest, ModuleGenerationService, + }; + + fn service() -> (ModuleGenerationService, Arc) { + let audit = Arc::new(ModuleAuditService::new()); + (ModuleGenerationService::new(Arc::clone(&audit)), audit) + } + + fn input(mode: GenerationMode) -> GenerationInput { + GenerationInput { + module_id: "fabrication".to_owned(), + mode, + prompt: "Generate a production-ready preview".to_owned(), + actor: Some("planner".to_owned()), + input_artifacts: None, + constraints: None, + } + } + + #[test] + fn conversion_matrix_covers_required_modes() { + assert_eq!(GenerationMode::ALL.len(), 29); + assert!(GenerationMode::ALL.contains(&GenerationMode::TextToImage)); + assert!(GenerationMode::ALL.contains(&GenerationMode::TextToDigitalTwin)); + assert!(GenerationMode::ALL.contains(&GenerationMode::ImageToPdfDrawing)); + assert!(GenerationMode::ALL.contains(&GenerationMode::VideoToPointCloud)); + assert!(GenerationMode::ALL.contains(&GenerationMode::PdfDrawingToDigitalTwin)); + assert!(GenerationMode::ALL.contains(&GenerationMode::ModelToImage)); + } + + #[test] + fn job_runs_through_review_and_approval() { + let (service, audit) = service(); + let job = service + .create_job(input(GenerationMode::CadToBim)) + .expect("job should be created"); + assert_eq!(job.module_id, "production_manufacturing"); + assert_eq!(job.status, GenerationJobStatus::Queued); + + let job = service + .plan_job( + job.id, + GenerationActionRequest { + actor: Some("planner".to_owned()), + comment: None, + }, + ) + .expect("job should be planned"); + assert_eq!(job.status, GenerationJobStatus::Planned); + + let job = service + .run_job( + job.id, + GenerationActionRequest { + actor: Some("runner".to_owned()), + comment: None, + }, + ) + .expect("job should run"); + assert_eq!(job.status, GenerationJobStatus::PendingReview); + assert_eq!(job.traces.len(), 5); + assert_eq!(job.artifacts[0].kind, ArtifactKind::Bim); + assert_eq!(job.artifacts[0].status, ArtifactStatus::Preview); + assert_ne!( + job.output.as_ref().expect("output exists").generator_id, + job.output.as_ref().expect("output exists").evaluator_id + ); + + let job = service + .review_job( + job.id, + GenerationReviewRequest { + reviewer: "reviewer".to_owned(), + decision: GenerationReviewDecision::Approved, + comment: Some("ready for approval".to_owned()), + }, + ) + .expect("job should be reviewed"); + assert_eq!(job.status, GenerationJobStatus::PendingApproval); + + let job = service + .approve_job( + job.id, + GenerationActionRequest { + actor: Some("approver".to_owned()), + comment: None, + }, + ) + .expect("job should approve"); + assert_eq!(job.status, GenerationJobStatus::Approved); + assert_eq!(job.artifacts[0].status, ArtifactStatus::Approved); + + let events = audit + .list(&AuditEventQuery { + module_id: Some("production_manufacturing".to_owned()), + target_type: Some("generation_job".to_owned()), + target_id: Some(job.id.to_string()), + actor: None, + limit: Some(20), + cursor: None, + }) + .expect("audit list should work"); + assert!( + events + .items + .iter() + .any(|event| event.action == AuditEventKind::GenerationJobRun) + ); + assert!( + events + .items + .iter() + .any(|event| event.action == AuditEventKind::GenerationArtifactCreated) + ); + assert!( + events + .items + .iter() + .filter(|event| event.action == AuditEventKind::GenerationStageCompleted) + .count() + >= 6 + ); + assert!( + events + .items + .iter() + .any(|event| event.action == AuditEventKind::GenerationJobApproved) + ); + } + + #[test] + fn run_requires_plan_and_review_requires_run() { + let (service, _audit) = service(); + let job = service + .create_job(input(GenerationMode::TextToPdf)) + .expect("job should be created"); + + assert!( + service + .run_job( + job.id, + GenerationActionRequest { + actor: None, + comment: None, + }, + ) + .is_err() + ); + assert!( + service + .review_job( + job.id, + GenerationReviewRequest { + reviewer: "reviewer".to_owned(), + decision: GenerationReviewDecision::Approved, + comment: None, + }, + ) + .is_err() + ); + } + + #[test] + fn list_jobs_filters_by_status_and_mode() { + let (service, _audit) = service(); + let job = service + .create_job(input(GenerationMode::TextToDocument)) + .expect("job should be created"); + let _other = service + .create_job(input(GenerationMode::TextToImage)) + .expect("job should be created"); + let page = service + .list_jobs(&GenerationJobQuery { + module_id: Some("manufacturing".to_owned()), + status: Some(GenerationJobStatus::Queued), + mode: Some(GenerationMode::TextToDocument), + limit: Some(10), + cursor: None, + }) + .expect("list should work"); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].id, job.id); + } + + #[test] + fn input_artifacts_are_normalized_to_input_status() { + let (service, _audit) = service(); + let mut req = input(GenerationMode::ModelToImage); + req.input_artifacts = Some(vec![Artifact { + id: Uuid::new_v4(), + kind: ArtifactKind::Model, + status: ArtifactStatus::Draft, + object_uri: Some("memory://input/model".to_owned()), + file_reference: "module-file://model".to_owned(), + schema_ref: "artifact.ifc.schema.v1".to_owned(), + version: 1, + hash: None, + metadata: json!({}), + }]); + let job = service.create_job(req).expect("job should be created"); + assert_eq!(job.artifacts[0].status, ArtifactStatus::Input); + } + + #[test] + fn rejects_unknown_module() { + let (service, _audit) = service(); + let mut req = input(GenerationMode::TextToImage); + req.module_id = "unknown".to_owned(); + assert!(service.create_job(req).is_err()); + } +} diff --git a/04-backend/openapi.yaml b/04-backend/openapi.yaml index 577f037b..1cd2af25 100644 --- a/04-backend/openapi.yaml +++ b/04-backend/openapi.yaml @@ -35,6 +35,8 @@ tags: description: Module transaction state machine and approval APIs. Current implementation is an in-memory preview and will move behind TransactionStore. - name: module-audit description: Append-only module audit event APIs. Current implementation is an in-memory preview and will move behind EventStore. + - name: generation + description: AI-native multimodal engineering generation and conversion APIs. Current implementation is an in-memory mock pipeline and will move behind WorkflowRouter, Skill Registry, MCP Tool Registry, and StorageRouter. - name: agents description: Legacy agent orchestration (L4) - name: projects @@ -861,6 +863,287 @@ paths: "409": { $ref: "#/components/responses/Conflict" } "500": { $ref: "#/components/responses/InternalServerError" } + /v1/generation/jobs: + get: + operationId: listGenerationJobs + summary: List AIGC generation jobs + description: In-memory mock preview of AI-native multimodal engineering generation jobs. Production execution will move behind WorkflowRouter, Skill Registry, MCP Tool Registry, and StorageRouter. + tags: [generation] + parameters: + - { $ref: "#/components/parameters/GenerationModuleIdQuery" } + - { $ref: "#/components/parameters/GenerationStatusQuery" } + - { $ref: "#/components/parameters/GenerationModeQuery" } + - { $ref: "#/components/parameters/LimitParam" } + - { $ref: "#/components/parameters/CursorParam" } + responses: + "200": + description: Generation jobs + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJobListResponse" } + examples: + jobs: + value: + jobs: + - id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1001" + moduleId: production_manufacturing + mode: cad_to_bim + status: pending_review + input: + moduleId: production_manufacturing + mode: cad_to_bim + prompt: Convert approved CAD package to BIM preview. + actor: planner + inputArtifacts: [] + constraints: { targetSchema: artifact.ifc.schema.v1 } + skill: + id: cad_to_bim_mock_skill + version: 0.1.0 + description: mock AIGC engineering generation skill; no external model call + inputSchema: generation.input.schema.v1 + outputSchema: artifact.ifc.schema.v1 + sandboxProfile: mock_tool_sandbox_no_network + licensePolicy: MIT/Apache-2.0/BSD preferred; GPL/AGPL/LGPL/SSPL/BUSL/Commons Clause denied + mcpTools: [] + modelRoute: + provider: mock + model: mock-aigc-generator-v1 + reason: local stub route + privacyTier: local_stub + costTier: zero + traces: [] + reviews: [] + artifacts: [] + actor: planner + createdAt: "2026-04-30T00:00:00Z" + updatedAt: "2026-04-30T00:10:00Z" + total: 1 + pageInfo: + limit: 50 + nextCursor: null + hasMore: false + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + post: + operationId: createGenerationJob + summary: Create an AIGC generation job + description: Creates a queued in-memory generation job. No real model, database, or object storage is called. + tags: [generation] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationInput" } + examples: + cadToBim: + value: + moduleId: production_manufacturing + mode: cad_to_bim + prompt: Convert approved CAD package to BIM preview. + actor: planner + inputArtifacts: + - id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1101" + kind: cad + status: input + objectUri: memory://input/cad-package.dxf + fileReference: module-file://018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f0002 + schemaRef: artifact.cad.schema.v1 + version: 1 + hash: null + metadata: { layers: 12 } + constraints: + targetSchema: artifact.ifc.schema.v1 + responses: + "201": + description: Generation job created + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + get: + operationId: getGenerationJob + summary: Get one AIGC generation job + tags: [generation] + responses: + "200": + description: Generation job + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}/plan: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + post: + operationId: planGenerationJob + summary: Run Planner stage for a generation job + tags: [generation] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationActionRequest" } + examples: + plan: + value: + actor: planner + comment: prepare deterministic mock plan + responses: + "200": + description: Planned generation job + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}/run: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + post: + operationId: runGenerationJob + summary: Run mock Generator, Evaluator, RuleChecker, and SchemaValidator stages + description: This endpoint does not call real models. It creates deterministic mock artifacts and independent evaluator traces. + tags: [generation] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationActionRequest" } + examples: + run: + value: + actor: runner + comment: run local stub + responses: + "200": + description: Generation job after mock run + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}/review: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + post: + operationId: reviewGenerationJob + summary: Active-review a generated artifact before final approval + tags: [generation] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationReviewRequest" } + examples: + review: + value: + reviewer: reviewer + decision: approved + comment: evaluator report accepted + responses: + "200": + description: Reviewed generation job + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}/approve: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + post: + operationId: approveGenerationJob + summary: Approve generated artifacts + tags: [generation] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationActionRequest" } + examples: + approve: + value: + actor: approver + comment: approved for downstream lifecycle use + responses: + "200": + description: Approved generation job + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}/reject: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + post: + operationId: rejectGenerationJob + summary: Reject generated artifacts + tags: [generation] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationActionRequest" } + examples: + reject: + value: + actor: approver + comment: schema validator evidence is insufficient + responses: + "200": + description: Rejected generation job + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationJob" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + + /v1/generation/jobs/{job_id}/artifacts: + parameters: + - { $ref: "#/components/parameters/GenerationJobIdParam" } + get: + operationId: listGenerationArtifacts + summary: List artifacts produced or consumed by a generation job + tags: [generation] + responses: + "200": + description: Generation artifacts + content: + application/json: + schema: { $ref: "#/components/schemas/GenerationArtifactsResponse" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "500": { $ref: "#/components/responses/InternalServerError" } + /v1/agents/invoke: post: summary: Run one 9-phase agent graph end-to-end @@ -1047,6 +1330,12 @@ components: required: true description: Module lifecycle transaction id. schema: { type: string, format: uuid } + GenerationJobIdParam: + in: path + name: job_id + required: true + description: AIGC generation job id. + schema: { type: string, format: uuid } LimitParam: in: query name: limit @@ -1106,7 +1395,7 @@ components: name: target_type required: false description: Optional target type filter. - schema: { type: string, enum: [file, transaction] } + schema: { type: string, enum: [file, transaction, generation_job, generation_artifact, generation_stage] } AuditTargetIdQuery: in: query name: target_id @@ -1119,6 +1408,26 @@ components: required: false description: Optional actor filter. schema: { type: string } + GenerationModuleIdQuery: + in: query + name: module_id + required: false + description: Optional active module id or accepted legacy alias. + schema: + type: string + examples: [production_manufacturing, manufacturing, fabrication] + GenerationStatusQuery: + in: query + name: status + required: false + description: Optional generation job status filter. + schema: { $ref: "#/components/schemas/GenerationJobStatus" } + GenerationModeQuery: + in: query + name: mode + required: false + description: Optional generation mode filter. + schema: { $ref: "#/components/schemas/GenerationMode" } responses: Unauthorized: @@ -1466,6 +1775,367 @@ components: type: string format: date-time + GenerationMode: + type: string + description: Complete ArchIToken AIGC multimodal generation and conversion matrix. + enum: + - text_to_image + - text_to_document + - text_to_spreadsheet + - text_to_pdf + - text_to_ppt + - text_to_mindmap + - text_to_flowchart + - text_to_gantt + - text_to_floorplan + - text_to_cad + - text_to_bim + - text_to_digital_twin + - image_to_video + - image_to_pdf_drawing + - image_to_cad + - image_to_bim + - image_to_digital_twin + - video_to_bim + - video_to_digital_twin + - video_to_point_cloud + - cad_to_bim + - cad_to_digital_twin + - pdf_drawing_to_bim + - pdf_drawing_to_digital_twin + - drawing_to_image + - drawing_to_pdf + - model_to_table + - model_to_drawing + - model_to_image + + ArtifactKind: + type: string + enum: + - text + - image + - video + - document + - spreadsheet + - pdf + - ppt + - mindmap + - flowchart + - gantt + - floorplan + - cad + - bim + - digital_twin + - pdf_drawing + - point_cloud + - drawing + - table + - model + + ArtifactStatus: + type: string + enum: [input, preview, draft, approved, rejected, archived] + + Artifact: + type: object + required: [id, kind, status, fileReference, schemaRef, version, metadata] + properties: + id: + type: string + format: uuid + kind: + $ref: "#/components/schemas/ArtifactKind" + status: + $ref: "#/components/schemas/ArtifactStatus" + objectUri: + type: [string, "null"] + description: ObjectStore URI. Current skeleton uses memory:// URIs. + fileReference: + type: string + description: Stable file/artifact reference for frontend and third-party callers. + example: generation://files/018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201 + schemaRef: + type: string + example: artifact.ifc.schema.v1 + version: + type: integer + minimum: 1 + hash: + type: [string, "null"] + metadata: + type: object + additionalProperties: true + example: + id: "018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201" + kind: bim + status: preview + objectUri: memory://generation/job/artifact + fileReference: generation://files/018f1d1d-7a7e-7f2b-a9d9-4c0f4c2f1201 + schemaRef: artifact.ifc.schema.v1 + version: 1 + hash: mock-cad-to-bim + metadata: + storage: in_memory_stub + modelCalls: 0 + + GenerationInput: + type: object + required: [moduleId, mode, prompt] + properties: + moduleId: + type: string + description: Active module id or accepted legacy alias. + examples: [production_manufacturing, manufacturing, fabrication] + mode: + $ref: "#/components/schemas/GenerationMode" + prompt: + type: string + minLength: 1 + actor: + type: string + inputArtifacts: + type: array + items: { $ref: "#/components/schemas/Artifact" } + constraints: + type: object + additionalProperties: true + + GenerationOutput: + type: object + required: [artifacts, summary, generatorId, evaluatorId, ruleCheckPassed, schemaValidationPassed] + properties: + artifacts: + type: array + items: { $ref: "#/components/schemas/Artifact" } + summary: + type: string + generatorId: + type: string + evaluatorId: + type: string + description: Must be distinct from generatorId; Generator must not self-evaluate. + ruleCheckPassed: + type: boolean + schemaValidationPassed: + type: boolean + + SkillSpec: + type: object + required: [id, version, description, inputSchema, outputSchema, sandboxProfile, licensePolicy] + properties: + id: + type: string + version: + type: string + description: + type: string + inputSchema: + type: string + outputSchema: + type: string + sandboxProfile: + type: string + licensePolicy: + type: string + + McpToolSpec: + type: object + required: [name, version, capability, inputSchema, outputSchema, permissionScope] + properties: + name: + type: string + version: + type: string + capability: + type: string + inputSchema: + type: string + outputSchema: + type: string + permissionScope: + type: string + + ModelRoute: + type: object + required: [provider, model, reason, privacyTier, costTier] + properties: + provider: + type: string + example: mock + model: + type: string + example: mock-aigc-generator-v1 + reason: + type: string + privacyTier: + type: string + example: local_stub + costTier: + type: string + example: zero + + GenerationStage: + type: string + enum: [planner, generator, evaluator, rule_checker, schema_validator, approver] + + GenerationReviewDecision: + type: string + enum: [approved, rejected, needs_changes] + + GenerationReview: + type: object + required: [id, reviewer, decision, activeReview, createdAt] + properties: + id: + type: string + format: uuid + reviewer: + type: string + decision: + $ref: "#/components/schemas/GenerationReviewDecision" + comment: + type: [string, "null"] + activeReview: + type: boolean + createdAt: + type: string + format: date-time + + GenerationTrace: + type: object + required: [id, stage, actor, summary, metadata, createdAt] + properties: + id: + type: string + format: uuid + stage: + $ref: "#/components/schemas/GenerationStage" + actor: + type: string + summary: + type: string + metadata: + type: object + additionalProperties: true + createdAt: + type: string + format: date-time + + GenerationJobStatus: + type: string + enum: + - queued + - planned + - running + - pending_review + - pending_approval + - approved + - rejected + - failed + - archived + + GenerationJob: + type: object + required: + - id + - moduleId + - mode + - status + - input + - skill + - mcpTools + - modelRoute + - traces + - reviews + - artifacts + - actor + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + moduleId: + $ref: "#/components/schemas/ModuleId" + mode: + $ref: "#/components/schemas/GenerationMode" + status: + $ref: "#/components/schemas/GenerationJobStatus" + input: + $ref: "#/components/schemas/GenerationInput" + output: + oneOf: + - $ref: "#/components/schemas/GenerationOutput" + - type: "null" + skill: + $ref: "#/components/schemas/SkillSpec" + mcpTools: + type: array + items: { $ref: "#/components/schemas/McpToolSpec" } + modelRoute: + $ref: "#/components/schemas/ModelRoute" + traces: + type: array + items: { $ref: "#/components/schemas/GenerationTrace" } + reviews: + type: array + items: { $ref: "#/components/schemas/GenerationReview" } + artifacts: + type: array + items: { $ref: "#/components/schemas/Artifact" } + actor: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + GenerationJobListResponse: + type: object + required: [jobs, total, pageInfo] + properties: + jobs: + type: array + items: { $ref: "#/components/schemas/GenerationJob" } + total: + type: integer + minimum: 0 + pageInfo: + $ref: "#/components/schemas/PageInfo" + + GenerationArtifactsResponse: + type: object + required: [jobId, artifacts] + properties: + jobId: + type: string + format: uuid + artifacts: + type: array + items: { $ref: "#/components/schemas/Artifact" } + + GenerationActionRequest: + type: object + properties: + actor: + type: string + comment: + type: string + + GenerationReviewRequest: + type: object + required: [reviewer, decision] + properties: + reviewer: + type: string + minLength: 1 + decision: + $ref: "#/components/schemas/GenerationReviewDecision" + comment: + type: string + ModuleTransactionStatus: type: string enum: @@ -1636,6 +2306,14 @@ components: - transaction_transitioned - transaction_approved - transaction_rejected + - generation_job_created + - generation_job_planned + - generation_job_run + - generation_job_reviewed + - generation_job_approved + - generation_job_rejected + - generation_artifact_created + - generation_stage_completed AuditEvent: type: object @@ -1652,7 +2330,7 @@ components: $ref: "#/components/schemas/AuditEventKind" targetType: type: string - enum: [file, transaction] + enum: [file, transaction, generation_job, generation_artifact, generation_stage] targetId: type: string summary: @@ -1673,7 +2351,7 @@ components: examples: [production_manufacturing, manufacturing, fabrication] target_type: type: string - enum: [file, transaction] + enum: [file, transaction, generation_job, generation_artifact, generation_stage] target_id: type: string actor: