gobby-wiki 0.2.0

Gobby wiki CLI shell
use std::path::{Path, PathBuf};

#[cfg(feature = "ai")]
use gobby_core::ai::effective_route;
use gobby_core::ai_context::AiContext;
use gobby_core::config::{AiCapability, AiRouting};

#[cfg(feature = "ai")]
use crate::ai::clients::ProductionVisionClient;
use crate::ingest::{
    IngestResult, index_after_ingest, markdown_metadata, markdown_title, path_to_string,
    write_asset, write_raw_markdown,
};
use crate::sources::{CompileStatus, IngestionMethod, SourceDraftRef, SourceKind, SourceManifest};
use crate::store::WikiIndexStore;
use crate::vision::{
    VisionDegradation, VisionEndpoint, VisionMarkdownResult, VisionRequest,
    write_image_derived_markdown,
};
use crate::{ScopeIdentity, WikiError};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageSnapshot {
    pub location: String,
    pub file_name: String,
    pub fetched_at: String,
    pub bytes: Vec<u8>,
    pub mime_type: Option<String>,
    pub width: Option<u32>,
    pub height: Option<u32>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageIngestResult {
    pub record: crate::sources::SourceRecord,
    pub raw_path: PathBuf,
    pub asset_path: PathBuf,
    pub derived_path: PathBuf,
    pub vision_degradation: Option<VisionDegradation>,
}

pub fn ingest_image(
    vault_root: &Path,
    store: &mut impl WikiIndexStore,
    scope: ScopeIdentity,
    snapshot: ImageSnapshot,
) -> Result<ImageIngestResult, WikiError> {
    ingest_image_with_vision(
        vault_root,
        store,
        scope,
        snapshot,
        VisionEndpoint::Unavailable(default_vision_degradation()),
    )
}

pub fn ingest_image_with_production_vision(
    vault_root: &Path,
    store: &mut impl WikiIndexStore,
    scope: ScopeIdentity,
    ai_context: &AiContext,
    snapshot: ImageSnapshot,
) -> Result<ImageIngestResult, WikiError> {
    let result =
        ingest_image_with_production_vision_without_index(vault_root, scope, ai_context, snapshot)?;
    index_after_ingest(vault_root, store)?;
    Ok(result)
}

pub(crate) fn ingest_image_with_production_vision_without_index(
    vault_root: &Path,
    scope: ScopeIdentity,
    ai_context: &AiContext,
    snapshot: ImageSnapshot,
) -> Result<ImageIngestResult, WikiError> {
    let capability = AiCapability::VisionExtract;

    #[cfg(feature = "ai")]
    {
        let routing = effective_route(ai_context, capability);
        let client = matches!(routing, AiRouting::Daemon | AiRouting::Direct)
            .then(|| ProductionVisionClient::new(ai_context.clone()));
        let endpoint = match client.as_ref() {
            Some(client) => VisionEndpoint::Available(client),
            None => VisionEndpoint::Unavailable(vision_degradation(routing)),
        };
        ingest_image_with_vision_without_index(vault_root, scope, snapshot, endpoint)
    }

    #[cfg(not(feature = "ai"))]
    {
        let endpoint =
            VisionEndpoint::Unavailable(vision_degradation(ai_context.binding(capability).routing));
        ingest_image_with_vision_without_index(vault_root, scope, snapshot, endpoint)
    }
}

pub fn ingest_image_with_vision(
    vault_root: &Path,
    store: &mut impl WikiIndexStore,
    scope: ScopeIdentity,
    snapshot: ImageSnapshot,
    endpoint: VisionEndpoint<'_>,
) -> Result<ImageIngestResult, WikiError> {
    let result = ingest_image_with_vision_without_index(vault_root, scope, snapshot, endpoint)?;
    index_after_ingest(vault_root, store)?;
    Ok(result)
}

pub(crate) fn ingest_image_with_vision_without_index(
    vault_root: &Path,
    scope: ScopeIdentity,
    snapshot: ImageSnapshot,
    endpoint: VisionEndpoint<'_>,
) -> Result<ImageIngestResult, WikiError> {
    let title = markdown_title(&snapshot.file_name);
    let record = SourceManifest::register_borrowed(
        vault_root,
        SourceDraftRef {
            location: snapshot.location.clone(),
            kind: SourceKind::Image,
            fetched_at: snapshot.fetched_at.clone(),
            content: &snapshot.bytes,
            title: Some(title),
            citation: Some(snapshot.location.clone()),
            license: None,
            ingestion_method: IngestionMethod::Manual,
            compile_status: CompileStatus::Pending,
        },
    )?;
    let asset_path = write_asset(vault_root, &record, &snapshot.file_name, &snapshot.bytes)?;
    let raw_markdown = render_raw_image_markdown(&snapshot, &record.content_hash, &asset_path);
    let raw_path = write_raw_markdown(vault_root, &record, &raw_markdown)?;
    let VisionMarkdownResult {
        path: derived_path,
        degradation,
    } = write_image_derived_markdown(
        vault_root,
        &scope,
        &record,
        VisionRequest {
            file_name: &snapshot.file_name,
            mime_type: snapshot.mime_type.as_deref(),
            asset_path: &asset_path,
            bytes: &snapshot.bytes,
            width: snapshot.width,
            height: snapshot.height,
        },
        endpoint,
    )?;

    Ok(ImageIngestResult {
        record,
        raw_path,
        asset_path,
        derived_path,
        vision_degradation: degradation,
    })
}

impl From<ImageIngestResult> for IngestResult {
    fn from(result: ImageIngestResult) -> Self {
        Self {
            record: result.record,
            raw_path: result.raw_path,
            asset_path: Some(result.asset_path),
        }
    }
}

fn render_raw_image_markdown(
    snapshot: &ImageSnapshot,
    source_hash: &str,
    asset_path: &Path,
) -> String {
    let asset_path = path_to_string(asset_path);
    let mut fields = vec![
        ("source_kind", "image".to_string()),
        ("source_location", snapshot.location.clone()),
        ("fetched_at", snapshot.fetched_at.clone()),
        ("source_hash", source_hash.to_string()),
        ("source_asset", asset_path.clone()),
    ];
    if let Some(mime_type) = &snapshot.mime_type {
        fields.push(("image_mime_type", mime_type.clone()));
    }
    if let Some(width) = snapshot.width {
        fields.push(("image_width", width.to_string()));
    }
    if let Some(height) = snapshot.height {
        fields.push(("image_height", height.to_string()));
    }

    let mut markdown = markdown_metadata(&fields);
    markdown.push_str("# ");
    markdown.push_str(&markdown_title(&snapshot.file_name));
    markdown.push_str("\n\n");
    markdown.push_str("Original image stored under `");
    markdown.push_str(&asset_path);
    markdown.push_str("`.\n");
    markdown
}

fn default_vision_degradation() -> VisionDegradation {
    VisionDegradation {
        reason: "missing_endpoint".to_string(),
        fallback:
            "Keep raw image assets and surface filename/metadata only; skip visual extraction."
                .to_string(),
    }
}

fn vision_degradation(routing: AiRouting) -> VisionDegradation {
    let reason = match routing {
        AiRouting::Off => "disabled",
        AiRouting::Auto | AiRouting::Daemon | AiRouting::Direct => "missing_endpoint",
    };
    VisionDegradation {
        reason: reason.to_string(),
        fallback: "Keep raw image assets and surface filename/metadata only.".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use gobby_core::indexing::content_hash;

    use super::*;
    use crate::sources::{SourceKind, SourceManifest};
    use crate::store::{MemoryWikiStore, WikiDocumentKind};

    fn sample_snapshot() -> ImageSnapshot {
        ImageSnapshot {
            location: "/tmp/diagram.png".to_string(),
            file_name: "diagram.png".to_string(),
            fetched_at: "2026-05-29T20:30:00Z".to_string(),
            bytes: b"\x89PNG\r\n\x1a\nimage-bytes\n".to_vec(),
            mime_type: Some("image/png".to_string()),
            width: Some(640),
            height: Some(480),
        }
    }

    #[test]
    fn stores_original_image() {
        let temp = tempfile::tempdir().expect("tempdir");
        let snapshot = sample_snapshot();
        let expected_hash = content_hash(&snapshot.bytes);
        let mut store = MemoryWikiStore::default();

        let result = ingest_image(
            temp.path(),
            &mut store,
            ScopeIdentity::topic("field-work"),
            snapshot.clone(),
        )
        .expect("ingest image");

        assert_eq!(
            result.asset_path.parent(),
            Some(PathBuf::from("raw/assets").as_path())
        );
        assert_eq!(
            std::fs::read(temp.path().join(&result.asset_path)).expect("asset bytes"),
            snapshot.bytes
        );
        let raw =
            std::fs::read_to_string(temp.path().join(&result.raw_path)).expect("raw markdown");
        assert!(raw.contains("source_kind: image"));
        assert!(raw.contains("source_asset: raw/assets/"));

        let manifest = SourceManifest::read(temp.path()).expect("read source manifest");
        assert_eq!(manifest.entries.len(), 1);
        assert_eq!(manifest.entries[0].kind, SourceKind::Image);
        assert_eq!(manifest.entries[0].content_hash, expected_hash);
    }

    #[test]
    fn image_metadata_is_scope_indexed() {
        let temp = tempfile::tempdir().expect("tempdir");
        let mut store = MemoryWikiStore::default();

        let result = ingest_image(
            temp.path(),
            &mut store,
            ScopeIdentity::project("project-123"),
            sample_snapshot(),
        )
        .expect("ingest image");

        let document = store
            .documents
            .get(&result.derived_path)
            .expect("derived image document indexed");
        assert_eq!(document.kind, WikiDocumentKind::SourceNote);
        assert!(document.body.contains("scope_kind: project"));
        assert!(document.body.contains("scope_id: project-123"));
        assert!(document.body.contains("image_width: 640"));
        assert!(document.body.contains("image_height: 480"));
        assert!(store.sources.contains_key(&result.derived_path));
    }

    #[cfg(feature = "ai")]
    #[test]
    fn production_vision_writes_description_and_ocr() {
        let response = r#"{"model":"gpt-4.1-mini","choices":[{"message":{"content":"{\"description\":\"A labeled wiring diagram\",\"ocr_text\":\"VCC GND Sensor\"}"}}]}"#;
        let (api_base, request) = spawn_vision_server(response);
        let context = test_ai_context(&api_base);
        let temp = tempfile::tempdir().expect("tempdir");
        let mut store = MemoryWikiStore::default();

        let result = ingest_image_with_production_vision(
            temp.path(),
            &mut store,
            ScopeIdentity::topic("field-work"),
            &context,
            sample_snapshot(),
        )
        .expect("ingest image with production vision");
        let request = request
            .join()
            .expect("vision request")
            .expect("vision request ok");

        assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
        assert!(request.contains("data:image/png;base64,"));
        assert!(result.vision_degradation.is_none());

        let document = store
            .documents
            .get(&result.derived_path)
            .expect("derived image document indexed");
        assert!(document.body.contains("vision_status: extracted"));
        assert!(document.body.contains("vision_model: gpt-4.1-mini"));
        assert!(
            document
                .body
                .contains("## Vision Description\n\nA labeled wiring diagram")
        );
        assert!(document.body.contains("## OCR Text\n\nVCC GND Sensor"));
    }

    #[cfg(feature = "ai")]
    fn test_ai_context(api_base: &str) -> gobby_core::ai_context::AiContext {
        use gobby_core::ai_context::{AiBindings, AiLimiter};
        use gobby_core::config::{AiRouting, AiTuning, CapabilityBinding};

        let binding = CapabilityBinding {
            routing: AiRouting::Direct,
            transport: None,
            api_base: Some(api_base.to_string()),
            api_key: None,
            model: Some("gpt-4.1-mini".to_string()),
            provider: None,
            task: None,
            language: None,
            target_lang: None,
        };

        gobby_core::ai_context::AiContext {
            bindings: AiBindings {
                embed: binding.clone(),
                audio_transcribe: binding.clone(),
                audio_translate: binding.clone(),
                vision_extract: binding.clone(),
                text_generate: binding,
            },
            tuning: AiTuning {
                max_concurrency: 1,
                keep_alive: None,
            },
            limiter: AiLimiter::new(1),
            project_id: None,
        }
    }

    #[cfg(feature = "ai")]
    fn spawn_vision_server(
        response: &'static str,
    ) -> (String, gobby_core::test_http::RequestHandle) {
        gobby_core::test_http::spawn_json_response(response).expect("spawn test server")
    }
}