htmlgraph 0.26.23__py3-none-any.whl → 0.26.25__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.
Files changed (36) hide show
  1. htmlgraph/__init__.py +1 -1
  2. htmlgraph/analytics/pattern_learning.py +771 -0
  3. htmlgraph/api/main.py +56 -23
  4. htmlgraph/api/templates/dashboard-redesign.html +3 -3
  5. htmlgraph/api/templates/dashboard.html +3 -3
  6. htmlgraph/api/templates/partials/work-items.html +613 -0
  7. htmlgraph/builders/track.py +26 -0
  8. htmlgraph/cli/base.py +31 -7
  9. htmlgraph/cli/work/__init__.py +74 -0
  10. htmlgraph/cli/work/browse.py +114 -0
  11. htmlgraph/cli/work/snapshot.py +558 -0
  12. htmlgraph/collections/base.py +34 -0
  13. htmlgraph/collections/todo.py +12 -0
  14. htmlgraph/converter.py +11 -0
  15. htmlgraph/db/schema.py +34 -1
  16. htmlgraph/hooks/orchestrator.py +88 -14
  17. htmlgraph/hooks/session_handler.py +3 -1
  18. htmlgraph/models.py +22 -2
  19. htmlgraph/orchestration/__init__.py +4 -0
  20. htmlgraph/orchestration/plugin_manager.py +1 -2
  21. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  22. htmlgraph/refs.py +343 -0
  23. htmlgraph/sdk.py +162 -1
  24. htmlgraph/session_manager.py +154 -2
  25. htmlgraph/sessions/__init__.py +23 -0
  26. htmlgraph/sessions/handoff.py +755 -0
  27. htmlgraph/track_builder.py +12 -0
  28. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/METADATA +1 -1
  29. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/RECORD +36 -28
  30. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/dashboard.html +0 -0
  31. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/styles.css +0 -0
  32. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  33. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  34. {htmlgraph-0.26.23.data → htmlgraph-0.26.25.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  35. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/WHEEL +0 -0
  36. {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.25.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,771 @@
1
+ """
2
+ Pattern Learning from Agent Behavior - Phase 2 Feature 2.
3
+
4
+ Analyzes tool call sequences to identify patterns, anti-patterns, and optimization
5
+ opportunities. Provides actionable recommendations based on historical behavior.
6
+
7
+ Key Components:
8
+ 1. PatternMatcher - Identifies sequences of tool types from event history
9
+ 2. InsightGenerator - Converts patterns to actionable recommendations
10
+ 3. LearningLoop - Stores patterns and refines based on user feedback
11
+
12
+ Usage:
13
+ from htmlgraph.analytics.pattern_learning import PatternLearner
14
+
15
+ learner = PatternLearner()
16
+ patterns = learner.detect_patterns(min_frequency=5)
17
+ insights = learner.generate_insights()
18
+ recommendations = learner.get_recommendations()
19
+ """
20
+
21
+ import json
22
+ import sqlite3
23
+ from collections import defaultdict
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+
30
+ @dataclass
31
+ class ToolPattern:
32
+ """
33
+ Represents a detected tool call sequence pattern.
34
+
35
+ Attributes:
36
+ pattern_id: Unique identifier for the pattern
37
+ sequence: List of tool names in order (e.g., ["Read", "Grep", "Edit"])
38
+ frequency: Number of times this pattern occurs
39
+ success_rate: Percentage of times pattern led to successful outcomes
40
+ avg_duration_seconds: Average time taken for this pattern
41
+ last_seen: When this pattern was last observed
42
+ sessions: List of session IDs where pattern occurred
43
+ user_feedback: User feedback score (1=helpful, 0=neutral, -1=unhelpful)
44
+ """
45
+
46
+ pattern_id: str
47
+ sequence: list[str]
48
+ frequency: int = 0
49
+ success_rate: float = 0.0
50
+ avg_duration_seconds: float = 0.0
51
+ last_seen: datetime | None = None
52
+ sessions: list[str] = field(default_factory=list)
53
+ user_feedback: int = 0
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ """Convert pattern to dictionary."""
57
+ return {
58
+ "pattern_id": self.pattern_id,
59
+ "sequence": self.sequence,
60
+ "frequency": self.frequency,
61
+ "success_rate": self.success_rate,
62
+ "avg_duration_seconds": self.avg_duration_seconds,
63
+ "last_seen": self.last_seen.isoformat() if self.last_seen else None,
64
+ "sessions": self.sessions,
65
+ "user_feedback": self.user_feedback,
66
+ }
67
+
68
+
69
+ @dataclass
70
+ class PatternInsight:
71
+ """
72
+ Actionable insight generated from pattern analysis.
73
+
74
+ Attributes:
75
+ insight_id: Unique identifier
76
+ insight_type: Type of insight (recommendation, anti-pattern, optimization)
77
+ title: Human-readable title
78
+ description: Detailed explanation
79
+ impact_score: Estimated impact (0-100)
80
+ patterns: Related pattern IDs
81
+ """
82
+
83
+ insight_id: str
84
+ insight_type: str # "recommendation", "anti-pattern", "optimization"
85
+ title: str
86
+ description: str
87
+ impact_score: float
88
+ patterns: list[str] = field(default_factory=list)
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Convert insight to dictionary."""
92
+ return {
93
+ "insight_id": self.insight_id,
94
+ "insight_type": self.insight_type,
95
+ "title": self.title,
96
+ "description": self.description,
97
+ "impact_score": self.impact_score,
98
+ "patterns": self.patterns,
99
+ }
100
+
101
+
102
+ class PatternMatcher:
103
+ """
104
+ Identifies sequences of tool types from event history.
105
+
106
+ Uses sliding window approach to find common tool call patterns.
107
+ """
108
+
109
+ def __init__(self, db_path: Path | str):
110
+ """
111
+ Initialize pattern matcher.
112
+
113
+ Args:
114
+ db_path: Path to HtmlGraph database
115
+ """
116
+ self.db_path = Path(db_path)
117
+
118
+ if not self.db_path.exists():
119
+ raise FileNotFoundError(f"Database not found at {self.db_path}")
120
+
121
+ def _get_connection(self) -> sqlite3.Connection:
122
+ """Get database connection."""
123
+ conn = sqlite3.connect(str(self.db_path))
124
+ conn.row_factory = sqlite3.Row
125
+ return conn
126
+
127
+ def get_tool_sequences(
128
+ self, window_size: int = 3, session_id: str | None = None
129
+ ) -> list[tuple[list[str], str, datetime]]:
130
+ """
131
+ Extract tool call sequences from database.
132
+
133
+ Args:
134
+ window_size: Number of consecutive tools in each sequence
135
+ session_id: Optional session ID to filter by
136
+
137
+ Returns:
138
+ List of (sequence, session_id, timestamp) tuples
139
+ """
140
+ conn = self._get_connection()
141
+ try:
142
+ # Query tool calls ordered by timestamp
143
+ query = """
144
+ SELECT tool_name, session_id, timestamp
145
+ FROM agent_events
146
+ WHERE event_type = 'tool_call'
147
+ AND tool_name IS NOT NULL
148
+ """
149
+
150
+ params: tuple[Any, ...] = ()
151
+ if session_id:
152
+ query += " AND session_id = ?"
153
+ params = (session_id,)
154
+
155
+ query += " ORDER BY timestamp ASC"
156
+
157
+ cursor = conn.cursor()
158
+ cursor.execute(query, params)
159
+
160
+ # Group by session and extract sequences
161
+ session_tools: dict[str, list[tuple[str, datetime]]] = defaultdict(list)
162
+ for row in cursor.fetchall():
163
+ tool = row["tool_name"]
164
+ sess_id = row["session_id"]
165
+ timestamp = (
166
+ datetime.fromisoformat(row["timestamp"])
167
+ if isinstance(row["timestamp"], str)
168
+ else row["timestamp"]
169
+ )
170
+ session_tools[sess_id].append((tool, timestamp))
171
+
172
+ # Extract sliding windows
173
+ sequences = []
174
+ for sess_id, tools in session_tools.items():
175
+ for i in range(len(tools) - window_size + 1):
176
+ sequence = [t[0] for t in tools[i : i + window_size]]
177
+ timestamp = tools[i + window_size - 1][
178
+ 1
179
+ ] # Use last tool's timestamp
180
+ sequences.append((sequence, sess_id, timestamp))
181
+
182
+ return sequences
183
+ finally:
184
+ conn.close()
185
+
186
+ def find_patterns(
187
+ self, window_size: int = 3, min_frequency: int = 5
188
+ ) -> list[ToolPattern]:
189
+ """
190
+ Identify common tool call patterns.
191
+
192
+ Args:
193
+ window_size: Size of tool sequence window (default: 3)
194
+ min_frequency: Minimum occurrences to be considered a pattern
195
+
196
+ Returns:
197
+ List of detected patterns sorted by frequency
198
+ """
199
+ sequences = self.get_tool_sequences(window_size=window_size)
200
+
201
+ # Count sequence frequencies
202
+ sequence_data: dict[tuple[str, ...], list[tuple[str, datetime]]] = defaultdict(
203
+ list
204
+ )
205
+ for seq, sess_id, timestamp in sequences:
206
+ sequence_data[tuple(seq)].append((sess_id, timestamp))
207
+
208
+ # Filter by minimum frequency
209
+ patterns = []
210
+ for seq_tuple, occurrences in sequence_data.items():
211
+ if len(occurrences) >= min_frequency:
212
+ sequence = list(seq_tuple)
213
+ pattern_id = self._generate_pattern_id(sequence)
214
+
215
+ sessions = [occ[0] for occ in occurrences]
216
+ last_seen = max(occ[1] for occ in occurrences)
217
+
218
+ pattern = ToolPattern(
219
+ pattern_id=pattern_id,
220
+ sequence=sequence,
221
+ frequency=len(occurrences),
222
+ last_seen=last_seen,
223
+ sessions=sessions,
224
+ )
225
+ patterns.append(pattern)
226
+
227
+ # Sort by frequency descending
228
+ patterns.sort(key=lambda p: p.frequency, reverse=True)
229
+ return patterns
230
+
231
+ def _generate_pattern_id(self, sequence: list[str]) -> str:
232
+ """Generate unique pattern ID from sequence."""
233
+ seq_str = "->".join(sequence)
234
+ # Simple hash-based ID
235
+ import hashlib
236
+
237
+ hash_obj = hashlib.md5(seq_str.encode())
238
+ return f"pat-{hash_obj.hexdigest()[:8]}"
239
+
240
+
241
+ class InsightGenerator:
242
+ """
243
+ Converts patterns to actionable recommendations.
244
+
245
+ Analyzes pattern success rates, costs, and contexts to generate
246
+ insights about workflow optimization.
247
+ """
248
+
249
+ def __init__(self, db_path: Path | str):
250
+ """
251
+ Initialize insight generator.
252
+
253
+ Args:
254
+ db_path: Path to HtmlGraph database
255
+ """
256
+ self.db_path = Path(db_path)
257
+
258
+ if not self.db_path.exists():
259
+ raise FileNotFoundError(f"Database not found at {self.db_path}")
260
+
261
+ def _get_connection(self) -> sqlite3.Connection:
262
+ """Get database connection."""
263
+ conn = sqlite3.connect(str(self.db_path))
264
+ conn.row_factory = sqlite3.Row
265
+ return conn
266
+
267
+ def calculate_success_rate(self, pattern: ToolPattern) -> float:
268
+ """
269
+ Calculate success rate for a pattern.
270
+
271
+ Success defined as: pattern followed by passing tests or completion,
272
+ not followed by error/failure events.
273
+
274
+ Args:
275
+ pattern: Pattern to analyze
276
+
277
+ Returns:
278
+ Success rate as percentage (0-100)
279
+ """
280
+ if not pattern.sessions:
281
+ return 0.0
282
+
283
+ conn = self._get_connection()
284
+ try:
285
+ successes = 0
286
+ for session_id in set(pattern.sessions):
287
+ # Check if session has more successes than failures
288
+ cursor = conn.cursor()
289
+ cursor.execute(
290
+ """
291
+ SELECT
292
+ SUM(CASE WHEN event_type = 'completion' THEN 1 ELSE 0 END) as completions,
293
+ SUM(CASE WHEN event_type = 'error' THEN 1 ELSE 0 END) as errors
294
+ FROM agent_events
295
+ WHERE session_id = ?
296
+ """,
297
+ (session_id,),
298
+ )
299
+ row = cursor.fetchone()
300
+
301
+ completions = row["completions"] or 0
302
+ errors = row["errors"] or 0
303
+
304
+ # Session is successful if completions > errors
305
+ if completions > errors:
306
+ successes += 1
307
+
308
+ return (successes / len(set(pattern.sessions))) * 100
309
+ finally:
310
+ conn.close()
311
+
312
+ def calculate_avg_duration(self, pattern: ToolPattern) -> float:
313
+ """
314
+ Calculate average duration for pattern execution.
315
+
316
+ Args:
317
+ pattern: Pattern to analyze
318
+
319
+ Returns:
320
+ Average duration in seconds
321
+ """
322
+ if not pattern.sessions:
323
+ return 0.0
324
+
325
+ conn = self._get_connection()
326
+ try:
327
+ cursor = conn.cursor()
328
+ cursor.execute(
329
+ """
330
+ SELECT AVG(execution_duration_seconds) as avg_duration
331
+ FROM agent_events
332
+ WHERE session_id IN ({})
333
+ AND execution_duration_seconds > 0
334
+ """.format(",".join("?" * len(set(pattern.sessions)))),
335
+ tuple(set(pattern.sessions)),
336
+ )
337
+ row = cursor.fetchone()
338
+ return row["avg_duration"] or 0.0
339
+ finally:
340
+ conn.close()
341
+
342
+ def enrich_pattern(self, pattern: ToolPattern) -> ToolPattern:
343
+ """
344
+ Enrich pattern with success rate and duration data.
345
+
346
+ Args:
347
+ pattern: Pattern to enrich
348
+
349
+ Returns:
350
+ Enriched pattern with calculated metrics
351
+ """
352
+ pattern.success_rate = self.calculate_success_rate(pattern)
353
+ pattern.avg_duration_seconds = self.calculate_avg_duration(pattern)
354
+ return pattern
355
+
356
+ def generate_insights(self, patterns: list[ToolPattern]) -> list[PatternInsight]:
357
+ """
358
+ Generate actionable insights from patterns.
359
+
360
+ Args:
361
+ patterns: List of detected patterns
362
+
363
+ Returns:
364
+ List of insights with recommendations
365
+ """
366
+ insights = []
367
+
368
+ # Enrich patterns with metrics
369
+ enriched = [self.enrich_pattern(p) for p in patterns]
370
+
371
+ # Find high-success patterns (recommendations)
372
+ for pattern in enriched:
373
+ if pattern.success_rate >= 80 and pattern.frequency >= 5:
374
+ insight = PatternInsight(
375
+ insight_id=f"insight-{pattern.pattern_id}",
376
+ insight_type="recommendation",
377
+ title=f"High Success Pattern: {' → '.join(pattern.sequence)}",
378
+ description=(
379
+ f"This pattern has a {pattern.success_rate:.1f}% success rate "
380
+ f"across {pattern.frequency} occurrences. "
381
+ f"Consider using this workflow for similar tasks."
382
+ ),
383
+ impact_score=pattern.success_rate * (pattern.frequency / 10),
384
+ patterns=[pattern.pattern_id],
385
+ )
386
+ insights.append(insight)
387
+
388
+ # Find low-success patterns (anti-patterns)
389
+ for pattern in enriched:
390
+ if pattern.success_rate < 50 and pattern.frequency >= 5:
391
+ insight = PatternInsight(
392
+ insight_id=f"anti-{pattern.pattern_id}",
393
+ insight_type="anti-pattern",
394
+ title=f"Low Success Pattern: {' → '.join(pattern.sequence)}",
395
+ description=(
396
+ f"This pattern has only a {pattern.success_rate:.1f}% success rate "
397
+ f"across {pattern.frequency} occurrences. "
398
+ f"Consider alternative approaches."
399
+ ),
400
+ impact_score=100 - pattern.success_rate,
401
+ patterns=[pattern.pattern_id],
402
+ )
403
+ insights.append(insight)
404
+
405
+ # Find repeated Read patterns (optimization opportunity)
406
+ for pattern in enriched:
407
+ if pattern.sequence.count("Read") >= 2:
408
+ insight = PatternInsight(
409
+ insight_id=f"opt-{pattern.pattern_id}",
410
+ insight_type="optimization",
411
+ title="Multiple Read Operations Detected",
412
+ description=(
413
+ f"Pattern '{' → '.join(pattern.sequence)}' contains "
414
+ f"{pattern.sequence.count('Read')} Read operations. "
415
+ f"Consider delegating exploration to a subagent to reduce context usage."
416
+ ),
417
+ impact_score=pattern.frequency * 10,
418
+ patterns=[pattern.pattern_id],
419
+ )
420
+ insights.append(insight)
421
+
422
+ # Sort by impact score
423
+ insights.sort(key=lambda i: i.impact_score, reverse=True)
424
+ return insights
425
+
426
+
427
+ class LearningLoop:
428
+ """
429
+ Stores patterns and refines recommendations based on user feedback.
430
+
431
+ Maintains a persistent store of patterns and their effectiveness,
432
+ allowing the system to improve recommendations over time.
433
+ """
434
+
435
+ def __init__(self, db_path: Path | str):
436
+ """
437
+ Initialize learning loop.
438
+
439
+ Args:
440
+ db_path: Path to HtmlGraph database
441
+ """
442
+ self.db_path = Path(db_path)
443
+
444
+ if not self.db_path.exists():
445
+ raise FileNotFoundError(f"Database not found at {self.db_path}")
446
+
447
+ self._ensure_schema()
448
+
449
+ def _get_connection(self) -> sqlite3.Connection:
450
+ """Get database connection."""
451
+ conn = sqlite3.connect(str(self.db_path))
452
+ conn.row_factory = sqlite3.Row
453
+ return conn
454
+
455
+ def _ensure_schema(self) -> None:
456
+ """Create tool_patterns table if it doesn't exist."""
457
+ conn = self._get_connection()
458
+ try:
459
+ cursor = conn.cursor()
460
+ cursor.execute("""
461
+ CREATE TABLE IF NOT EXISTS tool_patterns (
462
+ pattern_id TEXT PRIMARY KEY,
463
+ tool_sequence TEXT NOT NULL,
464
+ frequency INTEGER DEFAULT 0,
465
+ success_rate REAL DEFAULT 0.0,
466
+ avg_duration_seconds REAL DEFAULT 0.0,
467
+ last_seen TIMESTAMP,
468
+ sessions TEXT,
469
+ user_feedback INTEGER DEFAULT 0,
470
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
471
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
472
+ )
473
+ """)
474
+ conn.commit()
475
+ finally:
476
+ conn.close()
477
+
478
+ def store_pattern(self, pattern: ToolPattern) -> None:
479
+ """
480
+ Store or update a pattern in the database.
481
+
482
+ Args:
483
+ pattern: Pattern to store
484
+ """
485
+ conn = self._get_connection()
486
+ try:
487
+ cursor = conn.cursor()
488
+ cursor.execute(
489
+ """
490
+ INSERT OR REPLACE INTO tool_patterns (
491
+ pattern_id,
492
+ tool_sequence,
493
+ frequency,
494
+ success_rate,
495
+ avg_duration_seconds,
496
+ last_seen,
497
+ sessions,
498
+ user_feedback,
499
+ updated_at
500
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
501
+ """,
502
+ (
503
+ pattern.pattern_id,
504
+ "->".join(pattern.sequence),
505
+ pattern.frequency,
506
+ pattern.success_rate,
507
+ pattern.avg_duration_seconds,
508
+ pattern.last_seen.isoformat() if pattern.last_seen else None,
509
+ json.dumps(pattern.sessions),
510
+ pattern.user_feedback,
511
+ datetime.now().isoformat(),
512
+ ),
513
+ )
514
+ conn.commit()
515
+ finally:
516
+ conn.close()
517
+
518
+ def get_pattern(self, pattern_id: str) -> ToolPattern | None:
519
+ """
520
+ Retrieve a pattern by ID.
521
+
522
+ Args:
523
+ pattern_id: Pattern ID to retrieve
524
+
525
+ Returns:
526
+ Pattern or None if not found
527
+ """
528
+ conn = self._get_connection()
529
+ try:
530
+ cursor = conn.cursor()
531
+ cursor.execute(
532
+ "SELECT * FROM tool_patterns WHERE pattern_id = ?", (pattern_id,)
533
+ )
534
+ row = cursor.fetchone()
535
+
536
+ if not row:
537
+ return None
538
+
539
+ return ToolPattern(
540
+ pattern_id=row["pattern_id"],
541
+ sequence=row["tool_sequence"].split("->"),
542
+ frequency=row["frequency"],
543
+ success_rate=row["success_rate"],
544
+ avg_duration_seconds=row["avg_duration_seconds"],
545
+ last_seen=datetime.fromisoformat(row["last_seen"])
546
+ if row["last_seen"]
547
+ else None,
548
+ sessions=json.loads(row["sessions"]) if row["sessions"] else [],
549
+ user_feedback=row["user_feedback"],
550
+ )
551
+ finally:
552
+ conn.close()
553
+
554
+ def update_feedback(self, pattern_id: str, feedback: int) -> None:
555
+ """
556
+ Update user feedback for a pattern.
557
+
558
+ Args:
559
+ pattern_id: Pattern ID
560
+ feedback: Feedback score (1=helpful, 0=neutral, -1=unhelpful)
561
+ """
562
+ conn = self._get_connection()
563
+ try:
564
+ cursor = conn.cursor()
565
+ cursor.execute(
566
+ """
567
+ UPDATE tool_patterns
568
+ SET user_feedback = ?, updated_at = ?
569
+ WHERE pattern_id = ?
570
+ """,
571
+ (feedback, datetime.now().isoformat(), pattern_id),
572
+ )
573
+ conn.commit()
574
+ finally:
575
+ conn.close()
576
+
577
+ def get_all_patterns(self) -> list[ToolPattern]:
578
+ """
579
+ Get all stored patterns.
580
+
581
+ Returns:
582
+ List of all patterns
583
+ """
584
+ conn = self._get_connection()
585
+ try:
586
+ cursor = conn.cursor()
587
+ cursor.execute("SELECT * FROM tool_patterns ORDER BY frequency DESC")
588
+
589
+ patterns = []
590
+ for row in cursor.fetchall():
591
+ pattern = ToolPattern(
592
+ pattern_id=row["pattern_id"],
593
+ sequence=row["tool_sequence"].split("->"),
594
+ frequency=row["frequency"],
595
+ success_rate=row["success_rate"],
596
+ avg_duration_seconds=row["avg_duration_seconds"],
597
+ last_seen=datetime.fromisoformat(row["last_seen"])
598
+ if row["last_seen"]
599
+ else None,
600
+ sessions=json.loads(row["sessions"]) if row["sessions"] else [],
601
+ user_feedback=row["user_feedback"],
602
+ )
603
+ patterns.append(pattern)
604
+
605
+ return patterns
606
+ finally:
607
+ conn.close()
608
+
609
+
610
+ class PatternLearner:
611
+ """
612
+ Main interface for pattern learning.
613
+
614
+ Combines pattern detection, insight generation, and learning loop
615
+ into a single API for AI agents.
616
+
617
+ Example:
618
+ >>> learner = PatternLearner()
619
+ >>> patterns = learner.detect_patterns(min_frequency=5)
620
+ >>> insights = learner.generate_insights()
621
+ >>> recommendations = learner.get_recommendations()
622
+ """
623
+
624
+ def __init__(self, graph_dir: Path | None = None):
625
+ """
626
+ Initialize pattern learner.
627
+
628
+ Args:
629
+ graph_dir: Root directory for HtmlGraph (defaults to .htmlgraph)
630
+ """
631
+ if graph_dir is None:
632
+ graph_dir = Path.cwd() / ".htmlgraph"
633
+
634
+ self.graph_dir = Path(graph_dir)
635
+ self.db_path = self.graph_dir / "htmlgraph.db"
636
+
637
+ # Lazy initialization - only initialize components if database exists
638
+ # This prevents failures in tests with temporary directories
639
+ self._matcher: PatternMatcher | None = None
640
+ self._insight_generator: InsightGenerator | None = None
641
+ self._learning_loop: LearningLoop | None = None
642
+
643
+ @property
644
+ def matcher(self) -> PatternMatcher:
645
+ """Lazily initialize matcher."""
646
+ if self._matcher is None:
647
+ if not self.db_path.exists():
648
+ raise FileNotFoundError(f"Database not found at {self.db_path}")
649
+ self._matcher = PatternMatcher(self.db_path)
650
+ return self._matcher
651
+
652
+ @property
653
+ def insight_generator(self) -> InsightGenerator:
654
+ """Lazily initialize insight generator."""
655
+ if self._insight_generator is None:
656
+ if not self.db_path.exists():
657
+ raise FileNotFoundError(f"Database not found at {self.db_path}")
658
+ self._insight_generator = InsightGenerator(self.db_path)
659
+ return self._insight_generator
660
+
661
+ @property
662
+ def learning_loop(self) -> LearningLoop:
663
+ """Lazily initialize learning loop."""
664
+ if self._learning_loop is None:
665
+ if not self.db_path.exists():
666
+ raise FileNotFoundError(f"Database not found at {self.db_path}")
667
+ self._learning_loop = LearningLoop(self.db_path)
668
+ return self._learning_loop
669
+
670
+ def detect_patterns(
671
+ self, window_size: int = 3, min_frequency: int = 5
672
+ ) -> list[ToolPattern]:
673
+ """
674
+ Detect tool call patterns from event history.
675
+
676
+ Args:
677
+ window_size: Size of tool sequence window (default: 3)
678
+ min_frequency: Minimum occurrences to be considered a pattern
679
+
680
+ Returns:
681
+ List of detected patterns
682
+ """
683
+ patterns = self.matcher.find_patterns(
684
+ window_size=window_size, min_frequency=min_frequency
685
+ )
686
+
687
+ # Store patterns in learning loop
688
+ for pattern in patterns:
689
+ enriched = self.insight_generator.enrich_pattern(pattern)
690
+ self.learning_loop.store_pattern(enriched)
691
+
692
+ return patterns
693
+
694
+ def generate_insights(self) -> list[PatternInsight]:
695
+ """
696
+ Generate insights from detected patterns.
697
+
698
+ Returns:
699
+ List of actionable insights
700
+ """
701
+ patterns = self.learning_loop.get_all_patterns()
702
+ return self.insight_generator.generate_insights(patterns)
703
+
704
+ def get_recommendations(self, limit: int = 3) -> list[PatternInsight]:
705
+ """
706
+ Get top recommendations based on impact.
707
+
708
+ Args:
709
+ limit: Maximum number of recommendations (default: 3)
710
+
711
+ Returns:
712
+ Top recommendations sorted by impact
713
+ """
714
+ insights = self.generate_insights()
715
+ recommendations = [i for i in insights if i.insight_type == "recommendation"]
716
+ return recommendations[:limit]
717
+
718
+ def get_anti_patterns(self) -> list[PatternInsight]:
719
+ """
720
+ Get detected anti-patterns.
721
+
722
+ Returns:
723
+ List of anti-pattern insights
724
+ """
725
+ insights = self.generate_insights()
726
+ return [i for i in insights if i.insight_type == "anti-pattern"]
727
+
728
+ def export_learnings(self, output_path: Path | str) -> None:
729
+ """
730
+ Export learnings to markdown for team sharing.
731
+
732
+ Args:
733
+ output_path: Path to output markdown file
734
+ """
735
+ insights = self.generate_insights()
736
+ patterns = self.learning_loop.get_all_patterns()
737
+
738
+ output_path = Path(output_path)
739
+
740
+ with open(output_path, "w") as f:
741
+ f.write("# Pattern Learning Report\n\n")
742
+ f.write(f"Generated: {datetime.now().isoformat()}\n\n")
743
+
744
+ f.write("## Recommendations\n\n")
745
+ recommendations = [
746
+ i for i in insights if i.insight_type == "recommendation"
747
+ ]
748
+ for insight in recommendations[:5]:
749
+ f.write(f"### {insight.title}\n\n")
750
+ f.write(f"{insight.description}\n\n")
751
+ f.write(f"**Impact Score**: {insight.impact_score:.1f}\n\n")
752
+
753
+ f.write("## Anti-Patterns\n\n")
754
+ anti_patterns = [i for i in insights if i.insight_type == "anti-pattern"]
755
+ for insight in anti_patterns[:5]:
756
+ f.write(f"### {insight.title}\n\n")
757
+ f.write(f"{insight.description}\n\n")
758
+ f.write(f"**Impact Score**: {insight.impact_score:.1f}\n\n")
759
+
760
+ f.write("## Optimization Opportunities\n\n")
761
+ optimizations = [i for i in insights if i.insight_type == "optimization"]
762
+ for insight in optimizations[:5]:
763
+ f.write(f"### {insight.title}\n\n")
764
+ f.write(f"{insight.description}\n\n")
765
+ f.write(f"**Impact Score**: {insight.impact_score:.1f}\n\n")
766
+
767
+ f.write("## All Detected Patterns\n\n")
768
+ for pattern in patterns[:20]:
769
+ f.write(f"- **{' → '.join(pattern.sequence)}** ")
770
+ f.write(f"(frequency: {pattern.frequency}, ")
771
+ f.write(f"success: {pattern.success_rate:.1f}%)\n")