Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,9 @@ demo-*/
# Generated-project artifact if aegis init ever runs at repo root
/.copier-answers.yml
test-project/

# Planning docs (working notes, not published site content)
docs/plans/

# Editor workspace files
*.code-workspace
12 changes: 12 additions & 0 deletions aegis/commands/add_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,18 @@ def add_service_command(
if not migration_success:
brand.warn(t("add_service.migration_failed"))

# Seed the AI fixtures (LLM catalog + default agent) when the
# ai service just landed with a persistence backend; mirrors
# the init path so an added ai service starts with the same
# seeded registry a fresh project gets.
ai_added = any(
service_base_map[s] == AnswerKeys.SERVICE_AI for s in services_to_add
)
if migration_success and ai_added and ai_needs_migrations:
from ..core.post_gen_tasks import seed_ai_fixtures

seed_ai_fixtures(target_path)

brand.success(f"\n{t('add_service.success')}")

# Show project map with newly added services + auto-added components highlighted
Expand Down
260 changes: 260 additions & 0 deletions aegis/core/migration_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,242 @@ class ServiceMigrationSpec:
],
)

AGENTS_MIGRATION = ServiceMigrationSpec(
service_name="ai_agents",
description="AI agent registry tables (agents, tools, agent-tool links)",
tables=[
# Agent - the DB-driven agent definition. model_id is a plain
# indexed string, NOT an FK to large_language_model: the catalog
# is ETL-synced and rows churn, so agents stay decoupled from
# catalog lifecycle exactly like llm_usage. NULL model_id means
# "use the service's active model".
TableSpec(
name="agent",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("slug", "sa.String()", nullable=False),
ColumnSpec("name", "sa.String()", nullable=False),
ColumnSpec("description", "sa.String()", nullable=True),
ColumnSpec("category", "sa.String()", nullable=True),
ColumnSpec("model_id", "sa.String()", nullable=True),
ColumnSpec("system_prompt", "sa.String()", nullable=False),
ColumnSpec("temperature", "sa.Float()", nullable=False, default="0.7"),
ColumnSpec(
"max_tokens", "sa.Integer()", nullable=False, default="1000"
),
ColumnSpec("memory_modules", "sa.JSON()", nullable=False, default="[]"),
ColumnSpec(
"knowledge_base_ids", "sa.JSON()", nullable=False, default="[]"
),
ColumnSpec("is_active", "sa.Boolean()", nullable=False, default="True"),
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
],
indexes=[
IndexSpec("ix_agent_slug", ["slug"], unique=True),
IndexSpec("ix_agent_model_id", ["model_id"]),
],
),
# Tool - registry rows keyed by name into the Python tool registry.
TableSpec(
name="tool",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("name", "sa.String()", nullable=False),
ColumnSpec("description", "sa.String()", nullable=True),
ColumnSpec("is_active", "sa.Boolean()", nullable=False, default="True"),
],
indexes=[IndexSpec("ix_tool_name", ["name"], unique=True)],
),
# AgentTool - join rows are agent-owned; CASCADE both sides so
# deleting an agent or a tool cleans its links.
TableSpec(
name="agent_tool",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("agent_id", "sa.Integer()", nullable=False),
ColumnSpec("tool_id", "sa.Integer()", nullable=False),
],
indexes=[
IndexSpec("uq_agent_tool_pair", ["agent_id", "tool_id"], unique=True),
IndexSpec("ix_agent_tool_tool_id", ["tool_id"]),
],
foreign_keys=[
ForeignKeySpec(["agent_id"], "agent", ["id"], ondelete="CASCADE"),
ForeignKeySpec(["tool_id"], "tool", ["id"], ondelete="CASCADE"),
],
),
# MemoryModule - reusable prompt-context blocks agents opt into
# via Agent.memory_modules. Hybrid by design: a row may carry
# static prompt_content, a dynamic fetch_function, or both;
# assembly is column-driven (there is deliberately no "kind").
TableSpec(
name="memory_module",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("slug", "sa.String()", nullable=False),
ColumnSpec("name", "sa.String()", nullable=False),
ColumnSpec("description", "sa.String()", nullable=True),
ColumnSpec("category", "sa.String()", nullable=True),
ColumnSpec("prompt_content", "sa.String()", nullable=True),
ColumnSpec("fetch_function", "sa.String()", nullable=True),
ColumnSpec("context_key", "sa.String()", nullable=False),
ColumnSpec(
"supports_days_back",
"sa.Boolean()",
nullable=False,
default="False",
),
ColumnSpec("default_days_back", "sa.Integer()", nullable=True),
ColumnSpec("priority", "sa.Integer()", nullable=False, default="100"),
ColumnSpec(
"token_estimate", "sa.Integer()", nullable=False, default="0"
),
ColumnSpec("is_active", "sa.Boolean()", nullable=False, default="True"),
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
],
indexes=[IndexSpec("ix_memory_module_slug", ["slug"], unique=True)],
),
# AgentUserMemory - one JSON memory document per user, written by
# the built-in save_memory tool and injected (guarded) into chat
# context. Same gate as the registry, so it rides the same spec.
TableSpec(
name="agent_user_memory",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("user_id", "sa.String()", nullable=False),
ColumnSpec("memory", "sa.JSON()", nullable=False, default="{}"),
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
],
indexes=[
IndexSpec("ix_agent_user_memory_user_id", ["user_id"], unique=True),
],
),
],
)

KNOWLEDGE_MIGRATION = ServiceMigrationSpec(
service_name="ai_knowledge",
description="Knowledge base metadata (agent-scoped RAG collections)",
tables=[
# A knowledge base maps 1:1 onto a Chroma collection by name;
# agents scope retrieval via Agent.knowledge_base_ids holding
# these names.
TableSpec(
name="knowledge_base",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("name", "sa.String()", nullable=False),
ColumnSpec("description", "sa.String()", nullable=True),
ColumnSpec("category", "sa.String()", nullable=True),
ColumnSpec("meta_data", "sa.JSON()", nullable=False, default="{}"),
ColumnSpec("is_active", "sa.Boolean()", nullable=False, default="True"),
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
],
indexes=[IndexSpec("ix_knowledge_base_name", ["name"], unique=True)],
),
# A source document within a KB; ``loaded`` gates ingestion state
# and ``chunking_strategy`` picks the chunker preset per source.
TableSpec(
name="knowledge_base_source",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("knowledge_base_id", "sa.Integer()", nullable=False),
ColumnSpec("name", "sa.String()", nullable=False),
ColumnSpec("file_path", "sa.String()", nullable=True),
ColumnSpec("content_type", "sa.String()", nullable=True),
ColumnSpec(
"chunking_strategy",
"sa.String()",
nullable=False,
default="'paragraph'",
),
ColumnSpec("loaded", "sa.Boolean()", nullable=False, default="False"),
ColumnSpec("meta_data", "sa.JSON()", nullable=False, default="{}"),
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
],
indexes=[
IndexSpec(
"ix_knowledge_base_source_knowledge_base_id",
["knowledge_base_id"],
),
],
foreign_keys=[
ForeignKeySpec(
["knowledge_base_id"],
"knowledge_base",
["id"],
ondelete="CASCADE",
),
],
check_constraints=[
CheckConstraintSpec(
name="ck_knowledge_base_source_chunking_strategy",
sqltext=(
"chunking_strategy IN "
"('paragraph', 'sentence', 'fixed', 'code')"
),
),
],
),
],
)

SENTIMENT_MIGRATION = ServiceMigrationSpec(
service_name="ai_sentiment",
description="Conversation sentiment analysis results",
tables=[
# One verdict per conversation (unique conversation_id enforces
# score-once); rows die with their conversation via CASCADE.
TableSpec(
name="sentiment_analysis",
columns=[
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
ColumnSpec("conversation_id", "sa.String()", nullable=False),
ColumnSpec("overall_sentiment", "sa.String()", nullable=False),
ColumnSpec("overall_score", "sa.Float()", nullable=False),
ColumnSpec("assistant_performance", "sa.String()", nullable=False),
ColumnSpec("issues", "sa.JSON()", nullable=False, default="[]"),
ColumnSpec("summary", "sa.String()", nullable=True),
ColumnSpec("model_id", "sa.String()", nullable=True),
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
],
indexes=[
IndexSpec(
"ix_sentiment_analysis_conversation_id",
["conversation_id"],
unique=True,
),
],
foreign_keys=[
ForeignKeySpec(
["conversation_id"],
"conversation",
["id"],
ondelete="CASCADE",
),
],
check_constraints=[
CheckConstraintSpec(
name="ck_sentiment_analysis_overall_sentiment",
sqltext=(
"overall_sentiment IN "
"('positive', 'neutral', 'negative', 'frustrated')"
),
),
CheckConstraintSpec(
name="ck_sentiment_analysis_assistant_performance",
sqltext=("assistant_performance IN ('good', 'acceptable', 'poor')"),
),
],
),
],
)

AUTH_TOKENS_MIGRATION = ServiceMigrationSpec(
service_name="auth_tokens",
description="Auth token tables (password reset, email verification, refresh)",
Expand Down Expand Up @@ -4059,6 +4295,30 @@ def get_services_needing_migrations(context: dict[str, Any]) -> list[str]:
) and ai_backend != StorageBackends.MEMORY:
services.append("ai")

# AI agent registry - rides the exact same gate as the ai catalog
# tables: agents are the service's default architecture, and the DB
# config source exists whenever there is a persistence backend.
if (
include_ai == "yes" or include_ai is True
) and ai_backend != StorageBackends.MEMORY:
services.append("ai_agents")

# KB metadata (only with AI persistence AND the rag flag)
ai_rag = context.get(AnswerKeys.AI_RAG)
if (
(include_ai == "yes" or include_ai is True)
and ai_backend != StorageBackends.MEMORY
and (ai_rag == "yes" or ai_rag is True)
):
services.append("ai_knowledge")

# Sentiment analysis (with AI persistence; the conversation table is
# its FK target). The job that populates it is settings-gated off.
if (
include_ai == "yes" or include_ai is True
) and ai_backend != StorageBackends.MEMORY:
services.append("ai_sentiment")

# AI Voice service (only if AI with persistence and voice enabled)
ai_voice = context.get(AnswerKeys.AI_VOICE)
if (
Expand Down
52 changes: 45 additions & 7 deletions aegis/core/post_gen_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,43 @@ def is_enabled(key: str) -> bool:
# Remove LLM tracking models (only needed with persistence)
# Keep app/services/ai/models/__init__.py - contains core types (AIProvider, ProviderConfig)
remove_dir(project_path, "app/services/ai/models/llm")
# Agent registry models ride the same persistence gate (the memory
# backend resolves the default agent from code, not DB rows). The
# tools.py registry itself stays: it is DB-free and the code-config
# path still resolves tools through it.
remove_dir(project_path, "app/services/ai/models/agents")
remove_file(project_path, "tests/services/ai/test_agent_models.py")
# Per-user memory needs the agent_user_memory table + async DB.
remove_file(project_path, "app/services/ai/user_memory.py")
remove_file(project_path, "tests/services/ai/test_user_memory.py")
# Memory modules are DB rows (the memory_module table).
remove_file(project_path, "app/services/ai/memory_modules.py")
remove_file(project_path, "tests/services/ai/test_memory_modules.py")
# Reference fetchers query the conversation tables (removed above).
# The fetcher registry itself (fetchers.py) stays: it is DB-free.
remove_file(project_path, "app/services/ai/builtin_fetchers.py")
remove_file(project_path, "tests/services/ai/test_builtin_fetchers.py")
# Module context assembly reads memory_module rows.
remove_file(project_path, "app/services/ai/module_context.py")
remove_file(project_path, "tests/services/ai/test_module_context.py")
# Sentiment scoring reads/writes conversation + sentiment tables.
remove_file(project_path, "app/services/ai/sentiment.py")
remove_file(project_path, "app/services/ai/models/sentiment.py")
remove_file(project_path, "tests/services/ai/test_sentiment.py")
# Agent registry CLI inspects DB rows.
remove_file(project_path, "app/cli/agents.py")
remove_file(project_path, "tests/cli/test_agents_cli.py")
# Agent registry admin surface (API service + dashboard tab).
remove_file(project_path, "app/services/ai/agent_registry.py")
remove_file(
project_path,
"app/components/frontend/dashboard/modals/agents_tab.py",
)
remove_file(project_path, "tests/services/ai/test_agent_registry.py")
# KB metadata models are DB-backed; the rag service itself stays
# (Chroma is file-based and works without a database).
remove_file(project_path, "app/services/rag/models/knowledge.py")
remove_file(project_path, "tests/services/rag/test_knowledge_models.py")
# chat_kit + usage_recording ledger the LLM catalog + conversation
# tables removed above, so they only work with a persistent backend.
remove_dir(project_path, "app/services/ai/chat_kit")
Expand Down Expand Up @@ -872,12 +909,13 @@ class DependencyInstallationError(Exception):
pass


def seed_llm_fixtures(project_path: Path, python_version: str | None = None) -> bool:
def seed_ai_fixtures(project_path: Path, python_version: str | None = None) -> bool:
"""
Seed LLM fixtures (vendors, models, pricing) into the database.
Seed the AI fixtures into the database.

This runs the load_all_llm_fixtures function from the generated project
to populate the database with initial LLM data.
Runs the generated project's ``load_all_ai_fixtures``: the LLM catalog
(vendors, models, deployments, pricing) plus the agent registry's
default ``assistant`` row.

Args:
project_path: Path to the project directory
Expand All @@ -904,8 +942,8 @@ def seed_llm_fixtures(project_path: Path, python_version: str | None = None) ->
"python",
"-c",
"from app.core.db import SessionLocal; "
"from app.services.ai.fixtures import load_all_llm_fixtures; "
"load_all_llm_fixtures(SessionLocal())",
"from app.services.ai.fixtures import load_all_ai_fixtures; "
"load_all_ai_fixtures(SessionLocal())",
]
)

Expand Down Expand Up @@ -1082,7 +1120,7 @@ def run_post_generation_tasks(
slug = project_slug or project_path.name

# Always seed static fixtures first as baseline data
fixtures_loaded = seed_llm_fixtures(project_path, python_version)
fixtures_loaded = seed_ai_fixtures(project_path, python_version)

if skip_llm_sync:
brand.accent(t("postgen.llm_sync_skipped"))
Expand Down
Loading