pdftract/docs/research/profile-authoring.md
jedarden 55da9ddf51 feat(bf-4cbfb): add community profiles directory and CONTRIBUTING guide
- Create profiles/community/ directory structure with CONTRIBUTING.md
- CONTRIBUTING.md covers all required contribution areas:
  * File naming convention and directory structure
  * Required profile fields and schema requirements
  * Local testing workflow with validation steps
  * PR submission process and review guidelines
  * License requirements for fixture PDFs
  * Review checklist and quality standards
- Update extraction_loader.rs to add Community profile origin
- Update profiles_cmd.rs to list community profiles separately
- Document community profiles in profile-authoring.md search path
- Add test-profile example to verify implementation

Acceptance criteria met:
 profiles/community/CONTRIBUTING.md exists with comprehensive guide
 Code supports loading and listing community profiles
 Path documented in profile-authoring.md

Closes bf-4cbfb
2026-07-05 13:01:54 -04:00

599 lines
16 KiB
Markdown

# Profile Authoring Guide — YAML Footguns and Validation
This guide documents YAML authoring gotchas for document profiles and the `pdftract profiles validate` command. Required by [ADR-007](../plan/plan.md#adr-007-use-yaml-not-toml-or-json-for-profile-templates).
## 1. YAML Footguns
### 1.1 Scalar Type Coercion
YAML implicitly converts certain unquoted strings to boolean or integer values. **Always quote strings** that could be misinterpreted:
```yaml
# ❌ WRONG — YAML parses "on" as boolean true
enabled: on
yes_count: yes
no_count: no
true_value: true
# ✅ CORRECT — quoted strings preserve literal values
enabled: "on"
yes_count: "yes"
no_count: "no"
true_value: "true"
```
**Common coercion patterns that bite profile authors:**
| Literal string | YAML interprets as | Quote to preserve |
|----------------|-------------------|-------------------|
| `on`, `On`, `ON` | `true` (boolean) | `"on"` |
| `off`, `Off`, `OFF` | `false` (boolean) | `"off"` |
| `yes`, `Yes`, `YES` | `true` (boolean) | `"yes"` |
| `no`, `No`, `NO` | `false` (boolean) | `"no"` |
| `true`, `True`, `TRUE` | `true` (boolean) | `"true"` |
| `false`, `False`, `FALSE` | `false` (boolean) | `"false"` |
| `123`, `3.14` | integer/float | `"123"` |
### 1.2 Significant Indentation
YAML is indentation-sensitive (like Python). **Use spaces, not tabs**. Consistent 2-space indentation is recommended for profiles:
```yaml
# ❌ WRONG — inconsistent indentation breaks YAML parsing
match:
all:
- any:
- text_contains:
patterns: ["invoice"]
# ✅ CORRECT — consistent 2-space indentation
match:
all:
- any:
- text_contains:
patterns: ["invoice"]
```
### 1.3 Multi-line Strings
For regex patterns with quotes or complex strings, use proper multi-line syntax:
```yaml
# ❌ WRONG — unescaped special characters break parsing
patterns: ["invoice["] # Unclosed bracket
patterns: ["invoice\"total\""] # Confusing quote escaping
# ✅ CORRECT — use YAML block scalar for complex strings
patterns: [
"invoice\\[",
"invoice\\[.*\\]",
"invoice\\s*#\\s*([\\w-]+)"
]
```
**Block scalar syntax for very long patterns:**
```yaml
# Literal block scalar (|-) preserves newlines, no escaping
long_regex: |-
Invoice\s*#\s*[\w-]+
.*
Total:\s*\$?\s*[\d,]+\.\d{2}
# Folded block scalar (>) folds newlines into spaces
description: >
This profile matches commercial invoices
with line items, vendor/customer fields,
and monetary totals.
```
## 2. The `match:` Combinator DSL
The `match:` section uses boolean combinators to define document classification rules. Three combinators are available: `all:`, `any:`, and `none:`.
### 2.1 Basic Structure
```yaml
match:
all: # ALL conditions must be satisfied
- any: # At least ONE condition must be satisfied
- text_contains:
patterns: ["invoice"]
- heading_matches:
pattern: "^Invoice\\b"
- structural: # AND this structural condition
has_table: true
none: # NONE of these conditions may match
- text_contains:
patterns: ["abstract", "scientific paper"]
```
### 2.2 Common Mistakes
**❌ WRONG — Single value instead of list under `all:`:**
```yaml
match:
all:
text_contains: # Invalid: all expects a list
patterns: ["invoice"]
```
**✅ CORRECT — List structure for combinators:**
```yaml
match:
all:
- text_contains: # Valid: list of predicates
patterns: ["invoice"]
```
**❌ WRONG — Mixing `any:` with direct conditions:**
```yaml
match:
any:
- text_contains:
patterns: ["invoice"]
structural: # Invalid: not in a list item
has_table: true
```
**✅ CORRECT — All conditions inside list items:**
```yaml
match:
any:
- text_contains:
patterns: ["invoice"]
- structural:
has_table: true
```
### 2.3 Predicate Types
#### `text_contains`
Searches page text for pattern matches:
```yaml
- text_contains:
patterns: ["invoice", "bill to", "invoice #"]
case_sensitive: false # optional, default: false
min_hits: 1 # optional, default: 1
```
#### `heading_matches`
Matches against heading text:
```yaml
- heading_matches:
pattern: "^Invoice\\b" # Regex anchored to heading start
case_sensitive: false # optional
```
#### `structural`
Tests document structure:
```yaml
- structural:
has_table: true
has_form_field: false
has_math: false
page_count:
min: 1
max: 5
```
## 3. The `fields:` DSL
The `fields:` section defines extracted fields and their extraction strategies.
### 3.1 Field Definition Structure
```yaml
fields:
invoice_number:
type: string # Field type: string, date, decimal, int, bool, array
extraction:
regex: "Invoice\\s*#\\s*([\\w-]+)" # Capture group 1 is the value
near: ["Invoice", "Invoice #"] # Search near anchor text
max_distance_pt: 200 # Max distance in points
parse: string # Parser: string, decimal, date, int, bool
region: top_quarter # Optional region hint
pick: nearest_below # Disambiguation strategy
```
### 3.2 Field Extraction Strategies
#### `regex:` Pattern Matching
```yaml
# ❌ WRONG — Unescaped regex special characters
regex: "Invoice # ([\w-]+)" # Space is ambiguous
# ✅ CORRECT — Proper escaping
regex: "Invoice\\s*#\\s*([\\w-]+)"
```
#### `near:` Anchor Text Search
```yaml
# Search near anchor text (list of alternatives)
near: ["Invoice", "Invoice #", "Invoice Number"]
max_distance_pt: 200
```
#### `region:` Page Region Hints
```yaml
# Restrict search to page regions
region: top_quarter # | bottom_quarter | left_half | right_half
region: top:50 # Top 50 points
region: bbox:[0,0,200,100] # Explicit bounding box [x0,y0,x1,y1]
```
#### `pick:` Disambiguation
```yaml
# When multiple candidates match, pick one
pick: largest_font # | smallest_font | nearest_below | nearest_right | first | last
```
#### `parse:` Type Coercion
```yaml
# Parse matched text into typed values
parse: string # | decimal | date | int | bool
# Date parsing is heuristic (tries common formats)
parse: date # Detects: 2025-09-14, 09/14/2025, 14-Sep-2025, etc.
```
### 3.3 Common `fields:` Mistakes
**❌ WRONG — Missing extraction block:**
```yaml
fields:
invoice_number:
type: string
regex: "Invoice\\s*#\\s*([\\w-]+)" # Invalid: regex must be under extraction
```
**✅ CORRECT — Proper extraction nesting:**
```yaml
fields:
invoice_number:
type: string
extraction:
regex: "Invoice\\s*#\\s*([\\w-]+)"
```
**❌ WRONG — Invalid parse type combination:**
```yaml
fields:
total:
type: decimal
extraction:
parse: bool # Inconsistent: type is decimal but parse is bool
```
**✅ CORRECT — Consistent types:**
```yaml
fields:
total:
type: decimal
extraction:
parse: decimal
```
## 4. Common Validation Errors
The `pdftract profiles validate` command catches common mistakes. Run it after editing any profile:
```bash
pdftract profiles validate my-profile.yaml
```
### 4.1 YAML Parse Errors
**Error message:**
```
Error: YAML parse error at line 9, column 10:
unclosed bracket '['
```
**Causes:**
- Unclosed brackets: `patterns: ["test"`
- Unescaped quotes: `patterns: ["invoice["]`
- Inconsistent indentation
- Tab characters (use spaces)
### 4.2 Schema Validation Errors
**Error message:**
```
Error: Profile validation failed:
- Unknown key 'not_a_real_key' at extraction.not_a_real_key
- Missing required key 'name' at profile root
```
**Causes:**
- Typos in key names
- Missing required fields
- Keys not recognized by the profile schema
### 4.3 PROFILE_SECRETS_FORBIDDEN Errors
**Error message:**
```
Error: PROFILE_SECRETS_FORBIDDEN — profiles must not contain secret keys:
Forbidden key 'api_key' found at extraction.api_key
Forbidden key 'password' found at fields.login.extraction
```
**Causes:**
- Including secret keys in profiles (forbidden by security policy)
- Common forbidden keys: `api_key`, `password`, `token`, `secret`, `credential`
**Why this is forbidden:** Profiles are configuration files that may be checked into version control or shared. Secrets must be injected via environment variables, not embedded in profiles.
### 4.4 Combinator Structure Errors
**Error message:**
```
Error: Invalid combinator structure:
'all' requires a list, found single value at match.all
```
**Causes:**
- Single value instead of list under `all:`, `any:`, `none:`
- Mixing list items with direct key-value pairs
## 5. Profile Search Path Resolution
Profiles are resolved from multiple sources in order of priority (later sources override earlier ones on name collision):
1. **Built-in profiles** (compiled into binary at `profiles/builtin/`)
2. **Community profiles** (`profiles/community/*.yaml` or `profiles/community/*/profile.yaml`)
3. **System-wide profiles** (`/etc/pdftract/profiles/*.yaml`)
4. **User profiles** (`$XDG_CONFIG_HOME/pdftract/profiles/*.yaml`, defaults to `~/.config/pdftract/profiles/`)
5. **Runtime directories** (`--profile-dir DIR` CLI flag, repeatable)
### 5.1 Community Profiles
The `profiles/community/` directory contains user-contributed profiles for specialized document types. These are included in the repository and can be used directly:
```bash
# Use a community profile
pdftract extract --profile profiles/community/my-profile/profile.yaml document.pdf
# Or copy it to your user config directory
pdftract profiles install profiles/community/my-profile/profile.yaml
pdftract extract --profile my-profile document.pdf
```
See [Community Contribution Guide](../../profiles/community/CONTRIBUTING.md) for guidelines on submitting community profiles.
### 5.1 Shadowing Behavior
A user profile shadows a built-in profile of the same name:
```bash
# Export built-in invoice profile
pdftract profiles export invoice > ~/.config/pdftract/profiles/invoice.yaml
# Edit the file...
vim ~/.config/pdftract/profiles/invoice.yaml
# Next invocation uses the modified version
pdftract extract --profile invoice file.pdf
```
Verify which profile is active:
```bash
pdftract profiles list
# Output:
# invoice (user, overrides built-in)
# receipt (built-in)
# contract (built-in)
```
### 5.2 Resolution Order Example
```bash
# Assume built-in profile 'invoice' exists
# User creates ~/.config/pdftract/profiles/invoice.yaml
# System has /etc/pdftract/profiles/invoice.yaml
pdftract extract --profile invoice file.pdf
# Uses ~/.config/pdftract/profiles/invoice.yaml (highest priority)
pdftract extract --profile-dir /tmp/custom_profiles --profile invoice file.pdf
# Uses /tmp/custom_profiles/invoice.yaml (CLI flag highest priority)
```
## 6. PROFILE_SECRETS_FORBIDDEN Constraint
### 6.1 What Is Forbidden
Profiles must not contain secret keys or sensitive credentials. The following key patterns are rejected at validation:
- `api_key`
- `password`
- `token`
- `secret`
- `credential`
- `private_key`
- `auth_token`
### 6.2 Why This Exists
Profiles are configuration files intended to be:
- Checked into version control
- Shared across teams
- Documented in examples and tutorials
Embedding secrets in profiles violates the principle that **configuration is code, secrets are environment**.
### 6.3 Correct Pattern: Environment Variables
If you need to parameterize a profile, use environment variable references instead of hardcoded secrets:
```yaml
# ❌ WRONG — Hardcoded secret
extraction:
api_key: "sk-1234567890abcdef"
# ✅ CORRECT — Environment variable reference (implementation-specific)
# Profiles should avoid needing secrets entirely; pass credentials via CLI/env
```
**Note:** The current profile design does not support environment variable interpolation. If your use case requires secrets, it may not be a good fit for profile-based extraction.
## 7. Complete Annotated Example
Here's a complete, valid invoice profile with annotations:
```yaml
# Invoice extraction profile
# Matches commercial invoices with line items, vendor/customer, and totals
name: invoice
description: Commercial invoice with line items, vendor/customer, and totals
priority: 50
# Document classification: matches invoices only if ALL conditions are satisfied
match:
all:
# At least one heading/heading pattern must match
- any:
- text_contains:
patterns: ["invoice", "bill to", "invoice #", "invoice number", "tax invoice"]
case_sensitive: false
- heading_matches:
pattern: "^Invoice\\b"
# Must have currency pattern OR a table
- any:
- has_currency_pattern:
has_currency_pattern: true
- structural:
has_table: true
has_form_field: false
page_count:
min: 1
max: 5
# Must NOT contain these academic paper markers
none:
- text_contains:
patterns: ["abstract", "bibliography", "scientific paper"]
# Extraction tuning parameters
extraction:
reading_order: line_dominant # Use line-based reading order
table_detection: strict_borders # Strict table boundary detection
readability_threshold: 0.4 # Skip text blocks below readability threshold
include_invisible: false # Exclude invisible text
include_headers_footers: false # Exclude headers/footers
force_ocr: false # Don't force OCR if text exists
min_block_chars: 0 # No minimum character threshold
# Field extraction definitions
fields:
invoice_number:
type: string
extraction:
regex: "Invoice\\s*#\\s*([\\w-]+)"
near: ["Invoice", "Invoice Number", "Invoice #"]
max_distance_pt: 200
parse: string
vendor:
type: string
extraction:
region: top_quarter
pick: largest_font
customer:
type: string
extraction:
near: ["Bill To", "Customer", "Sold To"]
max_distance_pt: 150
pick: nearest_below
parse: string
invoice_date:
type: date
extraction:
near: ["Date", "Invoice Date"]
max_distance_pt: 100
parse: date
total:
type: decimal
extraction:
regex: "([\\d,]+\\.\\d{2})"
near: ["Total", "Amount Due", "Balance Due", "Grand Total"]
max_distance_pt: 80
parse: decimal
line_items:
type: array
extraction:
table_region: largest_table
schema:
- name: description
type: string
required: true
- name: quantity
type: decimal
required: false
- name: unit_price
type: decimal
required: false
- name: amount
type: decimal
required: false
fallback: [] # Empty array if no table found
```
## 8. Validation Workflow
Recommended workflow when authoring profiles:
1. **Create the profile:**
```bash
vim ~/.config/pdftract/profiles/my-profile.yaml
```
2. **Validate syntax:**
```bash
pdftract profiles validate ~/.config/pdftract/profiles/my-profile.yaml
```
3. **Test against fixture:**
```bash
pdftract extract --profile my-profile fixture.pdf
```
4. **Iterate:** Edit and re-validate until validation passes
5. **Check for shadowing:**
```bash
pdftract profiles list | grep my-profile
```
6. **Commit and test:** If sharing with a team, commit to version control and validate in CI
## References
- [ADR-007: Use YAML for profile templates](../plan/plan.md#adr-007-use-yaml-not-toml-or-json-for-profile-templates) — Rationale for YAML over TOML/JSON
- [Phase 7.10: Document Profiles and Field Extraction](../plan/plan.md#710-document-profiles-and-field-extraction) — Full profile specification
- [Profile validation tests](../../tests/integration/advanced/profiles.rs) — Integration test suite
- [Example profiles](../../profiles/builtin/) — Built-in profile implementations