epi-recorder 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

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.
epi_cli/verify.py CHANGED
@@ -46,7 +46,7 @@ def verify(
46
46
  """
47
47
  # Ensure file exists
48
48
  if not epi_file.exists():
49
- console.print(f"[red] Error:[/red] File not found: {epi_file}")
49
+ console.print(f"[red][FAIL] Error:[/red] File not found: {epi_file}")
50
50
  raise typer.Exit(1)
51
51
 
52
52
  # Initialize verification state
@@ -65,11 +65,11 @@ def verify(
65
65
  try:
66
66
  manifest = EPIContainer.read_manifest(epi_file)
67
67
  if verbose:
68
- console.print(" [green][/green] Valid ZIP format")
69
- console.print(" [green][/green] Valid mimetype")
70
- console.print(" [green][/green] Valid manifest schema")
68
+ console.print(" [green][OK][/green] Valid ZIP format")
69
+ console.print(" [green][OK][/green] Valid mimetype")
70
+ console.print(" [green][OK][/green] Valid manifest schema")
71
71
  except Exception as e:
72
- console.print(f"[red] Structural validation failed:[/red] {e}")
72
+ console.print(f"[red][FAIL] Structural validation failed:[/red] {e}")
73
73
  raise typer.Exit(1)
74
74
 
75
75
  # ========== STEP 2: INTEGRITY CHECKS ==========
@@ -80,9 +80,9 @@ def verify(
80
80
 
81
81
  if verbose:
82
82
  if integrity_ok:
83
- console.print(f" [green][/green] All {len(manifest.file_manifest)} files verified")
83
+ console.print(f" [green][OK][/green] All {len(manifest.file_manifest)} files verified")
84
84
  else:
85
- console.print(f" [red][/red] {len(mismatches)} file(s) failed verification")
85
+ console.print(f" [red][FAIL][/red] {len(mismatches)} file(s) failed verification")
86
86
  for filename, reason in mismatches.items():
87
87
  console.print(f" [red]•[/red] {filename}: {reason}")
88
88
 
@@ -101,19 +101,19 @@ def verify(
101
101
 
102
102
  if verbose:
103
103
  if signature_valid:
104
- console.print(f" [green][/green] {sig_message}")
104
+ console.print(f" [green][OK][/green] {sig_message}")
105
105
  else:
106
- console.print(f" [red][/red] {sig_message}")
106
+ console.print(f" [red][FAIL][/red] {sig_message}")
107
107
 
108
108
  except FileNotFoundError:
109
109
  signature_valid = False
110
110
  if verbose:
111
- console.print(f" [yellow]⚠️[/yellow] Public key not found: {signer_name}")
111
+ console.print(f" [yellow][WARN][/yellow] Public key not found: {signer_name}")
112
112
  console.print(" [dim]Cannot verify signature without public key[/dim]")
113
113
  else:
114
114
  signature_valid = None
115
115
  if verbose:
116
- console.print(" [yellow]⚠️[/yellow] No signature present (unsigned)")
116
+ console.print(" [yellow][WARN][/yellow] No signature present (unsigned)")
117
117
 
118
118
  # ========== CREATE REPORT ==========
119
119
  report = create_verification_report(
@@ -143,7 +143,7 @@ def verify(
143
143
  if verbose:
144
144
  console.print_exception()
145
145
  else:
146
- console.print(f"[red] Verification failed:[/red] {e}")
146
+ console.print(f"[red][FAIL] Verification failed:[/red] {e}")
147
147
  raise typer.Exit(1)
148
148
 
149
149
 
@@ -158,15 +158,15 @@ def print_trust_report(report: dict, epi_file: Path, verbose: bool = False):
158
158
  """
159
159
  # Determine overall status symbol and color
160
160
  if report["trust_level"] == "HIGH":
161
- status_symbol = ""
161
+ status_symbol = "[OK]"
162
162
  status_color = "green"
163
163
  panel_style = "green"
164
164
  elif report["trust_level"] == "MEDIUM":
165
- status_symbol = "⚠️"
165
+ status_symbol = "[WARN]"
166
166
  status_color = "yellow"
167
167
  panel_style = "yellow"
168
168
  else:
169
- status_symbol = ""
169
+ status_symbol = "[FAIL]"
170
170
  status_color = "red"
171
171
  panel_style = "red"
172
172
 
@@ -179,17 +179,17 @@ def print_trust_report(report: dict, epi_file: Path, verbose: bool = False):
179
179
 
180
180
  # Integrity status
181
181
  if report["integrity_ok"]:
182
- content_lines.append(f"[green] Integrity:[/green] Verified ({report['files_checked']} files)")
182
+ content_lines.append(f"[green][OK] Integrity:[/green] Verified ({report['files_checked']} files)")
183
183
  else:
184
- content_lines.append(f"[red] Integrity:[/red] Failed ({report['mismatches_count']} mismatches)")
184
+ content_lines.append(f"[red][FAIL] Integrity:[/red] Failed ({report['mismatches_count']} mismatches)")
185
185
 
186
186
  # Signature status
187
187
  if report["signature_valid"]:
188
- content_lines.append(f"[green] Signature:[/green] Valid (key: {report['signer']})")
188
+ content_lines.append(f"[green][OK] Signature:[/green] Valid (key: {report['signer']})")
189
189
  elif report["signature_valid"] is None:
190
- content_lines.append("[yellow]⚠️ Signature:[/yellow] Not signed")
190
+ content_lines.append("[yellow][WARN] Signature:[/yellow] Not signed")
191
191
  else:
192
- content_lines.append(f"[red] Signature:[/red] Invalid")
192
+ content_lines.append(f"[red][FAIL] Signature:[/red] Invalid")
193
193
 
194
194
  # Show metadata if verbose
195
195
  if verbose:
epi_cli/view.py CHANGED
@@ -17,26 +17,81 @@ console = Console()
17
17
 
18
18
  app = typer.Typer(name="view", help="View .epi file in browser")
19
19
 
20
+ DEFAULT_DIR = Path("epi-recordings")
21
+
22
+
23
+ def _resolve_epi_file(name_or_path: str) -> Path:
24
+ """
25
+ Resolve a name or path to an .epi file.
26
+
27
+ Tries in order:
28
+ 1. Exact path if it exists
29
+ 2. Add .epi extension if missing
30
+ 3. Look in ./epi-recordings/ directory
31
+
32
+ Args:
33
+ name_or_path: User input (name or path)
34
+
35
+ Returns:
36
+ Resolved Path object
37
+
38
+ Raises:
39
+ FileNotFoundError if file cannot be found
40
+ """
41
+ path = Path(name_or_path)
42
+
43
+ # Try exact path
44
+ if path.exists() and path.is_file():
45
+ return path
46
+
47
+ # Try adding .epi extension
48
+ if not str(path).endswith(".epi"):
49
+ with_ext = path.with_suffix(".epi")
50
+ if with_ext.exists():
51
+ return with_ext
52
+
53
+ # Try in default directory
54
+ in_default = DEFAULT_DIR / path.name
55
+ if in_default.exists():
56
+ return in_default
57
+
58
+ # Try in default directory with .epi extension
59
+ in_default_with_ext = DEFAULT_DIR / f"{path.stem}.epi"
60
+ if in_default_with_ext.exists():
61
+ return in_default_with_ext
62
+
63
+ # Not found
64
+ raise FileNotFoundError(f"Recording not found: {name_or_path}")
65
+
20
66
 
21
67
  @app.callback(invoke_without_command=True)
22
68
  def view(
23
69
  ctx: typer.Context,
24
- epi_file: Path = typer.Argument(..., help="Path to .epi file to view"),
70
+ epi_file: str = typer.Argument(..., help="Path or name of .epi file to view"),
25
71
  ):
26
72
  """
27
73
  Open .epi file in browser viewer.
28
74
 
29
- Extracts the embedded viewer.html and opens it in your default browser.
30
- All data is pre-embedded, no server required.
75
+ Accepts file path, name, or base name. Automatically resolves:
76
+ - foo -> ./epi-recordings/foo.epi
77
+ - foo.epi -> ./epi-recordings/foo.epi
78
+ - /path/to/file.epi -> /path/to/file.epi
79
+
80
+ Example:
81
+ epi view my_script_20251121_231501
82
+ epi view my_recording.epi
31
83
  """
32
- # Validate file exists
33
- if not epi_file.exists():
34
- console.print(f"[red]❌ Error:[/red] File not found: {epi_file}")
84
+ # Resolve the file path
85
+ try:
86
+ resolved_path = _resolve_epi_file(epi_file)
87
+ except FileNotFoundError as e:
88
+ console.print(f"[red][FAIL] Error:[/red] {e}")
89
+ console.print(f"[dim]Tip: Run 'epi ls' to see available recordings[/dim]")
35
90
  raise typer.Exit(1)
36
91
 
37
92
  # Validate it's a ZIP file
38
- if not zipfile.is_zipfile(epi_file):
39
- console.print(f"[red] Error:[/red] Not a valid .epi file: {epi_file}")
93
+ if not zipfile.is_zipfile(resolved_path):
94
+ console.print(f"[red][FAIL] Error:[/red] Not a valid .epi file: {resolved_path}")
40
95
  raise typer.Exit(1)
41
96
 
42
97
  try:
@@ -45,9 +100,9 @@ def view(
45
100
  viewer_path = temp_dir / "viewer.html"
46
101
 
47
102
  # Extract viewer.html
48
- with zipfile.ZipFile(epi_file, "r") as zf:
103
+ with zipfile.ZipFile(resolved_path, "r") as zf:
49
104
  if "viewer.html" not in zf.namelist():
50
- console.print("[red] Error:[/red] No viewer found in .epi file")
105
+ console.print("[red][FAIL] Error:[/red] No viewer found in .epi file")
51
106
  console.print("[dim]This file may have been created with an older version of EPI[/dim]")
52
107
  raise typer.Exit(1)
53
108
 
@@ -61,14 +116,14 @@ def view(
61
116
  success = webbrowser.open(file_url)
62
117
 
63
118
  if success:
64
- console.print("[green][/green] Viewer opened in browser")
119
+ console.print("[green][OK][/green] Viewer opened in browser")
65
120
  else:
66
- console.print("[yellow]⚠️ Could not open browser automatically[/yellow]")
121
+ console.print("[yellow][WARN] Could not open browser automatically[/yellow]")
67
122
  console.print(f"[dim]Open manually:[/dim] {file_url}")
68
123
 
69
124
  except KeyboardInterrupt:
70
125
  console.print("\n[yellow]Cancelled[/yellow]")
71
126
  raise typer.Exit(130)
72
127
  except Exception as e:
73
- console.print(f"[red] Error:[/red] {e}")
128
+ console.print(f"[red][FAIL] Error:[/red] {e}")
74
129
  raise typer.Exit(1)
epi_core/redactor.py CHANGED
@@ -78,17 +78,19 @@ class Redactor:
78
78
  from captured workflow data.
79
79
  """
80
80
 
81
- def __init__(self, config_path: Path | None = None, enabled: bool = True):
81
+ def __init__(self, config_path: Path | None = None, enabled: bool = True, allowlist: List[str] = None):
82
82
  """
83
83
  Initialize redactor with optional custom configuration.
84
84
 
85
85
  Args:
86
86
  config_path: Optional path to config.toml with custom patterns
87
87
  enabled: Whether redaction is enabled (default: True)
88
+ allowlist: Optional list of strings to NEVER redact
88
89
  """
89
90
  self.enabled = enabled
90
91
  self.patterns: List[Tuple[re.Pattern, str]] = []
91
92
  self.env_vars_to_redact = REDACT_ENV_VARS.copy()
93
+ self.allowlist = set(allowlist) if allowlist else set()
92
94
 
93
95
  # Compile default patterns
94
96
  for pattern_str, description in DEFAULT_REDACTION_PATTERNS:
@@ -132,6 +134,10 @@ class Redactor:
132
134
  # Load custom env vars
133
135
  if 'redaction' in config and 'env_vars' in config['redaction']:
134
136
  self.env_vars_to_redact.update(config['redaction']['env_vars'])
137
+
138
+ # Load allowlist
139
+ if 'redaction' in config and 'allowlist' in config['redaction']:
140
+ self.allowlist.update(config['redaction']['allowlist'])
135
141
 
136
142
  except Exception as e:
137
143
  print(f"Warning: Could not load config from {config_path}: {e}")
@@ -176,6 +182,10 @@ class Redactor:
176
182
  return redacted_list, redaction_count
177
183
 
178
184
  elif isinstance(data, str):
185
+ # Check allowlist first
186
+ if data in self.allowlist:
187
+ return data, 0
188
+
179
189
  redacted_str = data
180
190
  for pattern, description in self.patterns:
181
191
  matches = pattern.findall(redacted_str)
@@ -239,6 +249,9 @@ enabled = true
239
249
 
240
250
  # Additional environment variable names to redact
241
251
  # env_vars = ["MY_SECRET_VAR", "CUSTOM_TOKEN"]
252
+
253
+ # Allowlist: Strings that should NEVER be redacted (exact match)
254
+ # allowlist = ["sk-not-actually-a-key", "my-public-token"]
242
255
  """
243
256
 
244
257
  config_path.parent.mkdir(parents=True, exist_ok=True)
epi_core/schemas.py CHANGED
@@ -3,7 +3,7 @@ EPI Core Schemas - Pydantic models for manifest and steps.
3
3
  """
4
4
 
5
5
  from datetime import datetime
6
- from typing import Any, Dict, Optional
6
+ from typing import Any, Dict, Optional, List, Union
7
7
  from uuid import UUID, uuid4
8
8
 
9
9
  from pydantic import BaseModel, ConfigDict, Field
@@ -52,6 +52,32 @@ class ManifestModel(BaseModel):
52
52
  description="Ed25519 signature of the canonical CBOR hash of this manifest (excluding signature field)"
53
53
  )
54
54
 
55
+ # New metadata fields for decision tracking
56
+ goal: Optional[str] = Field(
57
+ default=None,
58
+ description="Goal or objective of this workflow execution"
59
+ )
60
+
61
+ notes: Optional[str] = Field(
62
+ default=None,
63
+ description="Additional notes or context about this workflow"
64
+ )
65
+
66
+ metrics: Optional[Dict[str, Union[float, str]]] = Field(
67
+ default=None,
68
+ description="Key-value metrics for this workflow (accuracy, latency, etc.)"
69
+ )
70
+
71
+ approved_by: Optional[str] = Field(
72
+ default=None,
73
+ description="Person or entity who approved this workflow execution"
74
+ )
75
+
76
+ tags: Optional[List[str]] = Field(
77
+ default=None,
78
+ description="Tags for categorizing this workflow"
79
+ )
80
+
55
81
  model_config = ConfigDict(
56
82
  json_schema_extra={
57
83
  "example": {
@@ -65,7 +91,12 @@ class ManifestModel(BaseModel):
65
91
  "env.json": "a3c5f...",
66
92
  "artifacts/output.txt": "c7f8a..."
67
93
  },
68
- "signature": "ed25519:3a4b5c6d..."
94
+ "signature": "ed25519:3a4b5c6d...",
95
+ "goal": "Improve model accuracy",
96
+ "notes": "Switched to GPT-4 for better reasoning",
97
+ "metrics": {"accuracy": 0.92, "latency": 210},
98
+ "approved_by": "alice@company.com",
99
+ "tags": ["prod-candidate", "v1.0"]
69
100
  }
70
101
  }
71
102
  )
@@ -109,4 +140,4 @@ class StepModel(BaseModel):
109
140
  }
110
141
  }
111
142
  }
112
- )
143
+ )
epi_recorder/api.py CHANGED
@@ -5,13 +5,15 @@ Provides a context manager for recording EPI packages programmatically
5
5
  with minimal code changes.
6
6
  """
7
7
 
8
+ import functools
8
9
  import json
10
+ import os
9
11
  import shutil
10
12
  import tempfile
11
13
  import threading
12
14
  from datetime import datetime
13
15
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional
16
+ from typing import Any, Callable, Dict, List, Optional, Union
15
17
 
16
18
  from epi_core.container import EPIContainer
17
19
  from epi_core.schemas import ManifestModel
@@ -45,7 +47,13 @@ class EpiRecorderSession:
45
47
  tags: Optional[List[str]] = None,
46
48
  auto_sign: bool = True,
47
49
  redact: bool = True,
48
- default_key_name: str = "default"
50
+ default_key_name: str = "default",
51
+ # New metadata fields
52
+ goal: Optional[str] = None,
53
+ notes: Optional[str] = None,
54
+ metrics: Optional[Dict[str, Union[float, str]]] = None,
55
+ approved_by: Optional[str] = None,
56
+ metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict with tags parameter
49
57
  ):
50
58
  """
51
59
  Initialize EPI recording session.
@@ -57,6 +65,11 @@ class EpiRecorderSession:
57
65
  auto_sign: Whether to automatically sign on exit (default: True)
58
66
  redact: Whether to redact secrets (default: True)
59
67
  default_key_name: Name of key to use for signing (default: "default")
68
+ goal: Goal or objective of this workflow execution
69
+ notes: Additional notes or context about this workflow
70
+ metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
71
+ approved_by: Person or entity who approved this workflow execution
72
+ metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
60
73
  """
61
74
  self.output_path = Path(output_path)
62
75
  self.workflow_name = workflow_name or "untitled"
@@ -65,6 +78,13 @@ class EpiRecorderSession:
65
78
  self.redact = redact
66
79
  self.default_key_name = default_key_name
67
80
 
81
+ # New metadata fields
82
+ self.goal = goal
83
+ self.notes = notes
84
+ self.metrics = metrics
85
+ self.approved_by = approved_by
86
+ self.metadata_tags = metadata_tags
87
+
68
88
  # Runtime state
69
89
  self.temp_dir: Optional[Path] = None
70
90
  self.recording_context: Optional[RecordingContext] = None
@@ -97,9 +117,9 @@ class EpiRecorderSession:
97
117
  set_recording_context(self.recording_context)
98
118
  _thread_local.active_session = self
99
119
 
100
- # Patch LLM libraries
101
- patch_openai() # Patches OpenAI if available
102
- # TODO: Add more patchers (Anthropic, etc.)
120
+ # Patch LLM libraries and HTTP
121
+ from epi_recorder.patcher import patch_all
122
+ patch_all()
103
123
 
104
124
  # Log session start
105
125
  self.log_step("session.start", {
@@ -134,16 +154,19 @@ class EpiRecorderSession:
134
154
  duration = (end_time - self.start_time).total_seconds()
135
155
 
136
156
  self.log_step("session.end", {
137
- "workflow_name": self.workflow_name,
138
157
  "timestamp": end_time.isoformat(),
139
158
  "duration_seconds": duration,
140
159
  "success": exc_type is None
141
160
  })
142
161
 
143
- # Create manifest
144
- # Note: workflow_name and tags are logged in steps, not manifest
162
+ # Create manifest with metadata
145
163
  manifest = ManifestModel(
146
- created_at=self.start_time
164
+ created_at=self.start_time,
165
+ goal=self.goal,
166
+ notes=self.notes,
167
+ metrics=self.metrics,
168
+ approved_by=self.approved_by,
169
+ tags=self.metadata_tags
147
170
  )
148
171
 
149
172
  # Pack into .epi file
@@ -333,9 +356,10 @@ class EpiRecorderSession:
333
356
  )
334
357
 
335
358
  # Repack the ZIP with signed manifest
336
- self.output_path.unlink() # Remove old file
359
+ # CRITICAL: Write to temp file first to prevent data loss
360
+ temp_output = self.output_path.with_suffix('.epi.tmp')
337
361
 
338
- with zipfile.ZipFile(self.output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
362
+ with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
339
363
  # Write mimetype first (uncompressed)
340
364
  from epi_core.container import EPI_MIMETYPE
341
365
  zf.writestr("mimetype", EPI_MIMETYPE, compress_type=zipfile.ZIP_STORED)
@@ -346,36 +370,209 @@ class EpiRecorderSession:
346
370
  arc_name = str(file_path.relative_to(tmp_path)).replace("\\", "/")
347
371
  zf.write(file_path, arc_name)
348
372
 
373
+ # Successfully created signed file, now safely replace original
374
+ self.output_path.unlink()
375
+ temp_output.rename(self.output_path)
376
+
349
377
  except Exception as e:
350
378
  # Non-fatal: log warning but continue
351
379
  print(f"Warning: Failed to sign .epi file: {e}")
352
380
 
353
381
 
354
- # Convenience function for users
382
+ def _auto_generate_output_path(name_hint: Optional[str] = None) -> Path:
383
+ """
384
+ Auto-generate output path in ./epi-recordings/ directory.
385
+
386
+ Args:
387
+ name_hint: Optional base name hint (script name, function name, etc.)
388
+
389
+ Returns:
390
+ Path object for the .epi file
391
+ """
392
+ # Get recordings directory from env or default
393
+ recordings_dir = Path(os.getenv("EPI_RECORDINGS_DIR", "epi-recordings"))
394
+ recordings_dir.mkdir(parents=True, exist_ok=True)
395
+
396
+ # Generate base name
397
+ if name_hint:
398
+ base = Path(name_hint).stem if "." in name_hint else name_hint
399
+ else:
400
+ base = "recording"
401
+
402
+ # Generate timestamp
403
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
404
+
405
+ # Ensure .epi extension
406
+ filename = f"{base}_{timestamp}.epi"
407
+
408
+ return recordings_dir / filename
409
+
410
+
411
+ def _resolve_output_path(output_path: Optional[Path | str]) -> Path:
412
+ """
413
+ Resolve output path, adding .epi extension and default directory if needed.
414
+
415
+ Args:
416
+ output_path: User-provided path or None for auto-generation
417
+
418
+ Returns:
419
+ Resolved Path object
420
+ """
421
+ if output_path is None:
422
+ return _auto_generate_output_path()
423
+
424
+ path = Path(output_path)
425
+
426
+ # Add .epi extension if missing
427
+ if path.suffix != ".epi":
428
+ path = path.with_suffix(".epi")
429
+
430
+ return path
431
+
432
+
433
+ # Convenience function for users (supports zero-config)
355
434
  def record(
356
- output_path: Path | str,
435
+ output_path: Optional[Path | str] = None,
357
436
  workflow_name: Optional[str] = None,
437
+ tags: Optional[List[str]] = None,
438
+ auto_sign: bool = True,
439
+ redact: bool = True,
440
+ default_key_name: str = "default",
441
+ # New metadata fields
442
+ goal: Optional[str] = None,
443
+ notes: Optional[str] = None,
444
+ metrics: Optional[Dict[str, Union[float, str]]] = None,
445
+ approved_by: Optional[str] = None,
446
+ metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict
358
447
  **kwargs
359
- ) -> EpiRecorderSession:
448
+ ) -> Union[EpiRecorderSession, Callable]:
360
449
  """
361
450
  Create an EPI recording session (context manager).
362
451
 
363
452
  Args:
364
- output_path: Path for output .epi file
453
+ output_path: Path for output .epi file (optional - auto-generates if None)
365
454
  workflow_name: Descriptive name for workflow
366
- **kwargs: Additional arguments (tags, auto_sign, redact, default_key_name)
455
+ tags: Tags for categorization
456
+ auto_sign: Whether to automatically sign on exit (default: True)
457
+ redact: Whether to redact secrets (default: True)
458
+ default_key_name: Name of key to use for signing (default: "default")
459
+ goal: Goal or objective of this workflow execution
460
+ notes: Additional notes or context about this workflow
461
+ metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
462
+ approved_by: Person or entity who approved this workflow execution
463
+ metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
464
+ **kwargs: Additional arguments (backward compatibility)
367
465
 
368
466
  Returns:
369
- EpiRecorderSession context manager
467
+ EpiRecorderSession context manager or decorated function
370
468
 
371
469
  Example:
372
470
  from epi_recorder import record
373
471
 
374
- with record("my_workflow.epi", workflow_name="Demo"):
472
+ # Zero-config (auto-generates filename in ./epi-recordings/)
473
+ with record():
474
+ # Your code here
475
+ pass
476
+
477
+ # With custom name
478
+ with record("my_workflow"):
479
+ # Your code here
480
+ pass
481
+
482
+ # With metadata
483
+ with record(
484
+ goal="reduce hallucinations",
485
+ notes="switched to GPT-4",
486
+ metrics={"accuracy": 0.89},
487
+ approved_by="alice@company.com",
488
+ metadata_tags=["prod-candidate"]
489
+ ):
490
+ # Your code here
491
+ pass
492
+
493
+ # Decorator usage
494
+ @record
495
+ def main():
496
+ # Your code here
497
+ pass
498
+
499
+ # Decorator with metadata
500
+ @record(goal="decorator test", metrics={"test_score": 0.95})
501
+ def main():
375
502
  # Your code here
376
503
  pass
377
504
  """
378
- return EpiRecorderSession(output_path, workflow_name, **kwargs)
505
+ # Check if this is being used as a decorator with arguments
506
+ # If the first argument is not a path but keyword arguments are provided,
507
+ # we need to return a decorator function
508
+ if output_path is None and (goal is not None or notes is not None or metrics is not None or
509
+ approved_by is not None or metadata_tags is not None):
510
+ # This is a decorator with arguments, return a decorator function
511
+ def decorator(func):
512
+ @functools.wraps(func)
513
+ def wrapper(*args, **kwargs):
514
+ # Auto-generate path based on function name
515
+ auto_path = _auto_generate_output_path(func.__name__)
516
+ with EpiRecorderSession(
517
+ auto_path,
518
+ workflow_name or func.__name__,
519
+ tags=tags,
520
+ auto_sign=auto_sign,
521
+ redact=redact,
522
+ default_key_name=default_key_name,
523
+ goal=goal,
524
+ notes=notes,
525
+ metrics=metrics,
526
+ approved_by=approved_by,
527
+ metadata_tags=metadata_tags,
528
+ **kwargs
529
+ ):
530
+ return func(*args, **kwargs)
531
+ return wrapper
532
+ return decorator
533
+
534
+ # Handle decorator usage: record is called without parentheses
535
+ if callable(output_path):
536
+ func = output_path
537
+
538
+ @functools.wraps(func)
539
+ def wrapper(*args, **kwargs):
540
+ # Auto-generate path based on function name
541
+ auto_path = _auto_generate_output_path(func.__name__)
542
+ with EpiRecorderSession(
543
+ auto_path,
544
+ workflow_name or func.__name__,
545
+ tags=tags,
546
+ auto_sign=auto_sign,
547
+ redact=redact,
548
+ default_key_name=default_key_name,
549
+ goal=goal,
550
+ notes=notes,
551
+ metrics=metrics,
552
+ approved_by=approved_by,
553
+ metadata_tags=metadata_tags,
554
+ **kwargs
555
+ ):
556
+ return func(*args, **kwargs)
557
+
558
+ return wrapper
559
+
560
+ # Normal context manager usage
561
+ resolved_path = _resolve_output_path(output_path)
562
+ return EpiRecorderSession(
563
+ resolved_path,
564
+ workflow_name,
565
+ tags=tags,
566
+ auto_sign=auto_sign,
567
+ redact=redact,
568
+ default_key_name=default_key_name,
569
+ goal=goal,
570
+ notes=notes,
571
+ metrics=metrics,
572
+ approved_by=approved_by,
573
+ metadata_tags=metadata_tags,
574
+ **kwargs
575
+ )
379
576
 
380
577
 
381
578
  # Make it easy to get current session
@@ -386,4 +583,4 @@ def get_current_session() -> Optional[EpiRecorderSession]:
386
583
  Returns:
387
584
  EpiRecorderSession or None
388
585
  """
389
- return getattr(_thread_local, 'active_session', None)
586
+ return getattr(_thread_local, 'active_session', None)