Files
smart-org/apps/api/app/seed.py
T
mjalmoudy94 3d1e98f4fb افزودن ارائه‌دهندهٔ agy (Google Antigravity) با لاگین headless و پول چرخشی اکانت
- Adapter اکانت‌محور با چرخش round-robin و بنچ‌شدن خودکار روی ۴۲۹/۴۰۱
- ورود ادمین از طریق URL بازگشتی گوگل (PKCE) و ذخیرهٔ رمزنگاری‌شدهٔ refresh token
- صفحهٔ مدیریت اکانت‌های agy در پنل ادمین به همراه تست و فعال/غیرفعال‌سازی
- مهاجرت Alembic b1c2d3e4f5a6 برای ستون‌های اکانت agy
- seed همیشه کاتالوگ agy را می‌سازد و فقط با AI_DEFAULT_PROVIDER=agy مسیریابی می‌شود
2026-10-13 08:20:27 +03:30

492 lines
20 KiB
Python

"""Idempotent development seed.
Run with ``pnpm db:seed``. Creates a platform owner, a demo user, a sample
organization that both of them own, the system agent templates, routing
policies, rate cards and a mock provider with two models. The agy (Google
Antigravity) provider and its model catalog are seeded too; agy models become
routable only when ``AI_DEFAULT_PROVIDER=agy``.
Safe to run repeatedly: every step checks for existing rows first.
The two accounts it prints at the end are meant to be usable immediately —
``tests/test_seed.py`` asserts exactly that, because a seed whose credentials the
login endpoint rejects is worse than no seed at all.
"""
from __future__ import annotations
import asyncio
import logging
from sqlalchemy import select
from app.core.config import settings
from app.core.database import Base, SessionFactory, engine
from app.core.security import hash_password
from app.domain.ai_control.models import (
AiModel,
Capability,
Provider,
ProviderType,
RateCard,
RateUnit,
RoutingMode,
RoutingPolicy,
)
from app.domain.identity.models import Membership, MembershipStatus, Organization, Role, User
from app.domain.sandbox.models import SandboxProviderKind, SandboxTemplate
from app.domain.workforce.models import AgentTemplate, RoleKey
from app.infrastructure.db.models import (
AgentTemplateRow,
AiModelRow,
AiProviderRow,
CreditWalletRow,
RateCardRow,
RoutingPolicyRow,
SandboxTemplateRow,
UserRow,
)
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
logger = logging.getLogger("agy.seed")
#: ``example.com`` is a documentation domain and passes email validation.
#: Do NOT use ``.local``/``.test``/``.invalid``: those are special-use TLDs and
#: ``email-validator`` rejects them, so the seed would create an account that the
#: API then refuses to log in — the user exists but is unreachable.
PLATFORM_OWNER_EMAIL = "owner@example.com"
PLATFORM_OWNER_PASSWORD = "owner12345"
DEMO_EMAIL = "demo@example.com"
DEMO_PASSWORD = "demo12345"
#: Stable identifiers for the sample organization. Both must stay constant for
#: the seed to be idempotent — see ``_ensure_org``.
DEMO_ORG_NAME = "سازمان نمونه"
DEMO_ORG_SLUG = "sample-org"
#: The agy (Google Antigravity) model catalog. ``internal_name`` is the model id
#: the Antigravity backend expects; the display name is admin-only. Seeded for
#: every deployment, activated for routing only when agy is the default
#: provider — see the seed step that consumes this table.
AGY_MODELS: list[tuple[str, str, list[Capability]]] = [
(
"gemini-3-flash",
"Gemini 3 Flash",
[Capability.TEXT, Capability.CODE, Capability.LONG_CONTEXT],
),
(
"gemini-3-pro-high",
"Gemini 3 Pro",
[
Capability.TEXT, Capability.REASONING, Capability.CODE,
Capability.VISION, Capability.LONG_CONTEXT,
],
),
(
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
[Capability.TEXT, Capability.CODE, Capability.TOOLS],
),
(
"claude-sonnet-4-5-thinking",
"Claude Sonnet 4.5 (Thinking)",
[Capability.TEXT, Capability.CODE, Capability.REASONING],
),
(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 (Thinking)",
[Capability.TEXT, Capability.REASONING, Capability.CODE, Capability.LONG_CONTEXT],
),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", [Capability.TEXT, Capability.CODE]),
]
AGENT_TEMPLATES: list[tuple[str, RoleKey, str, list[str]]] = [
(
"مدیر سازمان", RoleKey.MANAGER,
"کارها را دسته‌بندی می‌کند و بین کارمندها تقسیم می‌کند.",
["knowledge.search", "artifact.create"],
),
(
"تحلیل‌گر و معمار", RoleKey.PLANNER,
"نیازمندی‌ها را تحلیل و پلن اجرایی می‌سازد.",
["knowledge.search"],
),
(
"برنامه‌نویس", RoleKey.DEVELOPER,
"کد می‌نویسد و در sandbox اجرا و تست می‌کند.",
["git.ensure_repo", "git.create_branch", "sandbox.run_command",
"sandbox.read_file", "sandbox.write_file"],
),
(
"بازبین کد", RoleKey.REVIEWER,
"PRها را بازبینی می‌کند و بازخورد می‌دهد.",
["git.open_pr"],
),
(
"تستر", RoleKey.TESTER,
"تست می‌نویسد و اجرا می‌کند.",
["sandbox.run_command", "sandbox.write_file"],
),
(
"محقق", RoleKey.RESEARCHER,
"منابع را جمع و خلاصه می‌کند.",
["knowledge.search", "artifact.create"],
),
(
"تولیدکننده محتوا", RoleKey.CONTENT_CREATOR,
"متن و محتوای سازمانی تولید می‌کند.",
["artifact.create"],
),
(
"پشتیبان مشتری", RoleKey.SUPPORT,
"به پرسش‌های مشتری پاسخ می‌دهد.",
["knowledge.search"],
),
]
ROUTING_POLICIES: list[tuple[str, RoutingMode, bool, str]] = [
("سریع و ارزان", RoutingMode.CHEAP, True, "برای کارهای روزمره و پرتکرار."),
("متعادل", RoutingMode.BALANCED, False, "پیش‌فرض متعادل بین هزینه و کیفیت."),
("استدلال عمیق", RoutingMode.REASONING, False, "برای تحلیل و کارهای پیچیده."),
("کدنویسی", RoutingMode.CODING, False, "بهینه برای تولید و اصلاح کد."),
("چندرسانه‌ای", RoutingMode.VISION, False, "برای ورودی تصویری."),
]
RATE_CARDS: list[tuple[RateUnit, float, float]] = [
(RateUnit.MODEL_INPUT_TOKENS, 0.0000004, 0.0000002),
(RateUnit.MODEL_OUTPUT_TOKENS, 0.0000016, 0.0000008),
(RateUnit.SANDBOX_CPU_SECOND, 0.00002, 0.00001),
(RateUnit.SANDBOX_MEMORY_GB_SECOND, 0.000004, 0.000002),
(RateUnit.STORAGE_GB_DAY, 0.0004, 0.0002),
(RateUnit.TOOL_CALL, 0.0005, 0.0001),
(RateUnit.PREMIUM_EMPLOYEE_RUN, 0.02, 0.008),
]
#: (name, image, description, cpu, memory_mb, disk_mb)
SANDBOX_TEMPLATES: list[tuple[str, str, str, float, int, int]] = [
(
"python-3.13", "python:3.13-slim",
"محیط پایتون برای اجرای تست و اسکریپت.", 1.0, 1024, 2048,
),
(
"node-22", "node:22-slim",
"محیط Node برای build و اجرای پروژه‌های فرانت‌اند.", 1.0, 2048, 4096,
),
(
"ubuntu-base", "ubuntu:24.04",
"محیط عمومی لینوکس برای ابزارهای خط فرمان.", 1.0, 1024, 2048,
),
]
async def _ensure_user(
session, *, email: str, password: str, name: str, platform_owner: bool
) -> User:
existing = (
await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one_or_none()
if existing:
return User(
id=existing.id, email=existing.email, password_hash=existing.password_hash,
name=existing.name, is_platform_owner=existing.is_platform_owner,
)
from app.infrastructure.repositories.identity import UserRepository
user = await UserRepository(session).create(
User(
email=email,
password_hash=hash_password(password),
name=name,
is_platform_owner=platform_owner,
)
)
logger.info("created user %s", email)
return user
async def _ensure_org(session, *, members: list[User]) -> Organization:
"""Get or create the sample organization, with every given user in it.
Idempotent in all three parts: the organization, its memberships and its
credit wallet. A developer who runs ``pnpm db:seed`` twice must end up with
exactly one organization and no duplicate rows.
"""
from app.infrastructure.repositories.identity import (
MembershipRepository,
OrganizationRepository,
)
orgs = OrganizationRepository(session)
memberships = MembershipRepository(session)
# A *fixed* slug, not `slugify(name)`. The name is Persian, and `slugify`
# falls back to a random `x<hex>` when it cannot transliterate — so deriving
# the slug here would produce a different value on every run, `get_by_slug`
# would always miss, and re-seeding would silently create a duplicate
# organization (and a duplicate wallet) each time.
org = await orgs.get_by_slug(DEMO_ORG_SLUG)
if org is None:
org = await orgs.create(
Organization(name=DEMO_ORG_NAME, slug=DEMO_ORG_SLUG, owner_user_id=members[0].id)
)
logger.info("created organization %s", org.slug)
# Every listed user becomes an owner of the sample org. Without this the
# demo account can log in but belongs to no organization, so every screen
# renders empty and the account looks broken.
for user in members:
if await memberships.get(org.id, user.id) is None:
await memberships.add(
Membership(
organization_id=org.id, user_id=user.id,
role=Role.OWNER, status=MembershipStatus.ACTIVE,
)
)
logger.info("added %s to %s as owner", user.email, org.slug)
wallet = (
await session.execute(
select(CreditWalletRow).where(CreditWalletRow.organization_id == org.id)
)
).scalars().first()
if wallet is None:
session.add(CreditWalletRow(organization_id=org.id, balance=25.0, currency="USD"))
return org
async def seed() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SessionFactory() as session:
owner = await _ensure_user(
session, email=PLATFORM_OWNER_EMAIL, password=PLATFORM_OWNER_PASSWORD,
name="مالک سامانه", platform_owner=True,
)
demo = await _ensure_user(
session, email=DEMO_EMAIL, password=DEMO_PASSWORD,
name="کاربر نمونه", platform_owner=False,
)
# Both accounts join the sample organization, so a developer who logs in
# as either one lands on a populated dashboard rather than an empty one.
await _ensure_org(session, members=[owner, demo])
if not (await session.execute(select(AiProviderRow))).scalars().first():
provider = Provider(name="mock", type=ProviderType.MOCK, base_url="")
session.add(
AiProviderRow(
id=provider.id, name=provider.name, type=provider.type.value,
base_url=provider.base_url, is_active=True,
)
)
await session.flush()
for internal, display, caps in (
("mock-fast", "Mock Fast", [Capability.TEXT]),
(
"mock-reasoning",
"Mock Reasoning",
[Capability.TEXT, Capability.REASONING, Capability.CODE],
),
):
model = AiModel(
provider_id=provider.id,
internal_name=internal,
display_name_for_admin=display,
capabilities=[str(c) for c in caps],
input_price_per_1m=0.3,
output_price_per_1m=1.2,
)
session.add(
AiModelRow(
id=model.id, provider_id=model.provider_id,
internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=True,
)
)
logger.info("created mock provider + 2 models")
# The agy catalog (Google Antigravity). It is seeded for every
# deployment — the admin console needs the provider and its models to
# exist before any account is logged in. Routing activation is
# reconciled on *every* seed run below, not only at insert time, so
# flipping AI_DEFAULT_PROVIDER and re-seeding reshapes routing without
# a fresh database.
agy_is_default = settings.ai_default_provider.strip().lower() == "agy"
agy_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "agy")
)
).scalars().first()
if agy_provider_row is None:
agy_provider = Provider(name="agy", type=ProviderType.AGY, base_url="")
agy_provider_row = AiProviderRow(
id=agy_provider.id, name=agy_provider.name,
type=agy_provider.type.value, base_url="", is_active=True,
)
session.add(agy_provider_row)
await session.flush()
for internal, display, caps in AGY_MODELS:
existing = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id,
AiModelRow.internal_name == internal,
)
)
).scalars().first()
if existing is not None:
continue
model = AiModel(
provider_id=agy_provider_row.id,
internal_name=internal,
display_name_for_admin=display,
capabilities=[str(c) for c in caps],
# agy is billed through the Google AI subscription, so the
# internal token cost estimate is zero; the operator sets the
# customer-facing price on the rate card.
input_price_per_1m=0.0,
output_price_per_1m=0.0,
)
session.add(
AiModelRow(
id=model.id, provider_id=model.provider_id,
internal_name=model.internal_name,
display_name_for_admin=model.display_name_for_admin,
capabilities=model.capabilities,
input_price_per_1m=model.input_price_per_1m,
output_price_per_1m=model.output_price_per_1m,
is_active=False,
)
)
# Reconcile routing with the configured default on every run: agy models
# are routable only when agy is the default, and the mock catalog must
# not win when it is not — a model without accounts fails at call time.
agy_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == agy_provider_row.id
)
)
).scalars()
for row in agy_model_rows:
row.is_active = agy_is_default
if agy_is_default:
mock_provider_row = (
await session.execute(
select(AiProviderRow).where(AiProviderRow.name == "mock")
)
).scalars().first()
if mock_provider_row is not None:
mock_model_rows = (
await session.execute(
select(AiModelRow).where(
AiModelRow.provider_id == mock_provider_row.id
)
)
).scalars()
for row in mock_model_rows:
row.is_active = False
logger.info(
"agy is the default provider; agy catalog activated "
"(accounts are added from the admin console)"
)
if not (await session.execute(select(AgentTemplateRow))).scalars().first():
for name, role_key, description, tools in AGENT_TEMPLATES:
template = AgentTemplate(
name=name, role_key=role_key, description=description,
default_prompt=f"تو {name} هستی. وظیفه‌ات: {description}",
default_tools_json=tools,
)
session.add(
AgentTemplateRow(
id=template.id, name=template.name, role_key=template.role_key.value,
description=template.description, default_prompt=template.default_prompt,
default_tools_json=template.default_tools_json, is_system=True,
)
)
logger.info("created %d agent templates", len(AGENT_TEMPLATES))
if not (await session.execute(select(RoutingPolicyRow))).scalars().first():
for name, mode, is_default, description in ROUTING_POLICIES:
policy = RoutingPolicy(
name=name, mode=mode, description=description, is_default=is_default
)
session.add(
RoutingPolicyRow(
id=policy.id, name=policy.name, description=policy.description,
mode=policy.mode.value, is_default=policy.is_default, is_active=True,
)
)
logger.info("created %d routing policies", len(ROUTING_POLICIES))
if not (await session.execute(select(RateCardRow))).scalars().first():
for unit, customer_price, internal_cost in RATE_CARDS:
card = RateCard(
name=unit.value.replace("_", " "),
unit=unit,
customer_price=customer_price,
internal_cost_estimate=internal_cost,
)
session.add(
RateCardRow(
id=card.id, name=card.name, unit=card.unit.value,
customer_price=card.customer_price,
internal_cost_estimate=card.internal_cost_estimate, is_active=True,
)
)
logger.info("created %d rate cards", len(RATE_CARDS))
if not (await session.execute(select(SandboxTemplateRow))).scalars().first():
for name, image, description, cpu, memory_mb, disk_mb in SANDBOX_TEMPLATES:
# Named distinctly from the agent-template loop above so the two
# entities cannot be confused.
sandbox_template = SandboxTemplate(
name=name, provider=SandboxProviderKind.MOCK, image=image,
description=description, default_cpu=cpu,
default_memory_mb=memory_mb, default_disk_mb=disk_mb,
)
session.add(
SandboxTemplateRow(
id=sandbox_template.id, name=sandbox_template.name,
provider=str(sandbox_template.provider),
image=sandbox_template.image,
description=sandbox_template.description,
default_cpu=sandbox_template.default_cpu,
default_memory_mb=sandbox_template.default_memory_mb,
default_disk_mb=sandbox_template.default_disk_mb,
is_system=True,
)
)
logger.info("created %d sandbox templates", len(SANDBOX_TEMPLATES))
await session.commit()
logger.info("seed complete")
logger.info("platform owner: %s / %s", PLATFORM_OWNER_EMAIL, PLATFORM_OWNER_PASSWORD)
logger.info("demo user: %s / %s", DEMO_EMAIL, DEMO_PASSWORD)
if __name__ == "__main__":
try:
asyncio.run(seed())
except Exception as exc:
# A failed seed is almost always a first-run problem — the very moment
# there is nobody watching a terminal. Record it before exiting non-zero.
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
asyncio.run(
record_error(
kind="seed_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.STARTUP,
severity=ErrorSeverity.CRITICAL,
)
)
raise