htmlgraph 0.26.23__py3-none-any.whl → 0.26.24__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.
- htmlgraph/__init__.py +1 -1
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/db/schema.py +34 -1
- htmlgraph/models.py +4 -1
- htmlgraph/sdk.py +91 -0
- htmlgraph/session_manager.py +160 -2
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +749 -0
- {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.24.dist-info}/METADATA +1 -1
- {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.24.dist-info}/RECORD +17 -14
- {htmlgraph-0.26.23.data → htmlgraph-0.26.24.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.26.23.data → htmlgraph-0.26.24.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.26.23.data → htmlgraph-0.26.24.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.26.23.data → htmlgraph-0.26.24.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.26.23.data → htmlgraph-0.26.24.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.24.dist-info}/WHEEL +0 -0
- {htmlgraph-0.26.23.dist-info → htmlgraph-0.26.24.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")
|