@anysiteio/agent-skills 1.0.1 → 1.3.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.
@@ -0,0 +1,870 @@
1
+ # Dataset Pipeline & Database Guide
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
+
17
+ ## Dataset YAML Configuration
18
+
19
+ ### Full Structure
20
+
21
+ ```yaml
22
+ name: my-dataset
23
+ description: Optional description
24
+
25
+ sources:
26
+ - id: source_id # Unique identifier
27
+ endpoint: /api/... # API endpoint path
28
+ input: {} # Static API parameters
29
+ from_file: inputs.txt # Input file (txt/JSONL/CSV)
30
+ file_field: column_name # CSV column to extract
31
+ input_key: param_name # API parameter for input values
32
+ input_template: {} # Transform values before API call
33
+ dependency: # Link to parent source
34
+ from_source: parent_id
35
+ field: urn.value # Dot-notation field to extract
36
+ match_by: name # Alternative: fuzzy match by name
37
+ dedupe: true # Remove duplicate values
38
+ parallel: 3 # Concurrent requests
39
+ rate_limit: "10/s" # Rate limiting
40
+ on_error: skip # stop or skip
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"'
45
+ fields: [name, url] # Field selection with aliases
46
+ add_columns: # Static columns to inject
47
+ batch: "q1-2026"
48
+ export: # Per-source export destinations
49
+ - type: file
50
+ path: ./output/{{source}}-{{date}}.csv
51
+ format: csv # json, jsonl, csv
52
+ - type: webhook
53
+ url: https://example.com/hook
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
71
+ db_load: # Database loading config
72
+ filter: '.active == true' # DB load filter (only matching records to DB)
73
+ table: custom_name # Override table name
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]
77
+ exclude: [_input_value] # Fields to exclude
78
+
79
+ storage:
80
+ format: parquet
81
+ path: ./data/
82
+ partition_by: [source_id, collected_date]
83
+
84
+ schedule: # Collection schedule
85
+ cron: "0 9 * * *" # Standard cron expression
86
+
87
+ notifications: # Webhook notifications
88
+ on_complete:
89
+ - url: "https://hooks.slack.com/xxx"
90
+ headers: {X-Token: abc}
91
+ on_failure:
92
+ - url: "https://alerts.example.com/fail"
93
+ ```
94
+
95
+ ### Six Source Types
96
+
97
+ **Independent** — single API call with static input:
98
+ ```yaml
99
+ - id: search_results
100
+ endpoint: /api/linkedin/search/users
101
+ input:
102
+ keywords: "software engineer"
103
+ count: 50
104
+ ```
105
+
106
+ **from_file** — iterate over values from a file:
107
+ ```yaml
108
+ - id: companies
109
+ endpoint: /api/linkedin/company
110
+ from_file: companies.txt # One value per line
111
+ input_key: company # API parameter name
112
+ parallel: 3
113
+ ```
114
+
115
+ **Dependent** — extract values from parent source output:
116
+ ```yaml
117
+ - id: employees
118
+ endpoint: /api/linkedin/company/employees
119
+ dependency:
120
+ from_source: companies # Parent source ID
121
+ field: urn.value # Dot-notation path in parent records
122
+ dedupe: true # Deduplicate extracted values
123
+ input_key: companies # API parameter name
124
+ input_template: # Transform value before API call
125
+ companies:
126
+ - type: company
127
+ value: "{value}" # {value} is replaced with extracted value
128
+ count: 5
129
+ ```
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
+
186
+ ### Dependency Chains
187
+
188
+ Sources are topologically sorted — parents always run before children. Multi-level chains work automatically:
189
+
190
+ ```
191
+ companies → employees → profiles → posts → comments
192
+ ```
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
+
202
+ ### input_template
203
+
204
+ Transforms extracted values before passing to the API. Use `{value}` placeholder:
205
+
206
+ ```yaml
207
+ input_template:
208
+ urn: "urn:li:fsd_profile:{value}"
209
+ count: 5
210
+ ```
211
+
212
+ If `field: urn.value` extracts `"ACoAABCDEF"`, the API receives:
213
+ ```json
214
+ {"urn": "urn:li:fsd_profile:ACoAABCDEF", "count": 5}
215
+ ```
216
+
217
+ ### Dot-Notation Field Extraction
218
+
219
+ When Parquet stores nested objects as JSON strings, dot-notation traverses them:
220
+
221
+ - `urn.value` — parses JSON string in `urn` field, extracts `.value`
222
+ - `experience[0].company_urn` — array index + nested field
223
+ - `internal_id.value` — nested object access
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
+
335
+ ---
336
+
337
+ ## Collection Commands
338
+
339
+ ```bash
340
+ # Validate config first (recommended)
341
+ anysite dataset validate dataset.yaml
342
+
343
+ # Preview collection plan with estimated request counts
344
+ anysite dataset collect dataset.yaml --dry-run
345
+
346
+ # Full collection
347
+ anysite dataset collect dataset.yaml
348
+
349
+ # Collect and auto-load into database
350
+ anysite dataset collect dataset.yaml --load-db pg
351
+
352
+ # Incremental — skip inputs already collected
353
+ anysite dataset collect dataset.yaml --incremental --load-db pg
354
+
355
+ # Single source + its dependencies
356
+ anysite dataset collect dataset.yaml --source employees
357
+
358
+ # Quiet mode
359
+ anysite dataset collect dataset.yaml --quiet
360
+
361
+ # Pilot run: max 100 inputs per source
362
+ anysite dataset collect dataset.yaml --limit 100
363
+ ```
364
+
365
+ ### Provenance Tracking
366
+
367
+ Dependent and from_file records automatically get metadata columns:
368
+ - `_input_value` — the raw extracted value that produced this record
369
+ - `_parent_source` — parent source ID (dependent sources only)
370
+
371
+ These enable FK linking when loading into a database.
372
+
373
+ ### Incremental Collection
374
+
375
+ With `--incremental`:
376
+ 1. Independent sources: skipped if already collected today
377
+ 2. Dependent/from_file sources: skips individual input values already in `metadata.json`
378
+ 3. New values are still collected and tracked
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
+
400
+ ### Storage Layout
401
+
402
+ ```
403
+ <storage.path>/
404
+ raw/<source_id>/<YYYY-MM-DD>.parquet
405
+ metadata.json
406
+ ```
407
+
408
+ ---
409
+
410
+ ## Query Commands (DuckDB)
411
+
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`).
413
+
414
+ ```bash
415
+ # Direct SQL
416
+ anysite dataset query dataset.yaml --sql "SELECT * FROM companies LIMIT 10"
417
+
418
+ # Shorthand: --source auto-generates SELECT
419
+ anysite dataset query dataset.yaml --source profiles
420
+
421
+ # Dot-notation field extraction
422
+ anysite dataset query dataset.yaml --source profiles \
423
+ --fields "name, urn.value AS urn_id, headline"
424
+ # Generates: SELECT name, json_extract_string(urn, '$.value') AS urn_id, headline FROM profiles
425
+
426
+ # Exclude internal columns
427
+ anysite dataset query dataset.yaml --source profiles --exclude "_input_value,_parent_source"
428
+
429
+ # Output options
430
+ anysite dataset query dataset.yaml --sql "SELECT * FROM companies" \
431
+ --format csv --output companies.csv
432
+
433
+ # Interactive SQL shell
434
+ anysite dataset query dataset.yaml --interactive
435
+
436
+ # Column statistics
437
+ anysite dataset stats dataset.yaml --source companies
438
+
439
+ # Data profiling (completeness, record counts)
440
+ anysite dataset profile dataset.yaml
441
+ ```
442
+
443
+ ---
444
+
445
+ ## Database Loading (load-db)
446
+
447
+ Load Parquet data into a relational database with automatic FK linking.
448
+
449
+ ```bash
450
+ anysite dataset load-db dataset.yaml -c <connection_name> [OPTIONS]
451
+ ```
452
+
453
+ ### Options
454
+ ```
455
+ --connection, -c TEXT Database connection name (required)
456
+ --source, -s TEXT Load specific source + dependencies
457
+ --drop-existing Drop tables before creating
458
+ --snapshot TEXT Load a specific snapshot date (YYYY-MM-DD)
459
+ --dry-run Show plan without executing
460
+ --quiet, -q Suppress output
461
+ ```
462
+
463
+ ### What load-db Does
464
+
465
+ 1. Reads Parquet files for each source (in topological order)
466
+ 2. Infers SQL schema from data (integer, float, text, json, etc.)
467
+ 3. Creates tables with auto-increment `id` primary key
468
+ 4. Inserts rows, tracking which `_input_value` maps to which `id`
469
+ 5. For child sources: adds `{parent_source}_id` FK column using provenance
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
+
494
+ ### db_load Config
495
+
496
+ Control which fields go to the database per source:
497
+
498
+ ```yaml
499
+ db_load:
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)
503
+ fields: # Explicit field list
504
+ - name
505
+ - url
506
+ - urn.value AS urn_id # Dot-notation with alias
507
+ - experience # JSON columns stored as TEXT/JSONB
508
+ exclude: # Fields to skip (default: _input_value, _parent_source)
509
+ - _input_value
510
+ - _parent_source
511
+ - raw_html
512
+ ```
513
+
514
+ Without `db_load`: all fields except `_input_value` and `_parent_source` are loaded.
515
+
516
+ ### FK Linking Example
517
+
518
+ Given: companies → employees → posts
519
+
520
+ Result in database:
521
+ - `companies` table: `id`, name, url, ...
522
+ - `employees` table: `id`, name, ..., `companies_id` (FK to companies.id)
523
+ - `posts` table: `id`, text, ..., `employees_id` (FK to employees.id)
524
+
525
+ ---
526
+
527
+ ## Database Commands (anysite db)
528
+
529
+ ### Connection Management
530
+ ```bash
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
533
+ anysite db list # List all connections
534
+ anysite db test <name> # Test connectivity
535
+ anysite db info <name> # Show connection details
536
+ anysite db remove <name> # Delete connection
537
+ ```
538
+
539
+ Connections stored in `~/.anysite/connections.yaml`. Passwords use env var references for security.
540
+
541
+ ### Schema Inspection
542
+ ```bash
543
+ anysite db schema <name> # List all tables
544
+ anysite db schema <name> --table users # Show columns
545
+ ```
546
+
547
+ ### Data Operations
548
+ ```bash
549
+ # Insert from stdin (auto-create table)
550
+ cat data.jsonl | anysite db insert <name> --table users --stdin --auto-create
551
+
552
+ # Insert from file
553
+ anysite db insert <name> --table users --file data.jsonl
554
+
555
+ # Upsert (update on conflict)
556
+ anysite db upsert <name> --table users --conflict-columns id --stdin
557
+
558
+ # Insert with conflict handling
559
+ anysite db insert <name> --table users --file data.jsonl \
560
+ --on-conflict ignore --conflict-columns email
561
+ ```
562
+
563
+ ### SQL Queries
564
+ ```bash
565
+ anysite db query <name> --sql "SELECT * FROM users" --format table
566
+ anysite db query <name> --file report.sql --format csv --output report.csv
567
+ ```
568
+
569
+ ### Supported Databases
570
+ - **SQLite** — `--type sqlite --path ./data.db`
571
+ - **PostgreSQL** — `--type postgres --host localhost --database mydb --user app --password-env DB_PASS`
572
+ - PostgreSQL also supports `--url-env DATABASE_URL` for connection strings
573
+
574
+ ---
575
+
576
+ ## Three-Level Filtering
577
+
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.
624
+
625
+ ```yaml
626
+ transform:
627
+ filter: '.employee_count > 10 and .status == "active"'
628
+ fields:
629
+ - name
630
+ - url
631
+ - urn.value AS urn_id # Dot-notation with alias
632
+ - employee_count
633
+ add_columns:
634
+ batch: "q1-2026"
635
+ source: "linkedin"
636
+ ```
637
+
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
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]
679
+ ```
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
707
+ ```
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
+
717
+ ---
718
+
719
+ ## Per-Source Export
720
+
721
+ Export destinations run after Parquet write. Transform is applied to export records if configured.
722
+
723
+ ### File Export
724
+ ```yaml
725
+ export:
726
+ - type: file
727
+ path: ./output/{{source}}-{{date}}.csv
728
+ format: csv # json, jsonl, csv
729
+ ```
730
+
731
+ Template variables: `{{date}}` (YYYY-MM-DD), `{{datetime}}` (ISO), `{{source}}` (source ID), `{{dataset}}` (dataset name).
732
+
733
+ ### Webhook Export
734
+ ```yaml
735
+ export:
736
+ - type: webhook
737
+ url: https://example.com/hook
738
+ headers:
739
+ X-Token: abc
740
+ ```
741
+
742
+ Sends POST with JSON body: `{dataset, source, count, records, timestamp}`.
743
+
744
+ ---
745
+
746
+ ## Run History & Logs
747
+
748
+ Every `collect` run is automatically recorded in SQLite (`~/.anysite/dataset_history.db`).
749
+
750
+ ```bash
751
+ # View run history
752
+ anysite dataset history my-dataset
753
+ anysite dataset history my-dataset --limit 5
754
+
755
+ # View logs for a specific run
756
+ anysite dataset logs my-dataset --run 42
757
+
758
+ # View latest run logs
759
+ anysite dataset logs my-dataset
760
+ ```
761
+
762
+ ---
763
+
764
+ ## Scheduling
765
+
766
+ Generate cron or systemd entries from the `schedule.cron` config.
767
+
768
+ ```bash
769
+ # Crontab entry (default)
770
+ anysite dataset schedule dataset.yaml --incremental --load-db pg
771
+
772
+ # Systemd timer units
773
+ anysite dataset schedule dataset.yaml --systemd --incremental --load-db pg
774
+ ```
775
+
776
+ Output example:
777
+ ```
778
+ 0 9 * * * /path/to/anysite dataset collect dataset.yaml --incremental --load-db pg >> ~/.anysite/logs/my-dataset_cron.log 2>&1
779
+ ```
780
+
781
+ ---
782
+
783
+ ## Notifications
784
+
785
+ Webhook notifications sent on collection complete or failure.
786
+
787
+ ```yaml
788
+ notifications:
789
+ on_complete:
790
+ - url: "https://hooks.slack.com/services/xxx"
791
+ headers: {Authorization: "Bearer token"}
792
+ on_failure:
793
+ - url: "https://alerts.example.com/fail"
794
+ ```
795
+
796
+ Payload: `{event: "complete"|"failure", dataset, timestamp, record_count, source_count, duration, error}`.
797
+
798
+ ---
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
+
836
+ ## Reset Incremental State
837
+
838
+ Clear collected input tracking to force re-collection.
839
+
840
+ ```bash
841
+ # Reset all sources
842
+ anysite dataset reset-cursor dataset.yaml
843
+
844
+ # Reset specific source
845
+ anysite dataset reset-cursor dataset.yaml --source profiles
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
+ ```