Skip to content

Commit cfa0821

Browse files
authored
Merge pull request #628 from lbedner/insights-service
Insights Service
2 parents 7fc196b + 2fab368 commit cfa0821

File tree

91 files changed

+13566
-163
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+13566
-163
lines changed

.github/workflows/security.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ jobs:
5252

5353
- name: Run security audit
5454
run: |
55-
# Ignoring pip 25.2 vulnerabilities (fixed in pip 26.0, not yet released)
56-
# GHSA-4xh5-x5gv-qwph: symlink traversal vulnerability
57-
# GHSA-6vgw-5pg2-w6jp: additional pip vulnerability
55+
# Ignoring pip 25.2 vulnerabilities (uv manages pip, not user-facing)
5856
# Risk: Low - only affects installation of malicious packages from untrusted sources
5957
# Mitigation: All packages installed from trusted PyPI with uv.lock verification
60-
uv run pip-audit --ignore-vuln GHSA-4xh5-x5gv-qwph --ignore-vuln GHSA-6vgw-5pg2-w6jp
58+
uv run pip-audit \
59+
--ignore-vuln GHSA-4xh5-x5gv-qwph \
60+
--ignore-vuln GHSA-6vgw-5pg2-w6jp \
61+
--ignore-vuln ECHO-ffe1-1d3c-d9bc \
62+
--ignore-vuln ECHO-7db2-03aa-5591

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Most starters lock you in at `init`. Aegis Stack doesn't. See **[Evolving Your S
132132
- **Auth** → JWT authentication
133133
- **AI** → PydanticAI / LangChain
134134
- **Comms** → Resend + Twilio
135+
- **Insights** → Adoption metrics (GitHub, PyPI, Plausible) *(experimental)*
135136

136137
[Components Docs →](https://lbedner.github.io/aegis-stack/components/) | [Services Docs →](https://lbedner.github.io/aegis-stack/services/)
137138

aegis/cli/callbacks.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
restore_engine_info,
2222
)
2323
from ..core.dependency_resolver import DependencyResolver
24+
from ..core.insights_service_parser import (
25+
is_insights_service_with_options,
26+
parse_insights_service_config,
27+
)
2428
from ..core.service_resolver import ServiceResolver
2529
from ..core.services import SERVICES
2630
from ..i18n import t
@@ -212,6 +216,18 @@ def validate_and_resolve_services(
212216
typer.secho(f"Invalid auth service syntax: {e}", fg="red", err=True)
213217
raise typer.Exit(1)
214218

219+
# Parse Insights service bracket syntax
220+
for service in selected_services:
221+
if is_insights_service_with_options(service):
222+
try:
223+
insights_config = parse_insights_service_config(service)
224+
typer.echo(
225+
f"Insights service: sources={','.join(insights_config.sources)}"
226+
)
227+
except ValueError as e:
228+
typer.secho(f"Invalid insights service syntax: {e}", fg="red", err=True)
229+
raise typer.Exit(1)
230+
215231
# Resolve services to components
216232
resolved_components, service_added = ServiceResolver.resolve_service_dependencies(
217233
selected_services

aegis/commands/add_service.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,24 @@ def add_service_command(
422422
if isinstance(framework, str):
423423
service_data[AnswerKeys.AI_FRAMEWORK] = framework
424424

425+
# For insights service, pass source flags
426+
if base_service == AnswerKeys.SERVICE_INSIGHTS:
427+
from ..core.insights_service_parser import (
428+
DEFAULT_SOURCES,
429+
is_insights_service_with_options,
430+
parse_insights_service_config,
431+
)
432+
433+
if is_insights_service_with_options(service):
434+
insights_config = parse_insights_service_config(service)
435+
sources = insights_config.sources
436+
else:
437+
sources = DEFAULT_SOURCES
438+
service_data[AnswerKeys.INSIGHTS_GITHUB] = "github" in sources
439+
service_data[AnswerKeys.INSIGHTS_PYPI] = "pypi" in sources
440+
service_data[AnswerKeys.INSIGHTS_PLAUSIBLE] = "plausible" in sources
441+
service_data[AnswerKeys.INSIGHTS_REDDIT] = "reddit" in sources
442+
425443
# Add the service (services are added like components)
426444
# Use base_service for file lookup, not the full variant name
427445
result = updater.add_component(base_service, service_data)

aegis/constants.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,19 @@ class AnswerKeys:
125125
AUTH = "include_auth"
126126
AI = "include_ai"
127127
COMMS = "include_comms"
128+
INSIGHTS = "include_insights"
128129

129130
# Service names (used for selection/lookup)
130131
SERVICE_AUTH = "auth"
131132
SERVICE_AI = "ai"
132133
SERVICE_COMMS = "comms"
134+
SERVICE_INSIGHTS = "insights"
135+
136+
# Insights source flags
137+
INSIGHTS_GITHUB = "insights_github"
138+
INSIGHTS_PYPI = "insights_pypi"
139+
INSIGHTS_PLAUSIBLE = "insights_plausible"
140+
INSIGHTS_REDDIT = "insights_reddit"
133141

134142
# Configuration values
135143
SCHEDULER_BACKEND = "scheduler_backend"

aegis/core/copier_manager.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,21 @@ def generate_with_copier(
138138
AnswerKeys.AI_RAG: template_context.get(AnswerKeys.AI_RAG, "no") == "yes",
139139
AnswerKeys.AI_VOICE: template_context.get(AnswerKeys.AI_VOICE, "no") == "yes",
140140
AnswerKeys.OLLAMA_MODE: template_context.get(AnswerKeys.OLLAMA_MODE, "none"),
141+
AnswerKeys.INSIGHTS: template_context.get(AnswerKeys.INSIGHTS, "no") == "yes",
142+
AnswerKeys.INSIGHTS_GITHUB: template_context.get(
143+
AnswerKeys.INSIGHTS_GITHUB, "no"
144+
)
145+
== "yes",
146+
AnswerKeys.INSIGHTS_PYPI: template_context.get(AnswerKeys.INSIGHTS_PYPI, "no")
147+
== "yes",
148+
AnswerKeys.INSIGHTS_PLAUSIBLE: template_context.get(
149+
AnswerKeys.INSIGHTS_PLAUSIBLE, "no"
150+
)
151+
== "yes",
152+
AnswerKeys.INSIGHTS_REDDIT: template_context.get(
153+
AnswerKeys.INSIGHTS_REDDIT, "no"
154+
)
155+
== "yes",
141156
}
142157

143158
# Detect dev vs production mode for template sourcing
@@ -228,6 +243,7 @@ def generate_with_copier(
228243
# This ensures consistent behavior with Cookiecutter
229244
include_auth = copier_data.get(AnswerKeys.AUTH, False)
230245
include_ai = copier_data.get(AnswerKeys.AI, False)
246+
include_insights = copier_data.get(AnswerKeys.INSIGHTS, False)
231247
ai_backend = copier_data.get(AnswerKeys.AI_BACKEND, StorageBackends.MEMORY)
232248
database_engine = copier_data.get(
233249
AnswerKeys.DATABASE_ENGINE, StorageBackends.SQLITE
@@ -240,8 +256,11 @@ def generate_with_copier(
240256
# Type narrowing: ai_backend should always be a string, but narrow from Any
241257
ai_backend_str: str = str(ai_backend) if ai_backend else StorageBackends.MEMORY
242258

259+
is_insights_included: bool = include_insights is True
243260
ai_needs_migrations = is_ai_included and ai_backend_str != StorageBackends.MEMORY
244-
needs_migration_files = is_auth_included or ai_needs_migrations
261+
needs_migration_files = (
262+
is_auth_included or ai_needs_migrations or is_insights_included
263+
)
245264
# Only run migrations automatically for SQLite (file-based, no server needed)
246265
# PostgreSQL requires a running server, so skip auto-migration
247266
is_sqlite = database_engine == StorageBackends.SQLITE
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
Insights service bracket syntax parser.
3+
4+
Parses insights[sources...] syntax where values are data source names:
5+
- github: GitHub Traffic API + Stargazers API
6+
- pypi: PyPI/pepy.tech download stats
7+
- plausible: Plausible docs analytics
8+
- reddit: Reddit post tracking
9+
10+
Order doesn't matter. Defaults: github, pypi
11+
"""
12+
13+
from dataclasses import dataclass, field
14+
15+
# Valid source names
16+
SOURCES = {"github", "pypi", "plausible", "reddit"}
17+
18+
# Default sources when no brackets specified
19+
DEFAULT_SOURCES = ["github", "pypi"]
20+
21+
22+
@dataclass
23+
class InsightsServiceConfig:
24+
"""Parsed insights service configuration."""
25+
26+
sources: list[str] = field(default_factory=lambda: DEFAULT_SOURCES.copy())
27+
28+
29+
def parse_insights_service_config(service_string: str) -> InsightsServiceConfig:
30+
"""
31+
Parse insights[...] service string into config.
32+
33+
Args:
34+
service_string: Service specification like "insights", "insights[github]",
35+
or "insights[github,pypi,plausible,reddit]"
36+
37+
Returns:
38+
InsightsServiceConfig with selected sources
39+
40+
Raises:
41+
ValueError: If service string is invalid or has unknown values
42+
"""
43+
service_string = service_string.strip()
44+
45+
if not service_string.startswith("insights"):
46+
raise ValueError(
47+
f"Expected 'insights' service, got '{service_string}'. "
48+
"This parser only handles insights[...] syntax."
49+
)
50+
51+
# Plain "insights" with no brackets
52+
if service_string == "insights":
53+
return InsightsServiceConfig()
54+
55+
if "[" not in service_string:
56+
raise ValueError(
57+
f"Invalid service string '{service_string}'. "
58+
"Expected 'insights' or 'insights[sources]' format."
59+
)
60+
61+
if not service_string.endswith("]"):
62+
raise ValueError(
63+
f"Malformed brackets in '{service_string}'. Expected closing ']'."
64+
)
65+
66+
bracket_start = service_string.index("[")
67+
bracket_content = service_string[bracket_start + 1 : -1].strip()
68+
69+
# Empty brackets = defaults
70+
if not bracket_content:
71+
return InsightsServiceConfig()
72+
73+
# Split by comma and validate
74+
values = [v.strip().lower() for v in bracket_content.split(",") if v.strip()]
75+
76+
# Check for duplicates
77+
seen: set[str] = set()
78+
for value in values:
79+
if value in seen:
80+
raise ValueError(f"Duplicate source '{value}' in insights[...] syntax.")
81+
seen.add(value)
82+
83+
if value not in SOURCES:
84+
raise ValueError(
85+
f"Unknown source '{value}' in insights[...] syntax. "
86+
f"Valid sources: {', '.join(sorted(SOURCES))}."
87+
)
88+
89+
return InsightsServiceConfig(sources=values)
90+
91+
92+
def is_insights_service_with_options(service_string: str) -> bool:
93+
"""
94+
Check if a service string is an insights service with bracket options.
95+
96+
Returns True ONLY when explicit bracket syntax is used (insights[...]).
97+
Plain "insights" without brackets returns False.
98+
99+
Args:
100+
service_string: Service specification string
101+
102+
Returns:
103+
True if this is an insights[...] format string with explicit options
104+
"""
105+
return service_string.strip().startswith("insights[")

aegis/core/post_gen_tasks.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ def get_component_file_mapping() -> dict[str, list[str]]:
185185
"tests/api/test_voice_endpoints.py",
186186
"app/components/frontend/dashboard/modals/voice_settings_tab.py",
187187
],
188+
AnswerKeys.SERVICE_INSIGHTS: [
189+
"app/components/backend/api/insights",
190+
"app/services/insights",
191+
"app/cli/insights.py",
192+
"tests/services/test_insights_service.py",
193+
"tests/services/test_insights_collectors.py",
194+
"tests/api/test_insights_endpoints.py",
195+
# Frontend dashboard files
196+
"app/components/frontend/dashboard/cards/insights_card.py",
197+
"app/components/frontend/dashboard/modals/insights_modal.py",
198+
],
188199
}
189200

190201

@@ -559,6 +570,21 @@ def _rename_backend_files(suffix: str) -> set[str]:
559570
project_path, "app/components/frontend/dashboard/modals/comms_modal.py"
560571
)
561572

573+
# Remove insights service if not selected
574+
if not is_enabled(AnswerKeys.INSIGHTS):
575+
remove_dir(project_path, "app/components/backend/api/insights")
576+
remove_dir(project_path, "app/services/insights")
577+
remove_file(project_path, "app/cli/insights.py")
578+
remove_file(project_path, "tests/services/test_insights_service.py")
579+
remove_file(project_path, "tests/services/test_insights_collectors.py")
580+
remove_file(project_path, "tests/api/test_insights_endpoints.py")
581+
remove_file(
582+
project_path, "app/components/frontend/dashboard/cards/insights_card.py"
583+
)
584+
remove_file(
585+
project_path, "app/components/frontend/dashboard/modals/insights_modal.py"
586+
)
587+
562588
# Remove auth service dashboard files if not selected
563589
if not is_enabled(AnswerKeys.AUTH):
564590
remove_file(
@@ -569,23 +595,25 @@ def _rename_backend_files(suffix: str) -> set[str]:
569595
)
570596

571597
# Remove services_card.py only if NO services are enabled
572-
# ServicesCard shows all services (auth, AI, comms), so keep if ANY service is enabled
598+
# ServicesCard shows all services, so keep if ANY service is enabled
573599
if (
574600
not is_enabled(AnswerKeys.AUTH)
575601
and not is_enabled(AnswerKeys.AI)
576602
and not is_enabled(AnswerKeys.COMMS)
603+
and not is_enabled(AnswerKeys.INSIGHTS)
577604
):
578605
remove_file(
579606
project_path, "app/components/frontend/dashboard/cards/services_card.py"
580607
)
581608

582609
# Remove Alembic directory only if NO service needs migrations
583-
# Alembic is needed when: auth is enabled OR (AI is enabled AND backend is NOT memory)
610+
# Alembic is needed when: auth, insights, or (AI with non-memory backend)
584611
include_auth = is_enabled(AnswerKeys.AUTH)
585612
include_ai = is_enabled(AnswerKeys.AI)
613+
include_insights = is_enabled(AnswerKeys.INSIGHTS)
586614
ai_backend = context.get(AnswerKeys.AI_BACKEND, StorageBackends.MEMORY)
587615
ai_needs_migrations = include_ai and ai_backend != StorageBackends.MEMORY
588-
needs_migrations = include_auth or ai_needs_migrations
616+
needs_migrations = include_auth or ai_needs_migrations or include_insights
589617

590618
if not needs_migrations:
591619
remove_dir(project_path, "alembic")
@@ -632,6 +660,7 @@ def _render_jinja_template(src: Path, dst: Path, project_path: Path) -> None:
632660
"include_auth": True,
633661
"include_ai": True,
634662
"include_comms": True,
663+
"include_insights": True,
635664
# Component flags - check what exists in project
636665
"include_scheduler": (project_path / "app/components/scheduler").exists(),
637666
"include_worker": (project_path / "app/components/worker").exists(),

aegis/core/service_resolver.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
from .auth_service_parser import is_auth_service_with_options, parse_auth_service_config
1212
from .component_utils import extract_base_component_name, extract_base_service_name
1313
from .dependency_resolver import DependencyResolver
14+
from .insights_service_parser import (
15+
is_insights_service_with_options,
16+
parse_insights_service_config,
17+
)
1418
from .services import SERVICES, get_service_dependencies
1519

1620

@@ -126,6 +130,16 @@ def validate_services(services: list[str]) -> list[str]:
126130
except ValueError as e:
127131
errors.append(f"Invalid AI service syntax: {e}")
128132

133+
# Validate insights service bracket syntax if provided
134+
if (
135+
base_service == AnswerKeys.SERVICE_INSIGHTS
136+
and is_insights_service_with_options(service)
137+
):
138+
try:
139+
parse_insights_service_config(service)
140+
except ValueError as e:
141+
errors.append(f"Invalid insights service syntax: {e}")
142+
129143
spec = SERVICES[base_service]
130144

131145
# Check service conflicts

0 commit comments

Comments
 (0)