siggy-memory 0.5.0__tar.gz

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 (60) hide show
  1. siggy_memory-0.5.0/PKG-INFO +25 -0
  2. siggy_memory-0.5.0/agents/__init__.py +0 -0
  3. siggy_memory-0.5.0/agents/incident_agent.py +90 -0
  4. siggy_memory-0.5.0/agents/investigator.py +117 -0
  5. siggy_memory-0.5.0/agents/normalizer.py +64 -0
  6. siggy_memory-0.5.0/agents/recommender.py +81 -0
  7. siggy_memory-0.5.0/cli/__init__.py +1 -0
  8. siggy_memory-0.5.0/cli/__main__.py +3 -0
  9. siggy_memory-0.5.0/cli/config.py +83 -0
  10. siggy_memory-0.5.0/cli/main.py +1448 -0
  11. siggy_memory-0.5.0/experience/__init__.py +1 -0
  12. siggy_memory-0.5.0/experience/aggregator.py +87 -0
  13. siggy_memory-0.5.0/experience/models.py +127 -0
  14. siggy_memory-0.5.0/experience/patterns.py +29 -0
  15. siggy_memory-0.5.0/experience/ranking.py +216 -0
  16. siggy_memory-0.5.0/experience/statistics.py +64 -0
  17. siggy_memory-0.5.0/experience/store.py +199 -0
  18. siggy_memory-0.5.0/graph/__init__.py +1 -0
  19. siggy_memory-0.5.0/graph/builder.py +193 -0
  20. siggy_memory-0.5.0/graph/client.py +237 -0
  21. siggy_memory-0.5.0/graph/context_builder.py +97 -0
  22. siggy_memory-0.5.0/graph/queries.py +114 -0
  23. siggy_memory-0.5.0/graph/schema.py +70 -0
  24. siggy_memory-0.5.0/incident/__init__.py +1 -0
  25. siggy_memory-0.5.0/incident/processor.py +448 -0
  26. siggy_memory-0.5.0/knowledge/__init__.py +0 -0
  27. siggy_memory-0.5.0/knowledge/normalization_v2.py +564 -0
  28. siggy_memory-0.5.0/knowledge/normalizer.py +154 -0
  29. siggy_memory-0.5.0/knowledge/pipeline.py +240 -0
  30. siggy_memory-0.5.0/knowledge/schema.py +78 -0
  31. siggy_memory-0.5.0/knowledge/taxonomy.py +538 -0
  32. siggy_memory-0.5.0/llm/__init__.py +0 -0
  33. siggy_memory-0.5.0/llm/prompt.py +123 -0
  34. siggy_memory-0.5.0/memory/__init__.py +0 -0
  35. siggy_memory-0.5.0/memory/embeddings.py +50 -0
  36. siggy_memory-0.5.0/memory/experience.py +97 -0
  37. siggy_memory-0.5.0/memory/provider.py +23 -0
  38. siggy_memory-0.5.0/memory/search.py +53 -0
  39. siggy_memory-0.5.0/memory/vector_store.py +241 -0
  40. siggy_memory-0.5.0/models/__init__.py +0 -0
  41. siggy_memory-0.5.0/models/incident.py +116 -0
  42. siggy_memory-0.5.0/otel/__init__.py +1 -0
  43. siggy_memory-0.5.0/otel/instrument.py +80 -0
  44. siggy_memory-0.5.0/pyproject.toml +66 -0
  45. siggy_memory-0.5.0/rules/__init__.py +0 -0
  46. siggy_memory-0.5.0/rules/engine.py +141 -0
  47. siggy_memory-0.5.0/setup.cfg +4 -0
  48. siggy_memory-0.5.0/siggy_memory.egg-info/PKG-INFO +25 -0
  49. siggy_memory-0.5.0/siggy_memory.egg-info/SOURCES.txt +58 -0
  50. siggy_memory-0.5.0/siggy_memory.egg-info/dependency_links.txt +1 -0
  51. siggy_memory-0.5.0/siggy_memory.egg-info/entry_points.txt +2 -0
  52. siggy_memory-0.5.0/siggy_memory.egg-info/requires.txt +18 -0
  53. siggy_memory-0.5.0/siggy_memory.egg-info/top_level.txt +13 -0
  54. siggy_memory-0.5.0/telemetry/__init__.py +2 -0
  55. siggy_memory-0.5.0/telemetry/mcp_http.py +257 -0
  56. siggy_memory-0.5.0/telemetry/provider.py +51 -0
  57. siggy_memory-0.5.0/telemetry/signoz_mcp.py +177 -0
  58. siggy_memory-0.5.0/telemetry/summarizer.py +119 -0
  59. siggy_memory-0.5.0/utils/__init__.py +1 -0
  60. siggy_memory-0.5.0/utils/fallbacks.py +376 -0
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: siggy-memory
3
+ Version: 0.5.0
4
+ Summary: AI-powered observability memory layer for SigNoz
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/siggy-ai/siggy
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: fastapi>=0.115.0
10
+ Requires-Dist: uvicorn>=0.30.0
11
+ Requires-Dist: groq>=0.13.0
12
+ Requires-Dist: huggingface-hub>=1.0.0
13
+ Requires-Dist: qdrant-client[local]>=1.12.0
14
+ Requires-Dist: pydantic>=2.9.0
15
+ Requires-Dist: python-dotenv>=1.0.0
16
+ Requires-Dist: httpx>=0.27.0
17
+ Requires-Dist: click>=8.1.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Requires-Dist: opentelemetry-api>=1.20.0
20
+ Requires-Dist: opentelemetry-sdk>=1.20.0
21
+ Requires-Dist: opentelemetry-exporter-otlp>=1.20.0
22
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.41b0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
File without changes
@@ -0,0 +1,90 @@
1
+ import os
2
+ import json
3
+ from groq import Groq
4
+ from dotenv import load_dotenv
5
+
6
+ from memory.embeddings import get_embedding
7
+ from memory.search import get_search
8
+ from agents.normalizer import normalize_incident, build_embedding_text
9
+ from llm.prompt import build_analyze_prompt, build_search_hint_prompt
10
+ from utils.fallbacks import analyze_incident_fallback
11
+
12
+ load_dotenv()
13
+
14
+ _client = None
15
+
16
+
17
+ def _get_client():
18
+ global _client
19
+ if _client is None:
20
+ _client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
21
+ return _client
22
+
23
+
24
+ class IncidentAgent:
25
+ def __init__(self):
26
+ self.search = get_search()
27
+
28
+ def analyze(self, title: str, summary: str) -> dict:
29
+ # Step 1: Normalize
30
+ normalized = normalize_incident(title, summary)
31
+
32
+ # Step 2: Embed
33
+ embedding_text = build_embedding_text(normalized)
34
+ query_embedding = get_embedding(embedding_text)
35
+
36
+ # Step 3: Search with metadata hints
37
+ hints = build_search_hint_prompt(normalized.model_dump())
38
+ similar_incidents = self.search.retrieve(
39
+ query_embedding=query_embedding,
40
+ top_k=5,
41
+ filters=hints if hints else None,
42
+ )
43
+
44
+ # Step 4: If metadata filter too strict, fallback to pure vector search
45
+ if len(similar_incidents) < 2:
46
+ similar_incidents = self.search.retrieve(
47
+ query_embedding=query_embedding,
48
+ top_k=5,
49
+ filters=None,
50
+ )
51
+
52
+ # Step 5: Build prompt and call LLM
53
+ prompt = build_analyze_prompt(
54
+ current_incident={"title": title, "summary": summary},
55
+ similar_incidents=similar_incidents,
56
+ normalized=normalized.model_dump(),
57
+ )
58
+
59
+ try:
60
+ response = _get_client().chat.completions.create(
61
+ model="llama-3.3-70b-versatile",
62
+ messages=[
63
+ {
64
+ "role": "system",
65
+ "content": (
66
+ "You are a senior SRE engineer. "
67
+ "Always return valid JSON. "
68
+ "Show your reasoning chain step by step."
69
+ ),
70
+ },
71
+ {"role": "user", "content": prompt},
72
+ ],
73
+ temperature=0,
74
+ )
75
+
76
+ raw = response.choices[0].message.content.strip()
77
+ if raw.startswith("```"):
78
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
79
+
80
+ analysis = json.loads(raw)
81
+ except Exception:
82
+ analysis = analyze_incident_fallback(title, summary, normalized, similar_incidents)
83
+
84
+ # Step 6: Build final response
85
+ return {
86
+ "current_incident": {"title": title, "summary": summary},
87
+ "normalized": normalized.model_dump(),
88
+ "similar_incidents": similar_incidents,
89
+ "analysis": analysis,
90
+ }
@@ -0,0 +1,117 @@
1
+ import os
2
+ import json
3
+ import asyncio
4
+ from datetime import datetime, timedelta
5
+ from groq import Groq
6
+ from dotenv import load_dotenv
7
+
8
+ from telemetry.signoz_mcp import get_telemetry_provider
9
+ from telemetry.summarizer import summarize_telemetry
10
+ from utils.fallbacks import infer_service
11
+
12
+ load_dotenv()
13
+
14
+ _client = None
15
+
16
+
17
+ def _get_client():
18
+ global _client
19
+ if _client is None:
20
+ _client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
21
+ return _client
22
+
23
+
24
+ class InvestigatorAgent:
25
+ def __init__(self):
26
+ self.telemetry = get_telemetry_provider()
27
+
28
+ async def investigate(self, query: str) -> dict:
29
+ now = datetime.utcnow()
30
+ start_time = (now - timedelta(hours=1)).isoformat() + "Z"
31
+ end_time = now.isoformat() + "Z"
32
+
33
+ try:
34
+ services = await self.telemetry.list_services(start_time, end_time)
35
+ except Exception:
36
+ services = []
37
+ relevant_service = self._identify_service(query, services)
38
+
39
+ logs_task = self.telemetry.search_logs(
40
+ service=relevant_service,
41
+ query="level:error OR level:warn OR timeout OR exception OR error",
42
+ limit=30,
43
+ start_time=start_time,
44
+ end_time=end_time,
45
+ )
46
+ traces_task = self.telemetry.search_traces(
47
+ service=relevant_service,
48
+ min_duration_ms=1000,
49
+ limit=20,
50
+ start_time=start_time,
51
+ end_time=end_time,
52
+ )
53
+ latency_task = self.telemetry.query_metrics(
54
+ service=relevant_service,
55
+ metric_name="http_request_duration_milliseconds",
56
+ aggregation="p99",
57
+ start_time=start_time,
58
+ end_time=end_time,
59
+ )
60
+ error_task = self.telemetry.query_metrics(
61
+ service=relevant_service,
62
+ metric_name="http_requests_total",
63
+ aggregation="rate",
64
+ start_time=start_time,
65
+ end_time=end_time,
66
+ )
67
+
68
+ results = await asyncio.gather(
69
+ logs_task, traces_task, latency_task, error_task,
70
+ return_exceptions=True,
71
+ )
72
+
73
+ logs = results[0] if not isinstance(results[0], Exception) else []
74
+ traces = results[1] if not isinstance(results[1], Exception) else []
75
+ latency_metric = results[2] if not isinstance(results[2], Exception) else {}
76
+ error_metric = results[3] if not isinstance(results[3], Exception) else {}
77
+
78
+ raw_data = {
79
+ "query": query,
80
+ "service": relevant_service,
81
+ "time_range": {"start": start_time, "end": end_time},
82
+ "logs": logs,
83
+ "traces": traces,
84
+ "latency_metric": latency_metric,
85
+ "error_metric": error_metric,
86
+ }
87
+
88
+ summary = summarize_telemetry(raw_data)
89
+ return summary
90
+
91
+ def _identify_service(self, query: str, services: list[dict]) -> str:
92
+ query_lower = query.lower()
93
+
94
+ for svc in services:
95
+ svc_name = svc.get("serviceName", "").lower()
96
+ if svc_name in query_lower or query_lower in svc_name:
97
+ return svc.get("serviceName", "unknown")
98
+
99
+ if not services:
100
+ return infer_service(query, "unknown")
101
+
102
+ service_names = [s.get("serviceName", "unknown") for s in services]
103
+ heuristic = infer_service(query, "")
104
+ if heuristic and any(heuristic == svc.lower() for svc in service_names):
105
+ return heuristic
106
+ try:
107
+ response = _get_client().chat.completions.create(
108
+ model="llama-3.3-70b-versatile",
109
+ messages=[
110
+ {"role": "system", "content": "You are a service classifier. Return ONLY the service name."},
111
+ {"role": "user", "content": f"User query: '{query}'\nAvailable services: {service_names}\nWhich service is the user talking about? Return ONLY the service name."},
112
+ ],
113
+ temperature=0,
114
+ )
115
+ return response.choices[0].message.content.strip().strip('"').strip("'")
116
+ except Exception:
117
+ return service_names[0] if service_names else "unknown"
@@ -0,0 +1,64 @@
1
+ import os
2
+ import json
3
+ from groq import Groq
4
+ from dotenv import load_dotenv
5
+
6
+ from models.incident import NormalizedIncident
7
+ from utils.fallbacks import normalize_incident_fallback
8
+
9
+ load_dotenv()
10
+
11
+ _client = None
12
+
13
+
14
+ def _get_client():
15
+ global _client
16
+ if _client is None:
17
+ _client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
18
+ return _client
19
+
20
+ NORMALIZER_PROMPT = """You are an incident classifier. Convert the raw incident description into a structured JSON object.
21
+
22
+ Rules:
23
+ - service: the affected service name (lowercase, use "unknown" if unclear)
24
+ - component: the infrastructure component that failed (e.g., redis, kafka, postgresql, cpu, memory, disk, network)
25
+ - failure: the failure type in snake_case (e.g., connection_pool_exhaustion, broker_unavailable, infinite_loop, timeout, memory_leak)
26
+ - symptom: the observable symptom in snake_case (e.g., high_latency, request_timeout, cpu_spike, oom_killed, message_backup)
27
+ - root_cause: one-line technical root cause
28
+ - fix: one-line recommended fix
29
+
30
+ Return ONLY valid JSON. No markdown, no explanation.
31
+
32
+ Incident title: {title}
33
+ Incident summary: {summary}"""
34
+
35
+
36
+ def normalize_incident(title: str, summary: str) -> NormalizedIncident:
37
+ try:
38
+ response = _get_client().chat.completions.create(
39
+ model="llama-3.3-70b-versatile",
40
+ messages=[
41
+ {
42
+ "role": "system",
43
+ "content": "You are an incident classifier. Return only valid JSON.",
44
+ },
45
+ {
46
+ "role": "user",
47
+ "content": NORMALIZER_PROMPT.format(title=title, summary=summary),
48
+ },
49
+ ],
50
+ temperature=0,
51
+ )
52
+
53
+ raw = response.choices[0].message.content.strip()
54
+ if raw.startswith("```"):
55
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
56
+
57
+ data = json.loads(raw)
58
+ return NormalizedIncident(**data)
59
+ except Exception:
60
+ return normalize_incident_fallback(title, summary)
61
+
62
+
63
+ def build_embedding_text(normalized: NormalizedIncident) -> str:
64
+ return f"{normalized.service} {normalized.component} {normalized.failure} {normalized.symptom} {normalized.root_cause}"
@@ -0,0 +1,81 @@
1
+ import os
2
+ import json
3
+ from groq import Groq
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv()
7
+
8
+ _client = None
9
+
10
+
11
+ def _get_client():
12
+ global _client
13
+ if _client is None:
14
+ _client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
15
+ return _client
16
+
17
+ RECOMMENDER_PROMPT = """You are an SRE incident resolution expert. A new incident has occurred. Below are similar incidents from the past with their root causes and fixes.
18
+
19
+ Use these past incidents to recommend the best fix for the new incident.
20
+
21
+ IMPORTANT: Return your response as valid JSON with this exact structure:
22
+ {{
23
+ "recommended_fix": "detailed fix recommendation",
24
+ "confidence": 0.0 to 1.0,
25
+ "explanation": "brief explanation of why this fix is recommended"
26
+ }}
27
+
28
+ --- NEW INCIDENT ---
29
+ Title: {title}
30
+ Summary: {summary}
31
+ Normalized: {normalized}
32
+
33
+ --- SIMILAR PAST INCIDENTS ---
34
+ {similar_incidents}
35
+
36
+ --- END ---
37
+
38
+ Based on the above similar incidents, recommend the best fix for the new incident. Return ONLY valid JSON."""
39
+
40
+
41
+ def recommend_fix(
42
+ title: str,
43
+ summary: str,
44
+ normalized: dict,
45
+ similar_incidents: list[dict],
46
+ ) -> dict:
47
+ incidents_text = ""
48
+ for i, inc in enumerate(similar_incidents, 1):
49
+ incidents_text += f"""
50
+ Incident {i}:
51
+ Title: {inc['title']}
52
+ Root Cause: {inc['root_cause']}
53
+ Fix: {inc['fix']}
54
+ Similarity: {inc['similarity']}
55
+ """
56
+
57
+ response = _get_client().chat.completions.create(
58
+ model="llama-3.3-70b-versatile",
59
+ messages=[
60
+ {
61
+ "role": "system",
62
+ "content": "You are an SRE expert. Return only valid JSON.",
63
+ },
64
+ {
65
+ "role": "user",
66
+ "content": RECOMMENDER_PROMPT.format(
67
+ title=title,
68
+ summary=summary,
69
+ normalized=str(normalized),
70
+ similar_incidents=incidents_text.strip(),
71
+ ),
72
+ },
73
+ ],
74
+ temperature=0,
75
+ )
76
+
77
+ raw = response.choices[0].message.content.strip()
78
+ if raw.startswith("```"):
79
+ raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
80
+
81
+ return json.loads(raw)
@@ -0,0 +1 @@
1
+ """Siggy CLI — production observability with memory."""
@@ -0,0 +1,3 @@
1
+ from cli.main import main
2
+
3
+ main()
@@ -0,0 +1,83 @@
1
+ """Siggy configuration — reads and writes .siggy.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+
12
+ CONFIG_FILENAME = ".siggy.yaml"
13
+
14
+
15
+ @dataclass
16
+ class SigNozConfig:
17
+ url: str = "http://localhost:8080"
18
+ otlp_endpoint: str = "http://localhost:4317"
19
+ mcp_url: str = "http://localhost:8000/mcp"
20
+ api_key: str = ""
21
+ dashboard_url: str = ""
22
+
23
+
24
+ @dataclass
25
+ class ServiceConfig:
26
+ default_name: str = "my-service"
27
+
28
+
29
+ @dataclass
30
+ class MemoryConfig:
31
+ backend_url: str = "http://localhost:8010"
32
+ qdrant_url: str = "http://localhost:6333"
33
+
34
+
35
+ @dataclass
36
+ class SiggyConfig:
37
+ signoz: SigNozConfig = field(default_factory=SigNozConfig)
38
+ service: ServiceConfig = field(default_factory=ServiceConfig)
39
+ memory: MemoryConfig = field(default_factory=MemoryConfig)
40
+
41
+ @classmethod
42
+ def load(cls, path: Path | None = None) -> "SiggyConfig":
43
+ path = path or _find_config()
44
+ if path is None:
45
+ return cls()
46
+
47
+ with open(path, encoding="utf-8") as f:
48
+ raw = yaml.safe_load(f) or {}
49
+
50
+ return cls(
51
+ signoz=SigNozConfig(**raw.get("signoz", {})),
52
+ service=ServiceConfig(**raw.get("service", {})),
53
+ memory=MemoryConfig(**raw.get("memory", {})),
54
+ )
55
+
56
+ def save(self, path: Path | None = None) -> Path:
57
+ path = path or Path.cwd() / CONFIG_FILENAME
58
+ data = {
59
+ "signoz": {
60
+ "url": self.signoz.url,
61
+ "otlp_endpoint": self.signoz.otlp_endpoint,
62
+ "mcp_url": self.signoz.mcp_url,
63
+ "api_key": self.signoz.api_key or os.getenv("SIGNOZ_API_KEY", ""),
64
+ "dashboard_url": self.signoz.dashboard_url,
65
+ },
66
+ "service": {"default_name": self.service.default_name},
67
+ "memory": {
68
+ "backend_url": self.memory.backend_url,
69
+ "qdrant_url": self.memory.qdrant_url,
70
+ },
71
+ }
72
+ with open(path, "w", encoding="utf-8") as f:
73
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
74
+ return path
75
+
76
+
77
+ def _find_config() -> Path | None:
78
+ current = Path.cwd()
79
+ for parent in [current, *current.parents]:
80
+ candidate = parent / CONFIG_FILENAME
81
+ if candidate.exists():
82
+ return candidate
83
+ return None