claude-mpm 4.1.4__py3-none-any.whl → 4.1.6__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 (81) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/agents/templates/research.json +39 -13
  3. claude_mpm/cli/__init__.py +2 -0
  4. claude_mpm/cli/commands/__init__.py +2 -0
  5. claude_mpm/cli/commands/configure.py +1221 -0
  6. claude_mpm/cli/commands/configure_tui.py +1921 -0
  7. claude_mpm/cli/commands/tickets.py +365 -784
  8. claude_mpm/cli/parsers/base_parser.py +7 -0
  9. claude_mpm/cli/parsers/configure_parser.py +119 -0
  10. claude_mpm/cli/startup_logging.py +39 -12
  11. claude_mpm/constants.py +1 -0
  12. claude_mpm/core/output_style_manager.py +24 -0
  13. claude_mpm/core/socketio_pool.py +35 -3
  14. claude_mpm/core/unified_agent_registry.py +46 -15
  15. claude_mpm/dashboard/static/css/connection-status.css +370 -0
  16. claude_mpm/dashboard/static/js/components/connection-debug.js +654 -0
  17. claude_mpm/dashboard/static/js/connection-manager.js +536 -0
  18. claude_mpm/dashboard/templates/index.html +11 -0
  19. claude_mpm/hooks/claude_hooks/services/__init__.py +3 -1
  20. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +190 -0
  21. claude_mpm/services/agents/deployment/agent_discovery_service.py +12 -3
  22. claude_mpm/services/agents/deployment/agent_lifecycle_manager.py +172 -233
  23. claude_mpm/services/agents/deployment/agent_lifecycle_manager_refactored.py +575 -0
  24. claude_mpm/services/agents/deployment/agent_operation_service.py +573 -0
  25. claude_mpm/services/agents/deployment/agent_record_service.py +419 -0
  26. claude_mpm/services/agents/deployment/agent_state_service.py +381 -0
  27. claude_mpm/services/agents/deployment/multi_source_deployment_service.py +4 -2
  28. claude_mpm/services/diagnostics/checks/__init__.py +2 -0
  29. claude_mpm/services/diagnostics/checks/instructions_check.py +418 -0
  30. claude_mpm/services/diagnostics/diagnostic_runner.py +15 -2
  31. claude_mpm/services/event_bus/direct_relay.py +173 -0
  32. claude_mpm/services/infrastructure/__init__.py +31 -5
  33. claude_mpm/services/infrastructure/monitoring/__init__.py +43 -0
  34. claude_mpm/services/infrastructure/monitoring/aggregator.py +437 -0
  35. claude_mpm/services/infrastructure/monitoring/base.py +130 -0
  36. claude_mpm/services/infrastructure/monitoring/legacy.py +203 -0
  37. claude_mpm/services/infrastructure/monitoring/network.py +218 -0
  38. claude_mpm/services/infrastructure/monitoring/process.py +342 -0
  39. claude_mpm/services/infrastructure/monitoring/resources.py +243 -0
  40. claude_mpm/services/infrastructure/monitoring/service.py +367 -0
  41. claude_mpm/services/infrastructure/monitoring.py +67 -1030
  42. claude_mpm/services/project/analyzer.py +13 -4
  43. claude_mpm/services/project/analyzer_refactored.py +450 -0
  44. claude_mpm/services/project/analyzer_v2.py +566 -0
  45. claude_mpm/services/project/architecture_analyzer.py +461 -0
  46. claude_mpm/services/project/dependency_analyzer.py +462 -0
  47. claude_mpm/services/project/language_analyzer.py +265 -0
  48. claude_mpm/services/project/metrics_collector.py +410 -0
  49. claude_mpm/services/socketio/handlers/connection_handler.py +345 -0
  50. claude_mpm/services/socketio/server/broadcaster.py +32 -1
  51. claude_mpm/services/socketio/server/connection_manager.py +516 -0
  52. claude_mpm/services/socketio/server/core.py +63 -0
  53. claude_mpm/services/socketio/server/eventbus_integration.py +20 -9
  54. claude_mpm/services/socketio/server/main.py +27 -1
  55. claude_mpm/services/ticket_manager.py +5 -1
  56. claude_mpm/services/ticket_services/__init__.py +26 -0
  57. claude_mpm/services/ticket_services/crud_service.py +328 -0
  58. claude_mpm/services/ticket_services/formatter_service.py +290 -0
  59. claude_mpm/services/ticket_services/search_service.py +324 -0
  60. claude_mpm/services/ticket_services/validation_service.py +303 -0
  61. claude_mpm/services/ticket_services/workflow_service.py +244 -0
  62. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.6.dist-info}/METADATA +3 -1
  63. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.6.dist-info}/RECORD +67 -46
  64. claude_mpm/agents/OUTPUT_STYLE.md +0 -73
  65. claude_mpm/agents/backups/INSTRUCTIONS.md +0 -352
  66. claude_mpm/agents/templates/OPTIMIZATION_REPORT.md +0 -156
  67. claude_mpm/agents/templates/backup/data_engineer_agent_20250726_234551.json +0 -79
  68. claude_mpm/agents/templates/backup/documentation_agent_20250726_234551.json +0 -68
  69. claude_mpm/agents/templates/backup/engineer_agent_20250726_234551.json +0 -77
  70. claude_mpm/agents/templates/backup/ops_agent_20250726_234551.json +0 -78
  71. claude_mpm/agents/templates/backup/qa_agent_20250726_234551.json +0 -67
  72. claude_mpm/agents/templates/backup/research_agent_2025011_234551.json +0 -88
  73. claude_mpm/agents/templates/backup/research_agent_20250726_234551.json +0 -72
  74. claude_mpm/agents/templates/backup/research_memory_efficient.json +0 -88
  75. claude_mpm/agents/templates/backup/security_agent_20250726_234551.json +0 -78
  76. claude_mpm/agents/templates/backup/version_control_agent_20250726_234551.json +0 -62
  77. claude_mpm/agents/templates/vercel_ops_instructions.md +0 -582
  78. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.6.dist-info}/WHEEL +0 -0
  79. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.6.dist-info}/entry_points.txt +0 -0
  80. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.6.dist-info}/licenses/LICENSE +0 -0
  81. {claude_mpm-4.1.4.dist-info → claude_mpm-4.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,290 @@
1
+ """
2
+ Output formatting service for tickets.
3
+
4
+ WHY: Separates presentation logic from business logic, allowing for
5
+ consistent formatting across different output modes and contexts.
6
+
7
+ DESIGN DECISIONS:
8
+ - Supports multiple output formats (text, json, yaml, table)
9
+ - Uses emoji for visual status indicators
10
+ - Handles pagination display
11
+ - Provides consistent formatting patterns
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+
17
+ class TicketFormatterService:
18
+ """Service for formatting ticket output."""
19
+
20
+ # Status emoji mapping for visual indicators
21
+ STATUS_EMOJI = {
22
+ "open": "🔵",
23
+ "in_progress": "🟡",
24
+ "done": "🟢",
25
+ "closed": "⚫",
26
+ "blocked": "🔴",
27
+ "ready": "🟣",
28
+ "tested": "🟢",
29
+ "waiting": "🟠",
30
+ }
31
+
32
+ DEFAULT_EMOJI = "⚪"
33
+
34
+ def format_ticket_created(
35
+ self, ticket_id: str, verbose: bool = False, **metadata
36
+ ) -> List[str]:
37
+ """
38
+ Format output for created ticket.
39
+
40
+ Returns:
41
+ List of formatted output lines
42
+ """
43
+ lines = [f"✅ Created ticket: {ticket_id}"]
44
+
45
+ if verbose:
46
+ if metadata.get("type"):
47
+ lines.append(f" Type: {metadata['type']}")
48
+ if metadata.get("priority"):
49
+ lines.append(f" Priority: {metadata['priority']}")
50
+ if metadata.get("tags"):
51
+ lines.append(f" Tags: {', '.join(metadata['tags'])}")
52
+ if metadata.get("parent_epic"):
53
+ lines.append(f" Parent Epic: {metadata['parent_epic']}")
54
+ if metadata.get("parent_issue"):
55
+ lines.append(f" Parent Issue: {metadata['parent_issue']}")
56
+
57
+ return lines
58
+
59
+ def format_ticket_list(
60
+ self,
61
+ tickets: List[Dict[str, Any]],
62
+ page: int = 1,
63
+ page_size: int = 20,
64
+ verbose: bool = False,
65
+ ) -> List[str]:
66
+ """
67
+ Format a list of tickets for display.
68
+
69
+ Returns:
70
+ List of formatted output lines
71
+ """
72
+ if not tickets:
73
+ return ["No tickets found matching criteria"]
74
+
75
+ total_shown = len(tickets)
76
+ lines = [f"Tickets (page {page}, showing {total_shown} tickets):", "-" * 80]
77
+
78
+ for ticket in tickets:
79
+ # Get status emoji
80
+ status = ticket.get("status", "unknown")
81
+ emoji = self.STATUS_EMOJI.get(status, self.DEFAULT_EMOJI)
82
+
83
+ lines.append(f"{emoji} [{ticket['id']}] {ticket['title']}")
84
+
85
+ if verbose:
86
+ ticket_type = ticket.get("metadata", {}).get("ticket_type", "task")
87
+ priority = ticket.get("priority", "medium")
88
+ lines.append(
89
+ f" Type: {ticket_type} | Status: {status} | Priority: {priority}"
90
+ )
91
+
92
+ if ticket.get("tags"):
93
+ lines.append(f" Tags: {', '.join(ticket['tags'])}")
94
+
95
+ lines.append(f" Created: {ticket.get('created_at', 'Unknown')}")
96
+ lines.append("")
97
+
98
+ # Add pagination hints
99
+ if total_shown == page_size:
100
+ lines.extend(
101
+ [
102
+ "-" * 80,
103
+ f"📄 Page {page} | Showing {total_shown} tickets",
104
+ f"💡 Next page: claude-mpm tickets list --page {page + 1} --page-size {page_size}",
105
+ ]
106
+ )
107
+ if page > 1:
108
+ lines.append(
109
+ f"💡 Previous page: claude-mpm tickets list --page {page - 1} --page-size {page_size}"
110
+ )
111
+
112
+ return lines
113
+
114
+ def format_ticket_detail(
115
+ self, ticket: Dict[str, Any], verbose: bool = False
116
+ ) -> List[str]:
117
+ """
118
+ Format a single ticket's details for display.
119
+
120
+ Returns:
121
+ List of formatted output lines
122
+ """
123
+ if not ticket:
124
+ return ["❌ Ticket not found"]
125
+
126
+ lines = [
127
+ f"Ticket: {ticket['id']}",
128
+ "=" * 80,
129
+ f"Title: {ticket['title']}",
130
+ f"Type: {ticket.get('metadata', {}).get('ticket_type', 'unknown')}",
131
+ f"Status: {ticket['status']}",
132
+ f"Priority: {ticket['priority']}",
133
+ ]
134
+
135
+ if ticket.get("tags"):
136
+ lines.append(f"Tags: {', '.join(ticket['tags'])}")
137
+
138
+ if ticket.get("assignees"):
139
+ lines.append(f"Assignees: {', '.join(ticket['assignees'])}")
140
+
141
+ # Show parent references
142
+ metadata = ticket.get("metadata", {})
143
+ if metadata.get("parent_epic"):
144
+ lines.append(f"Parent Epic: {metadata['parent_epic']}")
145
+ if metadata.get("parent_issue"):
146
+ lines.append(f"Parent Issue: {metadata['parent_issue']}")
147
+
148
+ lines.extend(
149
+ [
150
+ "",
151
+ "Description:",
152
+ "-" * 40,
153
+ ticket.get("description", "No description"),
154
+ "",
155
+ f"Created: {ticket['created_at']}",
156
+ f"Updated: {ticket['updated_at']}",
157
+ ]
158
+ )
159
+
160
+ if verbose and metadata:
161
+ lines.extend(["", "Metadata:", "-" * 40])
162
+ for key, value in metadata.items():
163
+ if key not in ["parent_epic", "parent_issue", "ticket_type"]:
164
+ lines.append(f" {key}: {value}")
165
+
166
+ return lines
167
+
168
+ def format_search_results(
169
+ self, tickets: List[Dict[str, Any]], query: str, show_snippets: bool = True
170
+ ) -> List[str]:
171
+ """
172
+ Format search results with optional context snippets.
173
+
174
+ Returns:
175
+ List of formatted output lines
176
+ """
177
+ if not tickets:
178
+ return [f"No tickets found matching '{query}'"]
179
+
180
+ lines = [f"Search results for '{query}' (showing {len(tickets)}):", "-" * 80]
181
+
182
+ for ticket in tickets:
183
+ status = ticket.get("status", "unknown")
184
+ emoji = self.STATUS_EMOJI.get(status, self.DEFAULT_EMOJI)
185
+
186
+ lines.append(f"{emoji} [{ticket['id']}] {ticket['title']}")
187
+
188
+ if show_snippets:
189
+ # Show snippet if query appears in description
190
+ desc = ticket.get("description", "")
191
+ if query.lower() in desc.lower():
192
+ snippet = self._get_search_snippet(desc, query)
193
+ lines.append(f" {snippet}")
194
+
195
+ return lines
196
+
197
+ def _get_search_snippet(
198
+ self, text: str, query: str, context_chars: int = 30
199
+ ) -> str:
200
+ """
201
+ Extract a snippet of text around the search query.
202
+
203
+ Returns:
204
+ Formatted snippet with ellipsis if truncated
205
+ """
206
+ lower_text = text.lower()
207
+ query_lower = query.lower()
208
+
209
+ if query_lower not in lower_text:
210
+ return ""
211
+
212
+ idx = lower_text.index(query_lower)
213
+ start = max(0, idx - context_chars)
214
+ end = min(len(text), idx + len(query) + context_chars)
215
+
216
+ snippet = text[start:end]
217
+
218
+ if start > 0:
219
+ snippet = "..." + snippet
220
+ if end < len(text):
221
+ snippet = snippet + "..."
222
+
223
+ return snippet
224
+
225
+ def format_operation_result(
226
+ self,
227
+ operation: str,
228
+ ticket_id: str,
229
+ success: bool,
230
+ message: Optional[str] = None,
231
+ ) -> str:
232
+ """
233
+ Format the result of a ticket operation.
234
+
235
+ Returns:
236
+ Formatted result message
237
+ """
238
+ if success:
239
+ emoji = "✅"
240
+ default_messages = {
241
+ "update": f"Updated ticket: {ticket_id}",
242
+ "close": f"Closed ticket: {ticket_id}",
243
+ "delete": f"Deleted ticket: {ticket_id}",
244
+ "comment": f"Added comment to ticket: {ticket_id}",
245
+ "workflow": f"Updated workflow state for {ticket_id}",
246
+ }
247
+ msg = message or default_messages.get(
248
+ operation, f"Operation completed: {ticket_id}"
249
+ )
250
+ else:
251
+ emoji = "❌"
252
+ default_messages = {
253
+ "update": f"Failed to update ticket: {ticket_id}",
254
+ "close": f"Failed to close ticket: {ticket_id}",
255
+ "delete": f"Failed to delete ticket: {ticket_id}",
256
+ "comment": f"Failed to add comment to ticket: {ticket_id}",
257
+ "workflow": f"Failed to update workflow state for ticket: {ticket_id}",
258
+ }
259
+ msg = message or default_messages.get(
260
+ operation, f"Operation failed: {ticket_id}"
261
+ )
262
+
263
+ return f"{emoji} {msg}"
264
+
265
+ def format_error(self, error: str) -> str:
266
+ """
267
+ Format an error message.
268
+
269
+ Returns:
270
+ Formatted error message
271
+ """
272
+ return f"❌ {error}"
273
+
274
+ def format_info(self, info: str) -> str:
275
+ """
276
+ Format an informational message.
277
+
278
+ Returns:
279
+ Formatted info message
280
+ """
281
+ return f"ℹ️ {info}"
282
+
283
+ def format_warning(self, warning: str) -> str:
284
+ """
285
+ Format a warning message.
286
+
287
+ Returns:
288
+ Formatted warning message
289
+ """
290
+ return f"⚠️ {warning}"
@@ -0,0 +1,324 @@
1
+ """
2
+ Search service for tickets.
3
+
4
+ WHY: Extracts search logic into a dedicated service for better
5
+ organization and testability of search functionality.
6
+
7
+ DESIGN DECISIONS:
8
+ - Supports text-based search across multiple fields
9
+ - Handles filtering by type and status
10
+ - Provides relevance ranking for search results
11
+ - Abstracts search backend (can switch between different implementations)
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from ...core.logger import get_logger
17
+
18
+
19
+ class TicketSearchService:
20
+ """Service for searching tickets."""
21
+
22
+ def __init__(self, ticket_manager=None):
23
+ """
24
+ Initialize the search service.
25
+
26
+ Args:
27
+ ticket_manager: Optional ticket manager instance for testing
28
+ """
29
+ self.logger = get_logger("services.ticket_search")
30
+ self._ticket_manager = ticket_manager
31
+
32
+ @property
33
+ def ticket_manager(self):
34
+ """Lazy load ticket manager."""
35
+ if self._ticket_manager is None:
36
+ try:
37
+ from ..ticket_manager import TicketManager
38
+ except ImportError:
39
+ from claude_mpm.services.ticket_manager import TicketManager
40
+ self._ticket_manager = TicketManager()
41
+ return self._ticket_manager
42
+
43
+ def search_tickets(
44
+ self,
45
+ query: str,
46
+ type_filter: str = "all",
47
+ status_filter: str = "all",
48
+ limit: int = 10,
49
+ search_fields: Optional[List[str]] = None,
50
+ ) -> List[Dict[str, Any]]:
51
+ """
52
+ Search tickets by query string.
53
+
54
+ Args:
55
+ query: Search query
56
+ type_filter: Filter by ticket type
57
+ status_filter: Filter by status
58
+ limit: Maximum results to return
59
+ search_fields: Fields to search in (default: title, description, tags)
60
+
61
+ Returns:
62
+ List of matching tickets
63
+ """
64
+ if not search_fields:
65
+ search_fields = ["title", "description", "tags"]
66
+
67
+ try:
68
+ # Get all available tickets
69
+ all_tickets = self.ticket_manager.list_recent_tickets(limit=100)
70
+
71
+ # Search and filter
72
+ matched_tickets = []
73
+ query_lower = query.lower()
74
+
75
+ for ticket in all_tickets:
76
+ # Check if query matches any search field
77
+ if self._matches_query(ticket, query_lower, search_fields):
78
+ # Apply type filter
79
+ if not self._passes_type_filter(ticket, type_filter):
80
+ continue
81
+
82
+ # Apply status filter
83
+ if not self._passes_status_filter(ticket, status_filter):
84
+ continue
85
+
86
+ matched_tickets.append(ticket)
87
+
88
+ if len(matched_tickets) >= limit:
89
+ break
90
+
91
+ # Sort by relevance
92
+ return self._sort_by_relevance(matched_tickets, query_lower)
93
+
94
+ except Exception as e:
95
+ self.logger.error(f"Error searching tickets: {e}")
96
+ return []
97
+
98
+ def _matches_query(
99
+ self, ticket: Dict[str, Any], query: str, search_fields: List[str]
100
+ ) -> bool:
101
+ """
102
+ Check if ticket matches the search query.
103
+
104
+ Returns:
105
+ True if ticket matches query in any search field
106
+ """
107
+ for field in search_fields:
108
+ if field == "title" and query in ticket.get("title", "").lower():
109
+ return True
110
+
111
+ if (
112
+ field == "description"
113
+ and query in ticket.get("description", "").lower()
114
+ ):
115
+ return True
116
+
117
+ if field == "tags":
118
+ tags = ticket.get("tags", [])
119
+ if any(query in tag.lower() for tag in tags):
120
+ return True
121
+
122
+ if field == "id" and query in ticket.get("id", "").lower():
123
+ return True
124
+
125
+ # Search in metadata fields
126
+ if field == "metadata":
127
+ metadata = ticket.get("metadata", {})
128
+ for value in metadata.values():
129
+ if isinstance(value, str) and query in value.lower():
130
+ return True
131
+
132
+ return False
133
+
134
+ def _passes_type_filter(self, ticket: Dict[str, Any], type_filter: str) -> bool:
135
+ """
136
+ Check if ticket passes type filter.
137
+
138
+ Returns:
139
+ True if ticket matches type filter
140
+ """
141
+ if type_filter == "all":
142
+ return True
143
+
144
+ ticket_type = ticket.get("metadata", {}).get("ticket_type", "unknown")
145
+ return ticket_type == type_filter
146
+
147
+ def _passes_status_filter(self, ticket: Dict[str, Any], status_filter: str) -> bool:
148
+ """
149
+ Check if ticket passes status filter.
150
+
151
+ Returns:
152
+ True if ticket matches status filter
153
+ """
154
+ if status_filter == "all":
155
+ return True
156
+
157
+ return ticket.get("status") == status_filter
158
+
159
+ def _sort_by_relevance(
160
+ self, tickets: List[Dict[str, Any]], query: str
161
+ ) -> List[Dict[str, Any]]:
162
+ """
163
+ Sort tickets by relevance to search query.
164
+
165
+ Returns:
166
+ Sorted list of tickets (most relevant first)
167
+ """
168
+
169
+ def relevance_score(ticket):
170
+ score = 0
171
+
172
+ # Title matches are most relevant
173
+ if query in ticket.get("title", "").lower():
174
+ score += 10
175
+
176
+ # ID matches are very relevant
177
+ if query in ticket.get("id", "").lower():
178
+ score += 8
179
+
180
+ # Tag matches are moderately relevant
181
+ tags = ticket.get("tags", [])
182
+ for tag in tags:
183
+ if query in tag.lower():
184
+ score += 5
185
+
186
+ # Description matches are less relevant
187
+ if query in ticket.get("description", "").lower():
188
+ score += 2
189
+
190
+ # Boost recent tickets slightly
191
+ if ticket.get("status") in ["open", "in_progress"]:
192
+ score += 1
193
+
194
+ return score
195
+
196
+ return sorted(tickets, key=relevance_score, reverse=True)
197
+
198
+ def find_similar_tickets(
199
+ self, ticket_id: str, limit: int = 5
200
+ ) -> List[Dict[str, Any]]:
201
+ """
202
+ Find tickets similar to a given ticket.
203
+
204
+ Args:
205
+ ticket_id: ID of the reference ticket
206
+ limit: Maximum similar tickets to return
207
+
208
+ Returns:
209
+ List of similar tickets
210
+ """
211
+ try:
212
+ # Get the reference ticket
213
+ reference = self.ticket_manager.get_ticket(ticket_id)
214
+ if not reference:
215
+ return []
216
+
217
+ # Extract keywords from title and tags
218
+ keywords = self._extract_keywords(reference)
219
+
220
+ # Search for similar tickets
221
+ similar = []
222
+ all_tickets = self.ticket_manager.list_recent_tickets(limit=50)
223
+
224
+ for ticket in all_tickets:
225
+ if ticket["id"] == ticket_id:
226
+ continue # Skip the reference ticket
227
+
228
+ # Calculate similarity score
229
+ score = self._calculate_similarity(reference, ticket, keywords)
230
+
231
+ if score > 0:
232
+ similar.append((score, ticket))
233
+
234
+ # Sort by similarity and return top results
235
+ similar.sort(key=lambda x: x[0], reverse=True)
236
+ return [ticket for _, ticket in similar[:limit]]
237
+
238
+ except Exception as e:
239
+ self.logger.error(f"Error finding similar tickets: {e}")
240
+ return []
241
+
242
+ def _extract_keywords(self, ticket: Dict[str, Any]) -> List[str]:
243
+ """
244
+ Extract keywords from a ticket for similarity matching.
245
+
246
+ Returns:
247
+ List of keywords
248
+ """
249
+ keywords = []
250
+
251
+ # Extract words from title
252
+ title = ticket.get("title", "")
253
+ keywords.extend(title.lower().split())
254
+
255
+ # Add tags as keywords
256
+ keywords.extend([tag.lower() for tag in ticket.get("tags", [])])
257
+
258
+ # Extract key words from description (first 100 chars)
259
+ desc = ticket.get("description", "")[:100]
260
+ keywords.extend(desc.lower().split()[:10])
261
+
262
+ # Remove common words
263
+ common_words = {
264
+ "the",
265
+ "a",
266
+ "an",
267
+ "and",
268
+ "or",
269
+ "but",
270
+ "in",
271
+ "on",
272
+ "at",
273
+ "to",
274
+ "for",
275
+ }
276
+ keywords = [w for w in keywords if w not in common_words and len(w) > 2]
277
+
278
+ return list(set(keywords)) # Remove duplicates
279
+
280
+ def _calculate_similarity(
281
+ self, ref_ticket: Dict[str, Any], ticket: Dict[str, Any], keywords: List[str]
282
+ ) -> float:
283
+ """
284
+ Calculate similarity score between two tickets.
285
+
286
+ Returns:
287
+ Similarity score (0-100)
288
+ """
289
+ score = 0.0
290
+
291
+ # Same type bonus
292
+ ref_type = ref_ticket.get("metadata", {}).get("ticket_type")
293
+ ticket_type = ticket.get("metadata", {}).get("ticket_type")
294
+ if ref_type and ticket_type and ref_type == ticket_type:
295
+ score += 10
296
+
297
+ # Same status bonus
298
+ if ref_ticket.get("status") == ticket.get("status"):
299
+ score += 5
300
+
301
+ # Same priority bonus
302
+ if ref_ticket.get("priority") == ticket.get("priority"):
303
+ score += 3
304
+
305
+ # Keyword matches
306
+ ticket_text = (
307
+ ticket.get("title", "").lower()
308
+ + " "
309
+ + ticket.get("description", "").lower()
310
+ + " "
311
+ + " ".join(ticket.get("tags", []))
312
+ ).lower()
313
+
314
+ for keyword in keywords:
315
+ if keyword in ticket_text:
316
+ score += 2
317
+
318
+ # Common tags
319
+ ref_tags = set(ref_ticket.get("tags", []))
320
+ ticket_tags = set(ticket.get("tags", []))
321
+ common_tags = ref_tags.intersection(ticket_tags)
322
+ score += len(common_tags) * 5
323
+
324
+ return min(score, 100) # Cap at 100