Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,32 @@ steps:
- **`write:`** exports a step's rows to a file, format inferred from the extension: `.csv`, `.json` (array), `.md` (table), or `.jsonl`. It's terminal and doesn't change the intermediate JSONL that other steps read.
- **`image:`** on a prompt step attaches a file as a vision image, e.g. `image: "{{.item.path}}"` after `read`-ing a folder of images.

#### One file, or one file per row

A write step's source picks its mode. `from:` writes **one file** with every row — a dataset or report. `forEach:` writes **one file per row** — a folder of documents:

```yaml
- name: reply_digest
from: drafts # → one replies.md table of all drafts
write: replies.md

- name: reply_files
forEach: drafts # → one file per draft
write: replies/{{.item.subject}}.md # path is a template, rendered per row
content: "{{.item.reply}}" # body is raw text, not JSON
```

- The path template may nest (`write: out/{{.item.kind}}/{{.item.id}}.md`); missing directories are created.
- **`content:`** is the file body as raw text — that is what makes the output a document rather than a record. Omit it and the row is serialized by extension instead (`.json`, `.csv`, …), one row per file.
- Values interpolated into the path are made filename-safe, so a `/` or `:` inside your data can't redirect the file — only slashes you write in the template create directories. A name that renders empty falls back to the row number, and two rows producing the same name get numbered (`-2`) instead of overwriting each other.

**Where paths point.** Inputs travel with the workflow; everything generated lands in the output folder:

| Path | Relative to | Absolute |
| --- | --- | --- |
| `read:` (input) | the **config file's directory** — so a workflow runs from any working directory | used as-is |
| `write:` (deliverable) | the **output folder** | used as-is — this is how you publish outside it |
| `write:` per-row template | the **output folder**, after rendering | used as-is |
| intermediate JSONL | the **output folder** | — |
| `--output` (flag) | the **working directory** | used as-is |

Expand Down
3 changes: 2 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ type Step struct {
Run string `yaml:"run"`
JQ string `yaml:"jq"` // transform steps: jq program
Read string `yaml:"read"` // read steps: file/glob/dir to load as rows
Write string `yaml:"write"` // write steps: file path to export the source rows to
Write string `yaml:"write"` // write steps: file path to export the source rows to (a per-row template when used with forEach)
Content string `yaml:"content"` // per-row write steps: template for the file body, written as raw text
Format string `yaml:"format"` // read: "files"|"csv"|"jsonl"; write: "csv"|"json"|"md"|"jsonl" (default: by extension)
From string `yaml:"from"` // transform/write steps: source step name
Limit int `yaml:"limit"` // transform steps: cap output rows (0 = no cap)
Expand Down
2 changes: 1 addition & 1 deletion examples/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Each folder is a self-contained `config.yaml` + `README.md`. New to datamatic? R
| [basics](./basics) | text generation, JSON schema | Ollama |
| [process-my-files](./process-my-files) | `read` your own local files (glob/dir/CSV/JSONL) → rows | Ollama |
| [csv-enrichment](./csv-enrichment) | `read` CSV → enrich with LLM → `write` CSV (the full office loop) | Ollama |
| [inbox-triage](./inbox-triage) | `read` folder of emails → SGR triage → draft replies → `write` CSV + Markdown | Ollama |
| [inbox-triage](./inbox-triage) | `read` folder of emails → SGR triage → draft replies → `write` a board (CSV) + one file per reply | Ollama |
| [linked-steps](./linked-steps) | step chaining, native template values (`if`/`range`/`len`) | Ollama |
| [structured-extraction](./structured-extraction) | nested schema, both schema formats (YAML / JSON-string), native templates | Ollama |
| [dataset-pipeline](./dataset-pipeline) | transform fan-out, fan-in (`collect`, `$parent`), rating pipeline | Ollama |
Expand Down
13 changes: 9 additions & 4 deletions examples/v1/inbox-triage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

A real support-desk loop, no shell: **read a folder of incoming emails →
classify each with schema-guided reasoning → draft a suggested reply → write a
triage board (CSV) and a drafts digest (Markdown)**. Drop your own `.txt`
emails into `inbox/` and rerun.
triage board (CSV), a drafts digest (Markdown), and one editable reply file per
ticket**. Drop your own `.txt` emails into `inbox/` and rerun.

**Features:** `read` (folder of files) · `SGR` · `forEach` · `transform` · `write` (csv + md)
**Features:** `read` (folder of files) · `SGR` · `forEach` · `transform` · `write` (aggregate + per-row)

## Steps

Expand All @@ -14,7 +14,11 @@ emails into `inbox/` and rerun.
3. `board_rows` — **transform** drops the reasoning, keeping the scannable columns
4. `board` — `write: board.csv` → the triage board
5. `drafts` — `forEach` triage row → `{subject, reply}` (drafted from the summary, not the raw email)
6. `reply_digest` — `write: replies.md` → the suggested replies as a Markdown table
6. `reply_digest` — `write: replies.md` → all drafts as one Markdown table (aggregate mode)
7. `reply_files` — `forEach` draft → `replies/<subject>.md`, one file per ticket whose body is the reply itself (per-row mode)

Steps 6 and 7 show the two write modes side by side: `from:` for one file with
every row, `forEach:` + `content:` for a folder of documents.

## Requirements

Expand All @@ -27,4 +31,5 @@ emails into `inbox/` and rerun.
datamatic --config ./config.yaml --verbose
cat ./dataset/board.csv
cat ./dataset/replies.md
ls ./dataset/replies/
```
10 changes: 10 additions & 0 deletions examples/v1/inbox-triage/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ steps:
required: [subject, reply]
additionalProperties: false

# aggregate: one Markdown table of every draft, to skim
- name: reply_digest
from: drafts
write: replies.md

# per-row: one editable file per ticket, named after its subject.
# content is written verbatim — the block scalar adds the trailing newline a
# text file should end with (quote it instead to control the bytes exactly)
- name: reply_files
forEach: drafts
write: replies/{{.item.subject}}.md
content: |
{{.item.reply}}
18 changes: 18 additions & 0 deletions fs/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ func WriteMarkdownTable(path string, rows []map[string]interface{}) error {
return nil
}

// SanitizeFilename makes a string safe to use as one path segment, for file
// names built from row data. Both slash kinds are replaced regardless of
// platform, so a name written on one OS cannot redirect the file on another.
// Returns "" when nothing usable remains, leaving the fallback to the caller.
func SanitizeFilename(s string) string {
s = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r < 0x20 {
return '_'
}
return r
}, strings.TrimSpace(s))

if strings.Trim(s, "_. ") == "" {
return ""
}
return s
}

func mdEscape(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
return strings.ReplaceAll(s, "|", "\\|")
Expand Down
21 changes: 21 additions & 0 deletions fs/write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,24 @@ func TestWriteMarkdownTable(t *testing.T) {
assert.Equal(t, "| --- | --- |", lines[1])
assert.Contains(t, got, "| Acme | 9 |")
}

// TestSanitizeFilename covers names built from row data, which may contain
// anything a model emitted — path separators would silently redirect the file.
func TestSanitizeFilename(t *testing.T) {
tests := []struct{ in, want string }{
{"locked-out.md", "locked-out.md"},
{"a/b:c", "a_b_c"},
{`a\b`, "a_b"},
{"tab\there", "tab_here"},
{" padded ", "padded"},
{"", ""},
{" ", ""},
{"...", ""},
{"___", ""},
{"Any plans for dark mode?", "Any plans for dark mode?"},
}

for _, tc := range tests {
assert.Equal(t, tc.want, SanitizeFilename(tc.in), "input %q", tc.in)
}
}
36 changes: 36 additions & 0 deletions runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,39 @@ func TestRun_RerunReusesOutputFolder(t *testing.T) {
assert.Len(t, readOutputLines(t, filepath.Join(outputFolder, "gen.jsonl")), 2,
"the file must hold one run's rows, not both runs appended")
}

// TestRun_PerRowWritePipeline is the end-to-end folder-of-documents case: read
// rows, generate a body per row, and emit one file per row rather than a table.
func TestRun_PerRowWritePipeline(t *testing.T) {
srv := llmtest.NewServer(t,
`{"slug":"alpha","body":"Alpha body."}`,
`{"slug":"beta","body":"Beta body."}`)

srcDir := t.TempDir() // input files live apart from the output folder
seeds := filepath.Join(srcDir, "seeds.csv")
require.NoError(t, os.WriteFile(seeds, []byte("topic\nAlpha\nBeta\n"), 0o644))

cfg := config.NewConfig()
cfg.OutputFolder = t.TempDir()
cfg.Version = "1.0"
cfg.Steps = []config.Step{
{Name: "seeds", Read: seeds},
{
Name: "articles", Model: "ollama:test-model", ForEach: "seeds",
Prompt: "Write about {{.item.topic}}",
JSONSchemaRaw: `{"type":"object","properties":{"slug":{"type":"string"},"body":{"type":"string"}},"required":["slug","body"],"additionalProperties":false}`,
ModelConfig: config.ModelConfig{BaseURL: srv.URL},
},
{Name: "files", ForEach: "articles", Write: "docs/{{.item.slug}}.md", Content: "{{.item.body}}"},
}

require.NoError(t, utils.PreprocessConfig(cfg))
require.NoError(t, cfg.Validate())
require.NoError(t, runner.NewRunner(cfg).Run(context.Background()))

for slug, want := range map[string]string{"alpha": "Alpha body.", "beta": "Beta body."} {
data, err := os.ReadFile(filepath.Join(cfg.OutputFolder, "docs", slug+".md"))
require.NoError(t, err, "expected one file per row")
assert.Equal(t, want, string(data))
}
}
Loading
Loading