@anysiteio/agent-skills 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,19 @@
1
1
  # Dataset Pipeline & Database Guide
2
2
 
3
+ ## Agent Workflow
4
+
5
+ Follow these steps when building a new dataset pipeline:
6
+
7
+ 1. **Discover endpoint fields**: `anysite describe <endpoint> --json` — see input params (with examples, defaults) and output fields (with nested structure)
8
+ 2. **Draft YAML config** — use output fields from step 1 for `dependency.field` paths
9
+ 3. **Validate**: `anysite dataset validate dataset.yaml --json` — catches structural errors in < 0.1s
10
+ 4. **Dry run**: `anysite dataset collect dataset.yaml --dry-run` — preview plan with estimated request counts
11
+ 5. **Collect**: `anysite dataset collect dataset.yaml` or `anysite dataset collect dataset.yaml --load-db mydb`
12
+
13
+ **Key rule**: ALWAYS run `anysite describe` before setting `dependency.field`. The output fields show exactly which paths exist. For example, `/api/linkedin/user/posts` output has `author` as `object{@type,internal_id,urn,...}` — so the author's URN is `author.urn.value`, NOT `urn.value` (which is the post's own URN).
14
+
15
+ ---
16
+
3
17
  ## Dataset YAML Configuration
4
18
 
5
19
  ### Full Structure
@@ -11,7 +25,7 @@ description: Optional description
11
25
  sources:
12
26
  - id: source_id # Unique identifier
13
27
  endpoint: /api/... # API endpoint path
14
- params: {} # Static API parameters
28
+ input: {} # Static API parameters
15
29
  from_file: inputs.txt # Input file (txt/JSONL/CSV)
16
30
  file_field: column_name # CSV column to extract
17
31
  input_key: param_name # API parameter for input values
@@ -24,8 +38,10 @@ sources:
24
38
  parallel: 3 # Concurrent requests
25
39
  rate_limit: "10/s" # Rate limiting
26
40
  on_error: skip # stop or skip
27
- transform: # Post-collection transform (for exports only)
28
- filter: '.count > 10' # Safe filter expression
41
+ refresh: always # auto (default), always, or never (skip if data exists)
42
+ filter: '.count > 10' # Source filter (before LLM + Parquet write)
43
+ transform: # Export filter (after Parquet write, for exports only)
44
+ filter: '.status == "active"'
29
45
  fields: [name, url] # Field selection with aliases
30
46
  add_columns: # Static columns to inject
31
47
  batch: "q1-2026"
@@ -36,9 +52,28 @@ sources:
36
52
  - type: webhook
37
53
  url: https://example.com/hook
38
54
  headers: {X-Token: abc}
55
+ llm: # LLM enrichment (after collection, before Parquet)
56
+ - type: enrich # enrich, classify, summarize, generate
57
+ add: # Field specs for enrich type
58
+ - "sentiment:positive/negative/neutral"
59
+ - "language:string"
60
+ fields: [name, headline] # Record fields to include in LLM prompt
61
+ - type: classify
62
+ categories: "dev,recruiter,exec" # Omit for auto-detect
63
+ output_column: role # Default: "category"
64
+ - type: summarize
65
+ max_length: 50
66
+ output_column: bio # Default: "summary"
67
+ - type: generate
68
+ prompt: "Pitch for {name}" # Required for generate
69
+ output_column: pitch # Default: "text"
70
+ temperature: 0.7 # Per-step overrides
39
71
  db_load: # Database loading config
72
+ filter: '.active == true' # DB load filter (only matching records to DB)
40
73
  table: custom_name # Override table name
41
- fields: [name, url] # Fields to include
74
+ key: urn.value # Unique key for diff-based incremental sync
75
+ sync: full # full (INSERT/DELETE/UPDATE) or append (no DELETE)
76
+ fields: [name, url, sentiment, language, role, bio, pitch]
42
77
  exclude: [_input_value] # Fields to exclude
43
78
 
44
79
  storage:
@@ -57,13 +92,13 @@ notifications: # Webhook notifications
57
92
  - url: "https://alerts.example.com/fail"
58
93
  ```
59
94
 
60
- ### Three Source Types
95
+ ### Six Source Types
61
96
 
62
- **Independent** — single API call with static params:
97
+ **Independent** — single API call with static input:
63
98
  ```yaml
64
99
  - id: search_results
65
100
  endpoint: /api/linkedin/search/users
66
- params:
101
+ input:
67
102
  keywords: "software engineer"
68
103
  count: 50
69
104
  ```
@@ -93,6 +128,61 @@ notifications: # Webhook notifications
93
128
  count: 5
94
129
  ```
95
130
 
131
+ **Union** — combine multiple sources into one:
132
+ ```yaml
133
+ - id: all_results
134
+ type: union
135
+ sources: [search_cto, search_vp] # Parent source IDs to combine
136
+ dedupe_by: urn.value # Optional: remove duplicates by field
137
+ ```
138
+ All parent sources must have the same endpoint. Records are annotated with `_union_source` (parent source ID). Cannot have `endpoint`, `dependency`, `from_file`, `input_key`, or `input`.
139
+
140
+ **LLM Source (type: llm)** — process parent data through LLM without API calls:
141
+ ```yaml
142
+ - id: profiles_analyzed
143
+ type: llm # Source type: api (default) | llm
144
+ dependency:
145
+ from_source: profiles # Parent source (required)
146
+ field: name # Optional — omit if not extracting values
147
+ llm: # LLM enrichment steps (required for type: llm)
148
+ - type: classify
149
+ categories: "strong_fit,moderate_fit,weak_fit"
150
+ output_column: fit_category
151
+ fields: [headline, experience]
152
+ - type: enrich
153
+ add:
154
+ - "fit_score:1-10"
155
+ - "key_strengths:string"
156
+ - "concerns:string"
157
+ fields: [name, headline, experience, skills]
158
+ export:
159
+ - type: file
160
+ path: ./output/analyzed-{{date}}.csv
161
+ format: csv
162
+ ```
163
+
164
+ **type: llm source rules:**
165
+ - **Requires**: `dependency` (parent source) and non-empty `llm` list
166
+ - **Cannot have**: `endpoint`, `from_file`, `input_key`, `input`
167
+ - **Use case**: Run LLM enrichment on already-collected data without re-calling the API
168
+ - **Run with**: `anysite dataset collect dataset.yaml --source profiles_analyzed`
169
+
170
+ The LLM source reads the parent's Parquet data directly, applies LLM steps, and writes enriched records to its own Parquet file. This allows re-analyzing data with different prompts or categories without re-collecting from the API.
171
+
172
+ **SQL (type: sql)** — query a named database connection:
173
+ ```yaml
174
+ - id: billing_users
175
+ type: sql
176
+ connection: billing
177
+ query: "SELECT name, email FROM subscriptions WHERE status = 'inactive'"
178
+ ```
179
+ Runs the query and stores results as records. Downstream sources can depend on SQL output. Uses connections from `anysite db add`. Supports `query_file` for external `.sql` files.
180
+
181
+ **type: sql source rules:**
182
+ - **Requires**: `connection` and either `query` or `query_file`
183
+ - **Cannot have**: `endpoint`, `from_file`, `input_key`, `input`, `parallel`, `rate_limit`, `on_error`
184
+ - **Use case**: Bring external DB data into the pipeline for API enrichment
185
+
96
186
  ### Dependency Chains
97
187
 
98
188
  Sources are topologically sorted — parents always run before children. Multi-level chains work automatically:
@@ -101,6 +191,14 @@ Sources are topologically sorted — parents always run before children. Multi-l
101
191
  companies → employees → profiles → posts → comments
102
192
  ```
103
193
 
194
+ **Common dependency fields:**
195
+ - `/api/linkedin/company/employees` returns: `name`, `headline`, `url`, `image`, `location`, `internal_id`, `urn` — use `urn.value` (not `alias`) to chain into `/api/linkedin/user`
196
+ - `/api/linkedin/user` accepts both human-readable aliases (`satyanadella`) and URN values as the `user` parameter
197
+
198
+ Always run `anysite describe <endpoint>` to verify available fields before setting up dependencies.
199
+
200
+ **Note:** You can also use the `${source.field}` shorthand instead of explicit `dependency` + `input_key`. See the "Shorthand Syntax" section below.
201
+
104
202
  ### input_template
105
203
 
106
204
  Transforms extracted values before passing to the API. Use `{value}` placeholder:
@@ -124,11 +222,124 @@ When Parquet stores nested objects as JSON strings, dot-notation traverses them:
124
222
  - `experience[0].company_urn` — array index + nested field
125
223
  - `internal_id.value` — nested object access
126
224
 
225
+ ### Common Dependency Chains
226
+
227
+ Quick reference for popular endpoint combinations. Always verify with `anysite describe <endpoint>`.
228
+
229
+ | Parent endpoint | Child endpoint | `field` | `input_key` | Notes |
230
+ |----------------|---------------|---------|-------------|-------|
231
+ | `/api/linkedin/search/users` | `/api/linkedin/user` | `urn.value` | `user` | |
232
+ | `/api/linkedin/user` | `/api/linkedin/user/posts` | `urn.value` | `urn` | |
233
+ | `/api/linkedin/user/posts` | `/api/linkedin/post/comments` | `urn.value` | `urn` | |
234
+ | `/api/linkedin/user/posts` | `/api/linkedin/user` (author) | `author.urn.value` | `user` | NOT `urn.value` |
235
+ | `/api/linkedin/user` | `/api/linkedin/company` | `company.universalName` | `company` | |
236
+ | `/api/linkedin/company` | `/api/linkedin/company/posts` | `universalName` | `company` | needs `input_template` |
237
+ | `/api/linkedin/company` | `/api/linkedin/company/employees` | `urn.value` | `companies` | needs `input_template` |
238
+
239
+ **Posts → Author Profiles** (most common mistake — using wrong field):
240
+ ```yaml
241
+ - id: author_profiles
242
+ endpoint: /api/linkedin/user
243
+ dependency:
244
+ from_source: posts
245
+ field: author.urn.value # AUTHOR's URN, not post URN
246
+ dedupe: true
247
+ input_key: user
248
+ parallel: 5
249
+ ```
250
+
251
+ **Companies → Employees** (requires array input_template):
252
+ ```yaml
253
+ - id: employees
254
+ endpoint: /api/linkedin/company/employees
255
+ dependency:
256
+ from_source: companies
257
+ field: urn.value
258
+ input_key: companies
259
+ input_template:
260
+ companies:
261
+ - type: company
262
+ value: "{value}"
263
+ count: 5
264
+ parallel: 2
265
+ ```
266
+
267
+ ### Shorthand Syntax (`${source.field}`)
268
+
269
+ Instead of writing explicit `dependency` + `input_key`, you can use `${source.field}` directly in `input`:
270
+
271
+ ```yaml
272
+ # Shorthand (auto-expands to dependency + input_key):
273
+ - id: profiles
274
+ endpoint: /api/linkedin/user
275
+ input:
276
+ user: ${search_results.urn.value}
277
+ count: 20
278
+ ```
279
+
280
+ This is equivalent to:
281
+ ```yaml
282
+ - id: profiles
283
+ endpoint: /api/linkedin/user
284
+ dependency:
285
+ from_source: search_results
286
+ field: urn.value
287
+ input_key: user
288
+ input:
289
+ count: 20
290
+ ```
291
+
292
+ The `${source.field}` syntax extracts the source ID and field path automatically. Static parameters (like `count: 20`) are passed through as-is.
293
+
294
+ ### Required Fields by Source Type
295
+
296
+ | Field | `api` (default) | `llm` | `union` | `sql` |
297
+ |-------|-----------------|-------|---------|-------|
298
+ | `id` | required | required | required | required |
299
+ | `type` | optional (defaults to "api") | required: `llm` | required: `union` | required: `sql` |
300
+ | `endpoint` | required | N/A | N/A | N/A |
301
+ | `connection` | N/A | N/A | N/A | **required** |
302
+ | `query` | N/A | N/A | N/A | required (or `query_file`) |
303
+ | `query_file` | N/A | N/A | N/A | required (or `query`) |
304
+ | `dependency` | optional | **required** | N/A | optional |
305
+ | `llm` | optional | **required** (>= 1 step) | optional | optional |
306
+ | `sources` | N/A | N/A | **required** | N/A |
307
+ | `input` | optional | N/A | N/A | N/A |
308
+ | `input_key` | optional | N/A | N/A | N/A |
309
+ | `input_template` | optional | N/A | N/A | N/A |
310
+ | `from_file` | optional | N/A | N/A | N/A |
311
+ | `parallel` | optional (default: 1) | N/A | N/A | N/A |
312
+ | `filter` | optional | optional | optional | optional |
313
+ | `transform` | optional | optional | optional | optional |
314
+ | `export` | optional | optional | optional | optional |
315
+ | `db_load` | optional | optional | optional | optional |
316
+ | `dedupe_by` | N/A | N/A | optional | N/A |
317
+
318
+ **Key notes:**
319
+ - `dependency.field` is optional (not required) — needed for value extraction but not for LLM sources
320
+ - `endpoint` must start with `/` (e.g., `/api/linkedin/user`)
321
+ - `categories` in classify steps is a comma-separated **string**, not an array
322
+ - `partition_by` in storage config is accepted but not currently used — layout is always `raw/<source_id>/<date>.parquet`
323
+
324
+ ---
325
+
326
+ ## Validation
327
+
328
+ Validate dataset config before collecting. Catches structural errors, missing dependencies, invalid filters, and bad LLM specs.
329
+
330
+ ```bash
331
+ anysite dataset validate dataset.yaml # Validate config
332
+ anysite dataset validate dataset.yaml --json # Machine-readable output
333
+ ```
334
+
127
335
  ---
128
336
 
129
337
  ## Collection Commands
130
338
 
131
339
  ```bash
340
+ # Validate config first (recommended)
341
+ anysite dataset validate dataset.yaml
342
+
132
343
  # Preview collection plan with estimated request counts
133
344
  anysite dataset collect dataset.yaml --dry-run
134
345
 
@@ -146,6 +357,9 @@ anysite dataset collect dataset.yaml --source employees
146
357
 
147
358
  # Quiet mode
148
359
  anysite dataset collect dataset.yaml --quiet
360
+
361
+ # Pilot run: max 100 inputs per source
362
+ anysite dataset collect dataset.yaml --limit 100
149
363
  ```
150
364
 
151
365
  ### Provenance Tracking
@@ -163,6 +377,26 @@ With `--incremental`:
163
377
  2. Dependent/from_file sources: skips individual input values already in `metadata.json`
164
378
  3. New values are still collected and tracked
165
379
 
380
+ ### Refresh Mode
381
+
382
+ Per-source `refresh` field controls behavior with `--incremental`:
383
+
384
+ ```yaml
385
+ - id: posts
386
+ endpoint: /api/linkedin/user/posts
387
+ dependency: { from_source: profiles, field: urn.value }
388
+ input_key: user
389
+ refresh: always # Re-collect every run even with --incremental
390
+ ```
391
+
392
+ | Setting | `--incremental` | No flag |
393
+ |---------|----------------|---------|
394
+ | `refresh: auto` (default) | Skip collected inputs | Collect all |
395
+ | `refresh: always` | Collect all (ignore cache) | Collect all |
396
+ | `refresh: never` | Skip if data exists | Skip if data exists |
397
+
398
+ Use `refresh: always` for sources with frequently changing data (e.g., posts, activity feeds) where you want fresh snapshots each run while still caching stable parent data. Use `refresh: never` for stable reference data that only needs to be collected once.
399
+
166
400
  ### Storage Layout
167
401
 
168
402
  ```
@@ -175,7 +409,7 @@ With `--incremental`:
175
409
 
176
410
  ## Query Commands (DuckDB)
177
411
 
178
- Each source becomes a DuckDB view named after its ID.
412
+ Each source becomes a DuckDB view named after its ID. Hyphens and dots in source IDs are replaced with underscores (e.g., `search-results` → `search_results`).
179
413
 
180
414
  ```bash
181
415
  # Direct SQL
@@ -189,6 +423,9 @@ anysite dataset query dataset.yaml --source profiles \
189
423
  --fields "name, urn.value AS urn_id, headline"
190
424
  # Generates: SELECT name, json_extract_string(urn, '$.value') AS urn_id, headline FROM profiles
191
425
 
426
+ # Exclude internal columns
427
+ anysite dataset query dataset.yaml --source profiles --exclude "_input_value,_parent_source"
428
+
192
429
  # Output options
193
430
  anysite dataset query dataset.yaml --sql "SELECT * FROM companies" \
194
431
  --format csv --output companies.csv
@@ -218,6 +455,7 @@ anysite dataset load-db dataset.yaml -c <connection_name> [OPTIONS]
218
455
  --connection, -c TEXT Database connection name (required)
219
456
  --source, -s TEXT Load specific source + dependencies
220
457
  --drop-existing Drop tables before creating
458
+ --snapshot TEXT Load a specific snapshot date (YYYY-MM-DD)
221
459
  --dry-run Show plan without executing
222
460
  --quiet, -q Suppress output
223
461
  ```
@@ -230,6 +468,29 @@ anysite dataset load-db dataset.yaml -c <connection_name> [OPTIONS]
230
468
  4. Inserts rows, tracking which `_input_value` maps to which `id`
231
469
  5. For child sources: adds `{parent_source}_id` FK column using provenance
232
470
 
471
+ ### Incremental Sync with `db_load.key`
472
+
473
+ When `db_load.key` is set and the table already exists with >=2 snapshots, `load-db` uses diff-based incremental sync instead of full re-insertion:
474
+
475
+ 1. Compares the two most recent Parquet snapshots using `DatasetDiffer`
476
+ 2. **Added** records → INSERT into DB
477
+ 3. **Removed** records → DELETE from DB (by key) — only in `sync: full` mode
478
+ 4. **Changed** records → UPDATE modified fields (by key)
479
+
480
+ This keeps the database in sync without duplicates.
481
+
482
+ **Sync modes** (`db_load.sync`):
483
+ - `full` (default) — applies INSERT, DELETE, and UPDATE from diff
484
+ - `append` — applies INSERT and UPDATE only, skips DELETE. Use for sources where the API returns only the latest N items (e.g., posts, comments) and you want to accumulate records over time.
485
+
486
+ | Scenario | Behavior |
487
+ |----------|----------|
488
+ | First load (table doesn't exist) | Full INSERT of latest snapshot |
489
+ | Table exists + `db_load.key` + >=2 snapshots | Diff-based sync (INSERT/DELETE/UPDATE delta) |
490
+ | `--drop-existing` | Drop table, full INSERT of latest snapshot |
491
+ | `--snapshot 2026-01-15` | Full INSERT of that specific snapshot |
492
+ | No `db_load.key` set | Full INSERT of latest snapshot (no diff) |
493
+
233
494
  ### db_load Config
234
495
 
235
496
  Control which fields go to the database per source:
@@ -237,6 +498,8 @@ Control which fields go to the database per source:
237
498
  ```yaml
238
499
  db_load:
239
500
  table: people # Custom table name (default: source ID)
501
+ key: urn.value # Unique key for diff-based incremental sync
502
+ sync: append # full (default) or append (no DELETE on diff)
240
503
  fields: # Explicit field list
241
504
  - name
242
505
  - url
@@ -265,7 +528,8 @@ Result in database:
265
528
 
266
529
  ### Connection Management
267
530
  ```bash
268
- anysite db add <name> # Interactive add
531
+ anysite db add <name> --type postgres --host localhost --database mydb --user app --password secret
532
+ anysite db add <name> --type postgres --host localhost --database mydb --user app --password-env DB_PASS
269
533
  anysite db list # List all connections
270
534
  anysite db test <name> # Test connectivity
271
535
  anysite db info <name> # Show connection details
@@ -309,9 +573,54 @@ anysite db query <name> --file report.sql --format csv --output report.csv
309
573
 
310
574
  ---
311
575
 
312
- ## Per-Source Transform
576
+ ## Three-Level Filtering
313
577
 
314
- Transforms apply to export destinations only. Parquet always stores full records (needed for dependency resolution).
578
+ The pipeline supports filtering at three independent stages:
579
+
580
+ ```
581
+ Collection → [source.filter] → LLM → Parquet → [transform.filter] → Export
582
+ [db_load.filter] → DB
583
+ ```
584
+
585
+ | Level | Config | When | Effect |
586
+ |-------|--------|------|--------|
587
+ | 1 | `source.filter` | After collection, before LLM + Parquet | Drops records entirely |
588
+ | 2 | `transform.filter` | After Parquet, before exports | Parquet keeps all |
589
+ | 3 | `db_load.filter` | Before DB loading | Parquet keeps all |
590
+
591
+ ```yaml
592
+ - id: analyzed
593
+ type: llm
594
+ dependency: { from_source: comments, field: urn.value }
595
+ filter: '.is_commenter_post_author == false' # Level 1
596
+ llm:
597
+ - type: enrich
598
+ add: ["score:1-10"]
599
+ transform:
600
+ filter: '.score > 5' # Level 2
601
+ fields: [text, author, score]
602
+ db_load:
603
+ filter: '.score > 8' # Level 3
604
+ fields: [text, author, score]
605
+ ```
606
+
607
+ ### Filter Syntax
608
+ Safe expression parser (no `eval()`). Supported operators: `==`, `!=`, `>`, `<`, `>=`, `<=`. Connectors: `and`, `or`. Values: strings (`"..."`), numbers, booleans (`true`/`false`), `null`.
609
+
610
+ ```
611
+ .field > 10
612
+ .status == "active"
613
+ .active == true
614
+ .hidden == false
615
+ .location != ""
616
+ .name != null
617
+ .count > 5 and .count < 100
618
+ .status == "active" or .status == "pending"
619
+ ```
620
+
621
+ ### Per-Source Transform
622
+
623
+ `transform` block applies to export destinations only. Parquet always stores full records.
315
624
 
316
625
  ```yaml
317
626
  transform:
@@ -326,18 +635,85 @@ transform:
326
635
  source: "linkedin"
327
636
  ```
328
637
 
329
- ### Filter Syntax
330
- Safe expression parser (no `eval()`). Supported operators: `==`, `!=`, `>`, `<`, `>=`, `<=`. Connectors: `and`, `or`. Values: strings (`"..."`), numbers, `null`.
638
+ ---
639
+
640
+ ## LLM Enrichment
641
+
642
+ Optional `llm` list per source. Steps run **after collection, before Parquet write**, so enriched fields are stored in Parquet and flow to DB via `db_load.fields`.
643
+
644
+ ### Step Types
645
+
646
+ | Type | Purpose | Required | Output |
647
+ |------|---------|----------|--------|
648
+ | `enrich` | Extract structured attributes | `add` specs | One column per spec |
649
+ | `classify` | Categorize records | `categories` (optional — auto-detects if omitted) | `category` + `category_confidence` |
650
+ | `summarize` | Concise text summary | — | `summary` |
651
+ | `generate` | Custom text from prompt template | `prompt` | `text` |
652
+
653
+ ### Config Example
331
654
 
655
+ ```yaml
656
+ sources:
657
+ - id: profiles
658
+ endpoint: /api/linkedin/user
659
+ llm:
660
+ - type: enrich
661
+ add:
662
+ - "sentiment:positive/negative/neutral"
663
+ - "language:string"
664
+ - "quality_score:1-10"
665
+ fields: [headline, summary] # Fields sent to LLM (empty = all)
666
+ - type: classify
667
+ categories: "developer,recruiter,executive"
668
+ output_column: role_type # Default: "category"
669
+ fields: [headline]
670
+ - type: summarize
671
+ max_length: 50
672
+ output_column: bio # Default: "summary"
673
+ - type: generate
674
+ prompt: "Write a pitch for {name} who works as {headline}"
675
+ output_column: pitch # Default: "text"
676
+ temperature: 0.7 # Higher for creative generation
677
+ db_load:
678
+ fields: [name, headline, sentiment, language, quality_score, role_type, bio, pitch]
332
679
  ```
333
- .field > 10
334
- .status == "active"
335
- .location != ""
336
- .name != null
337
- .count > 5 and .count < 100
338
- .status == "active" or .status == "pending"
680
+
681
+ ### Enrich Field Spec Formats
682
+
683
+ | Format | Example | JSON Schema Type |
684
+ |--------|---------|-----------------|
685
+ | Enum | `sentiment:positive/negative/neutral` | `string` with `enum` |
686
+ | String | `language:string` | `string` |
687
+ | Integer | `count:integer` | `integer` |
688
+ | Range | `score:1-10` | `integer` |
689
+ | Number | `weight:number` | `number` |
690
+ | Boolean | `active:boolean` | `boolean` |
691
+
692
+ ### Per-Step Overrides
693
+
694
+ Each step supports: `provider`, `model`, `temperature`, `max_tokens`, `fields`, `output_column`.
695
+
696
+ ### Collect Flags
697
+
698
+ ```bash
699
+ # Collect with LLM enrichment (default when llm: is configured)
700
+ anysite dataset collect dataset.yaml
701
+
702
+ # Skip LLM steps
703
+ anysite dataset collect dataset.yaml --no-llm
704
+
705
+ # Dry run shows LLM step count per source
706
+ anysite dataset collect dataset.yaml --dry-run
339
707
  ```
340
708
 
709
+ ### LLM Response Caching
710
+
711
+ LLM responses are cached in SQLite (`~/.anysite/llm_cache.db`). Re-running collection on the same data reuses cached results without calling the LLM provider. Clear with `anysite llm cache-clear`.
712
+
713
+ ### Incremental Behavior
714
+
715
+ `--incremental` skips already-collected inputs at collection stage — only new records reach LLM enrichment. LLM response cache provides additional savings for repeated content.
716
+
341
717
  ---
342
718
 
343
719
  ## Per-Source Export
@@ -421,6 +797,42 @@ Payload: `{event: "complete"|"failure", dataset, timestamp, record_count, source
421
797
 
422
798
  ---
423
799
 
800
+ ## Comparing Snapshots (Diff)
801
+
802
+ Compare two collection snapshots to find added, removed, and changed records.
803
+
804
+ ```bash
805
+ # Compare two most recent snapshots (auto-detect dates)
806
+ anysite dataset diff dataset.yaml --source profiles --key _input_value
807
+
808
+ # Compare with dot-notation key (JSON fields)
809
+ anysite dataset diff dataset.yaml --source profiles --key urn.value
810
+
811
+ # Compare specific dates
812
+ anysite dataset diff dataset.yaml --source profiles --key urn.value --from 2026-01-30 --to 2026-02-01
813
+
814
+ # Only compare specific fields
815
+ anysite dataset diff dataset.yaml --source profiles --key urn --fields "name,headline,follower_count"
816
+
817
+ # Output as JSON/CSV
818
+ anysite dataset diff dataset.yaml --source profiles --key urn --format json --output diff.json
819
+ ```
820
+
821
+ **Options:**
822
+ - `--source, -s` (required) — source to compare
823
+ - `--key, -k` (required) — field to match records by. Supports dot-notation for JSON fields (e.g., `urn.value`)
824
+ - `--from` / `--to` — snapshot dates (default: two most recent)
825
+ - `--fields, -f` — restrict both comparison and output to these fields
826
+ - `--format` — output format (table, json, jsonl, csv)
827
+ - `--output, -o` — write to file
828
+
829
+ **Output** shows summary counts and a table of changes:
830
+ - **added** — records in the new snapshot but not the old
831
+ - **removed** — records in the old snapshot but not the new
832
+ - **changed** — records with the same key but different values (shows `old → new`)
833
+
834
+ ---
835
+
424
836
  ## Reset Incremental State
425
837
 
426
838
  Clear collected input tracking to force re-collection.
@@ -432,3 +844,27 @@ anysite dataset reset-cursor dataset.yaml
432
844
  # Reset specific source
433
845
  anysite dataset reset-cursor dataset.yaml --source profiles
434
846
  ```
847
+
848
+ ---
849
+
850
+ ## Common Mistakes
851
+
852
+ | # | Mistake | Fix |
853
+ |---|---------|-----|
854
+ | 1 | `field: urn.value` for author profiles (wrong URN) | Use `field: author.urn.value` — run `anysite describe` to find correct path |
855
+ | 2 | `input_template: { company: "microsoft" }` (missing placeholder) | Use `company: "{value}"` — `{value}` is replaced with extracted value |
856
+ | 3 | `endpoint: api/linkedin/user` (no leading `/`) | Use `endpoint: /api/linkedin/user` |
857
+ | 4 | `categories: ["pos", "neg"]` (array) | Use `categories: "pos,neg"` — comma-separated string |
858
+ | 5 | `parallel: 50` (too high) | Use `parallel: 3` to `5` — higher causes rate limiting |
859
+ | 6 | Confusing `field` and `input_key` | `field` = WHAT to extract from parent; `input_key` = WHERE to put it in API call |
860
+ | 7 | `type: llm` without `dependency` | LLM sources require `dependency` (parent source) and `llm` (>= 1 step) |
861
+ | 8 | SQL query uses `search-results` view name | Hyphens → underscores in DuckDB views: use `search_results` |
862
+
863
+ **Validate catches most errors:**
864
+ ```bash
865
+ anysite dataset validate dataset.yaml --json
866
+ # "Endpoint must start with '/'" → add leading /
867
+ # "enrich step requires 'add'" → add 'add' list to enrich step
868
+ # "LLM source must have a dependency" → add dependency block
869
+ # "did you mean '=='?" → replace = with == in filter
870
+ ```