-
Notifications
You must be signed in to change notification settings - Fork 584
feat: Talent Market + per-user onboarding + tenant default model #471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
076f9e3
90e2e73
cb98384
7660ef8
70e5cd2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """Add bootstrap_content + capability_bullets to agent templates. | ||
|
|
||
| Revision ID: add_agent_bootstrap_fields | ||
| Revises: increase_api_key_length | ||
| Create Date: 2026-04-23 | ||
|
|
||
| Supports the Talent Market (capability_bullets fuel the template cards) and | ||
| the per-user onboarding ritual (bootstrap_content is the founder-facing | ||
| system prompt). The per-agent Agent.bootstrapped flag that earlier drafts | ||
| carried has been dropped in favour of the per-user agent_user_onboardings | ||
| junction table — see the add_agent_user_onboardings migration. | ||
| """ | ||
| from typing import Sequence, Union | ||
|
|
||
| from alembic import op | ||
|
|
||
|
|
||
| revision: str = 'add_agent_bootstrap_fields' | ||
| down_revision: Union[str, None] = 'increase_api_key_length' | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute("ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS capability_bullets JSON DEFAULT '[]'::json") | ||
| op.execute("ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS bootstrap_content TEXT") | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.execute("ALTER TABLE agent_templates DROP COLUMN IF EXISTS bootstrap_content") | ||
| op.execute("ALTER TABLE agent_templates DROP COLUMN IF EXISTS capability_bullets") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| """Per-(user, agent) onboarding junction table + drop legacy bootstrapped flag. | ||
|
|
||
| Revision ID: add_agent_user_onboardings | ||
| Revises: add_tenant_default_model | ||
| Create Date: 2026-04-24 | ||
|
|
||
| A row in agent_user_onboardings records that a user has been onboarded to a | ||
| specific agent. Its presence is the authoritative signal that onboarding | ||
| should NOT fire again for that pair — regardless of whether the user | ||
| actually finished the introduction flow. | ||
|
|
||
| Backfill: every (agent_id, user_id) pair that has any historical chat message | ||
| is inserted with onboarded_at = the earliest message. Existing users thus | ||
| never get retroactively re-onboarded. | ||
|
|
||
| Also drops the short-lived Agent.bootstrapped column that an earlier draft | ||
| of this feature introduced — the per-user model replaces it entirely. The | ||
| drop is idempotent so fresh installs (which no longer add the column in | ||
| add_agent_bootstrap_fields) aren't affected. | ||
| """ | ||
| from typing import Sequence, Union | ||
|
|
||
| from alembic import op | ||
|
|
||
|
|
||
| revision: str = 'add_agent_user_onboardings' | ||
| down_revision: Union[str, None] = 'add_tenant_default_model' | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.execute(""" | ||
| CREATE TABLE IF NOT EXISTS agent_user_onboardings ( | ||
| agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, | ||
| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| onboarded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | ||
| PRIMARY KEY (agent_id, user_id) | ||
| ) | ||
| """) | ||
|
|
||
| # Backfill from chat history: any pair that has ever exchanged messages is | ||
| # considered already onboarded — don't re-greet established relationships. | ||
| op.execute(""" | ||
| INSERT INTO agent_user_onboardings (agent_id, user_id, onboarded_at) | ||
| SELECT agent_id, user_id, MIN(created_at) | ||
| FROM chat_messages | ||
| WHERE agent_id IS NOT NULL AND user_id IS NOT NULL | ||
| GROUP BY agent_id, user_id | ||
| ON CONFLICT DO NOTHING | ||
| """) | ||
|
|
||
| # Clean up the abandoned per-agent flag from the previous design iteration. | ||
| op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS bootstrapped") | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.execute("DROP TABLE IF EXISTS agent_user_onboardings") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| """Add Tenant.default_model_id + backfill per-tenant to earliest enabled model. | ||
|
|
||
| Revision ID: add_tenant_default_model | ||
| Revises: add_agent_bootstrap_fields | ||
| Create Date: 2026-04-23 | ||
|
|
||
| Each tenant gets a default_model_id pointing at its first enabled LLM model | ||
| (by created_at ascending). Tenants with no enabled models stay NULL; the admin | ||
| picks one when they finally add a model (handled at the API layer). | ||
| """ | ||
| from typing import Sequence, Union | ||
|
|
||
| from alembic import op | ||
|
|
||
|
|
||
| revision: str = 'add_tenant_default_model' | ||
| down_revision: Union[str, None] = 'add_agent_bootstrap_fields' | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| # Add the nullable FK column. ON DELETE SET NULL — if a model is deleted, | ||
| # tenants that pointed at it revert to "no default." | ||
| op.execute(""" | ||
| ALTER TABLE tenants | ||
| ADD COLUMN IF NOT EXISTS default_model_id UUID | ||
| REFERENCES llm_models(id) ON DELETE SET NULL | ||
| """) | ||
|
|
||
| # Backfill: for each tenant, pick its earliest-created enabled model. | ||
| op.execute(""" | ||
| UPDATE tenants t | ||
| SET default_model_id = m.id | ||
| FROM ( | ||
| SELECT DISTINCT ON (tenant_id) tenant_id, id | ||
| FROM llm_models | ||
| WHERE enabled = TRUE AND tenant_id IS NOT NULL | ||
| ORDER BY tenant_id, created_at ASC | ||
| ) m | ||
| WHERE t.id = m.tenant_id AND t.default_model_id IS NULL | ||
| """) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS default_model_id") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -176,9 +176,45 @@ async def add_llm_model( | |
| ) | ||
| db.add(model) | ||
| await db.flush() | ||
|
|
||
| # First enabled model for a tenant becomes that tenant's default. | ||
| # Admins can later reassign via PATCH /llm-models/{id}/set-default. | ||
| if model.tenant_id and model.enabled: | ||
| from app.models.tenant import Tenant | ||
| t_result = await db.execute(select(Tenant).where(Tenant.id == model.tenant_id)) | ||
| tenant = t_result.scalar_one_or_none() | ||
| if tenant and tenant.default_model_id is None: | ||
| tenant.default_model_id = model.id | ||
|
|
||
| return LLMModelOut.model_validate(model) | ||
|
|
||
|
|
||
| @router.post("/llm-models/{model_id}/set-default", status_code=status.HTTP_204_NO_CONTENT) | ||
| async def set_default_llm_model( | ||
| model_id: uuid.UUID, | ||
| current_user: User = Depends(get_current_admin), | ||
| db: AsyncSession = Depends(get_db), | ||
| ): | ||
| """Mark this model as the tenant's default for new agents.""" | ||
| result = await db.execute(select(LLMModel).where(LLMModel.id == model_id)) | ||
| model = result.scalar_one_or_none() | ||
|
Comment on lines
+199
to
+200
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| if not model: | ||
| raise HTTPException(status_code=404, detail="Model not found") | ||
| if not model.tenant_id: | ||
| raise HTTPException(status_code=400, detail="Model is not tenant-scoped") | ||
| if not model.enabled: | ||
| raise HTTPException(status_code=400, detail="Model is disabled") | ||
|
|
||
| from app.models.tenant import Tenant | ||
| t_result = await db.execute(select(Tenant).where(Tenant.id == model.tenant_id)) | ||
| tenant = t_result.scalar_one_or_none() | ||
| if not tenant: | ||
| raise HTTPException(status_code=404, detail="Tenant not found") | ||
|
|
||
| tenant.default_model_id = model.id | ||
| await db.commit() | ||
|
|
||
|
|
||
| @router.delete("/llm-models/{model_id}", status_code=status.HTTP_204_NO_CONTENT) | ||
| async def remove_llm_model( | ||
| model_id: uuid.UUID, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agent creation now blindly applies
tenant.default_model_idwhen the caller omitsprimary_model_id, but defaults are not cleared when a model is later disabled. In that state, newly created agents inherit a disabled model and then fail chat model resolution (the websocket path drops disabled primaries), producing immediately unusable agents until manually reconfigured.Useful? React with 👍 / 👎.