---
name: add-converter
description: Adds a new output format converter (e.g., CSV, RDF) to the IO writers module.
---

# Adding a Converter

This skill documents how to add a new output format converter to `kgb/io/writers/`.

## Overview

Converters transform JSON triples into various output formats for use with external tools. The system provides:
- GraphML for graph analysis tools (Gephi, Cytoscape)
- Extensible architecture for custom formats (CSV, RDF, etc.)

## Architecture

```
                        IO Writers Module
    ┌───────────────────────────────────────────────────────────┐
    │                                                           │
    │  io/writers/__init__.py    ← Public exports               │
    │                                                           │
    │  io/writers/graphml.py     ← NetworkX GraphML format      │
    │  ├─ json_to_graphml()        Single file conversion       │
    │  └─ convert_json_directory() Batch conversion             │
    │                                                           │
    │  io/writers/csv.py         ← Your new format              │
    │  ├─ json_to_csv()                                         │
    │  └─ convert_csv_directory()                               │
    │                                                           │
    └───────────────────────────────────────────────────────────┘

Data Flow:
  list[Triple] → Validation → Field Mapping → Format Rendering → File
```

**Key Files:**
- `kgb/io/writers/graphml.py` — Reference implementation (GraphML)
- `kgb/io/writers/__init__.py` — Public exports
- `kgb/io/__init__.py` — Top-level IO exports

## Dependencies

| Format | Required Library | Purpose |
|--------|-----------------|---------|
| CSV | `csv` (stdlib) | Tabular export |
| GraphML | `networkx>=3.0` | Graph format |
| RDF | `rdflib>=6.0` | Semantic web |

## Field Mapping

| Triple Field | GraphML | CSV | RDF |
|--------------|---------|-----|-----|
| `head` | Source node | `head` column | Subject URI |
| `tail` | Target node | `tail` column | Object URI |
| `relation` | Edge label | `relation` column | Predicate URI |
| `inference` | Edge attribute | `inference` column | Annotation |

## Step 1: Understand the Interface

The existing GraphML converter follows this pattern (in `kgb/io/writers/graphml.py`):

```python
def json_to_graphml(
    triples: list[Triple] | list[dict[str, Any]],
    output_path: Path | str | None = None
) -> nx.DiGraph:
    """Convert triples to a NetworkX DiGraph (optionally saved as GraphML).

    - Validates/converts to Triple objects
    - Normalizes entity names (case-insensitive dedup)
    - Stores relation and inference as edge attributes
    - Uses inference.value (not str(inference)) for clean enum serialization
    """
```

Key implementation details from the reference:
- Accept both `list[Triple]` and `list[dict]` inputs
- Use `Triple(**t)` to validate dict inputs, skip invalid with warning
- Entity name canonicalization via `get_canonical_name()` to avoid duplicates
- Preserve `inference` as `.value` string (`"explicit"` / `"contextual"`)

## Step 2: Implement Your Converter

Create `kgb/io/writers/csv.py`:

```python
"""CSV converter for knowledge graph triples."""

from __future__ import annotations
import csv
from pathlib import Path
from typing import Any

from pydantic import ValidationError
from ...domains import Triple


def json_to_csv(
    triples: list[Triple] | list[dict[str, Any]],
    output_path: Path | str,
    *,
    include_metadata: bool = True,
    delimiter: str = ","
) -> Path:
    """Convert triples to CSV edge list format."""
    if not triples:
        raise ValueError("Cannot convert empty triple list")

    output_path = Path(output_path)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # Validate and convert to Triple objects
    validated: list[Triple] = []
    for t in triples:
        try:
            if isinstance(t, Triple):
                validated.append(t)
            else:
                validated.append(Triple(**t))
        except ValidationError as e:
            print(f"Warning: Skipping invalid triple: {e}")
            continue

    if not validated:
        raise ValueError("No valid triples after validation")

    # Determine columns
    fieldnames = ["head", "relation", "tail"]
    if include_metadata:
        fieldnames.extend(["inference", "justification"])

    # Write CSV
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=delimiter)
        writer.writeheader()

        for triple in validated:
            row = {
                "head": triple.head,
                "relation": triple.relation,
                "tail": triple.tail,
            }
            if include_metadata:
                row.update({
                    "inference": triple.inference.value,
                    "justification": triple.justification or "",
                })
            writer.writerow(row)

    return output_path


def convert_csv_directory(
    input_dir: Path | str,
    output_dir: Path | str,
    *,
    include_metadata: bool = True
) -> list[Path]:
    """Convert all JSON files to CSV format."""
    import json

    input_dir = Path(input_dir)
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    csv_files = []
    for json_file in input_dir.glob("*.json"):
        try:
            with open(json_file) as f:
                data = json.load(f)

            output_path = output_dir / f"{json_file.stem}.csv"
            json_to_csv(data, output_path, include_metadata=include_metadata)
            print(f"Converted: {json_file.name} -> {output_path.name}")
            csv_files.append(output_path)
        except ValueError as e:
            print(f"Skipped {json_file.name}: {e}")

    return csv_files
```

## Step 3: Register in Module

Update `kgb/io/writers/__init__.py`:

```python
from .graphml import json_to_graphml, convert_json_directory
from .csv import json_to_csv, convert_csv_directory

__all__ = [
    "json_to_graphml",
    "convert_json_directory",
    "json_to_csv",
    "convert_csv_directory",
]
```

Update `kgb/io/__init__.py` to export the new functions:

```python
from .readers import load_records, detect_format, DataLoadError
from .writers import json_to_graphml, convert_json_directory, json_to_csv, convert_csv_directory

__all__ = [
    "load_records",
    "detect_format",
    "DataLoadError",
    "json_to_graphml",
    "convert_json_directory",
    "json_to_csv",
    "convert_csv_directory",
]
```

## Step 4: Add CLI Support

Update the `convert` command in `kgb/__main__.py` to support the new format:

```python
@app.command()
def convert(
    input_dir: Path = typer.Option(..., "--input", "-i", exists=True),
    output_dir: Optional[Path] = typer.Option(None, "--output", "-o"),
    format: str = typer.Option("graphml", "--format", "-f"),
):
    """Convert JSON triples to specified format."""
    from .io.writers import convert_json_directory, convert_csv_directory

    out_dir = output_dir or input_dir.parent / format

    if format == "graphml":
        files = convert_json_directory(input_dir, out_dir)
    elif format == "csv":
        files = convert_csv_directory(input_dir, out_dir)
    else:
        console.print(f"[red]Unknown format: {format}[/red]")
        raise typer.Exit(code=1)

    console.print(f"\n[green]Converted {len(files)} files to {format}[/green]")
```

## Step 5: Verify

### Check Import

```bash
python -c "from kgb.io.writers.csv import json_to_csv; print('OK')"
```

### Unit Tests

```python
def test_json_to_csv_basic(tmp_path):
    from kgb.io.writers.csv import json_to_csv
    from kgb.domains import Triple

    triples = [
        Triple(head="Alice", relation="knows", tail="Bob"),
        Triple(head="Bob", relation="works_at", tail="Acme"),
    ]

    output = tmp_path / "graph.csv"
    result = json_to_csv(triples, output)

    assert result.exists()

    import csv
    with open(result) as f:
        rows = list(csv.DictReader(f))

    assert len(rows) == 2
    assert rows[0]["head"] == "Alice"
    assert rows[0]["inference"] == "explicit"


def test_json_to_csv_empty_list(tmp_path):
    from kgb.io.writers.csv import json_to_csv
    import pytest

    with pytest.raises(ValueError, match="empty"):
        json_to_csv([], tmp_path / "empty.csv")


def test_json_to_csv_from_dicts(tmp_path):
    from kgb.io.writers.csv import json_to_csv
    import csv

    dicts = [{"head": "X", "relation": "r", "tail": "Y", "inference": "explicit"}]
    csv_path = tmp_path / "roundtrip.csv"
    json_to_csv(dicts, csv_path)

    with open(csv_path) as f:
        row = next(csv.DictReader(f))

    assert row["head"] == "X"
    assert row["relation"] == "r"
    assert row["tail"] == "Y"
```

## Key Principles

| Principle | Implementation |
|-----------|---------------|
| **Accept `list[Triple]` and `list[dict]`** | Use isinstance check with `Triple(**t)` validation |
| **Use `.value` for enums** | `triple.inference.value` → `"explicit"` (not `"InferenceType.EXPLICIT"`) |
| **Create Directories** | `output_path.parent.mkdir(parents=True, exist_ok=True)` |
| **Skip Invalid Data** | Log warning and continue |

## Error Handling

| Exception | When | Action |
|-----------|------|--------|
| `ValueError` | Empty input or no valid triples | Fail with message |
| `ValidationError` | Triple validation fails | Log, skip, continue |
| `FileNotFoundError` | Input directory doesn't exist | Fail loudly |

## Files to Create/Modify

| File | Action |
|------|--------|
| `kgb/io/writers/csv.py` | Create — converter implementation |
| `kgb/io/writers/__init__.py` | Modify — add imports |
| `kgb/io/__init__.py` | Modify — add exports |
| `kgb/__main__.py` | Modify — add format dispatch (optional) |

## Verification Checklist

- [ ] Implementation validates Triple inputs
- [ ] Uses `inference.value` for enum serialization
- [ ] Tests pass (unit + round-trip)
- [ ] Batch function for directory processing
- [ ] Registered in `kgb/io/writers/__init__.py`
- [ ] Exported in `kgb/io/__init__.py`
- [ ] CLI format dispatch works (if added)
