diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index a0ffed7..a502c7e 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -101,6 +101,22 @@ def build_llm_providers() -> dict[str, LLMProvider]: return providers +def configured_llm_providers() -> list[dict]: + """Configured LLM providers as {name, model} — for the AI admin view (no + secrets). Mirrors build_llm_providers() without constructing clients.""" + s = get_settings() + out: list[dict] = [] + if s.anthropic_api_key: + out.append({"name": "anthropic", "model": s.anthropic_model}) + if s.openai_api_key: + out.append({"name": "openai", "model": s.openai_model}) + if s.xai_api_key: + out.append({"name": "xai", "model": s.xai_model}) + if s.ollama_enabled: + out.append({"name": "ollama", "model": s.ollama_model}) + return out + + def get_llm_provider(name: str | None = None) -> LLMProvider: """The named LLM provider, or the configured default, or Null if unconfigured.""" providers = build_llm_providers() diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 626f707..57c094f 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter from app.api.v1 import ( + ai, auth, citations, cleanup, @@ -36,3 +37,4 @@ api_router.include_router(cleanup.router) api_router.include_router(public.router) api_router.include_router(members.router) api_router.include_router(proposals.router) +api_router.include_router(ai.router) diff --git a/backend/app/api/v1/ai.py b/backend/app/api/v1/ai.py new file mode 100644 index 0000000..9f07317 --- /dev/null +++ b/backend/app/api/v1/ai.py @@ -0,0 +1,34 @@ +"""Per-tree AI model policy — owner-only admin view.""" + +import uuid + +from fastapi import APIRouter + +from app.api.deps import CurrentUser, SessionDep +from app.schemas.ai_policy import TreeAiPolicyRead, TreeAiPolicyUpdate +from app.services import ai_policy_service, tree_service + +router = APIRouter(prefix="/trees", tags=["ai"]) + + +@router.get("/{tree_id}/ai", response_model=TreeAiPolicyRead) +async def get_ai_policy( + tree_id: uuid.UUID, session: SessionDep, current: CurrentUser +) -> TreeAiPolicyRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + return TreeAiPolicyRead(**await ai_policy_service.get_policy(session, actor=current, tree=tree)) + + +@router.patch("/{tree_id}/ai", response_model=TreeAiPolicyRead) +async def update_ai_policy( + tree_id: uuid.UUID, data: TreeAiPolicyUpdate, session: SessionDep, current: CurrentUser +) -> TreeAiPolicyRead: + tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) + policy = await ai_policy_service.update_policy( + session, + actor=current, + tree=tree, + member_provider=data.member_provider, + recommender_provider=data.recommender_provider, + ) + return TreeAiPolicyRead(**policy) diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 55d6a77..ac546fe 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -36,6 +36,11 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete): use_alter=True, ) ) + # Per-tree AI model policy (owner-configured). The names reference configured + # providers from the registry; null = that role has no model. The owner may + # use any configured provider; these limit members + the recommender. + ai_member_provider: Mapped[str | None] = mapped_column(String(32)) + ai_recommender_provider: Mapped[str | None] = mapped_column(String(32)) class TreeMembership(Base, UUIDPrimaryKey, Timestamps): diff --git a/backend/app/schemas/ai_policy.py b/backend/app/schemas/ai_policy.py new file mode 100644 index 0000000..14f5744 --- /dev/null +++ b/backend/app/schemas/ai_policy.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel + + +class ConfiguredProvider(BaseModel): + name: str + model: str + + +class TreeAiPolicyRead(BaseModel): + # The model non-owners' assistant uses (null = none). + member_provider: str | None + # The model the association/recommendation engine uses (null = none). + recommender_provider: str | None + # Providers the operator has configured (from env). The owner may use any of + # these; the two settings above restrict members and the recommender to one. + configured_providers: list[ConfiguredProvider] + default_provider: str + + +class TreeAiPolicyUpdate(BaseModel): + member_provider: str | None = None + recommender_provider: str | None = None diff --git a/backend/app/services/ai_policy_service.py b/backend/app/services/ai_policy_service.py new file mode 100644 index 0000000..46b20f9 --- /dev/null +++ b/backend/app/services/ai_policy_service.py @@ -0,0 +1,77 @@ +"""Per-tree AI model policy — owner-only. Assigns which configured provider +members and the recommender use; the owner may use any configured provider. + +The operator decides which providers exist (env / registry); the tree owner +decides who uses which. See app/api/deps.py for the registry. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import configured_llm_providers +from app.models.enums import MembershipRole +from app.models.tree import Tree +from app.models.user import User +from app.services import privacy +from app.services.exceptions import Forbidden + + +async def _require_owner(session: AsyncSession, *, actor: User, tree: Tree) -> None: + role = await privacy.get_membership_role(session, actor.id, tree.id) + if role is not MembershipRole.owner: + raise Forbidden("only the tree owner can configure AI") + + +def _names() -> set[str]: + return {p["name"] for p in configured_llm_providers()} + + +async def get_policy(session: AsyncSession, *, actor: User, tree: Tree) -> dict: + await _require_owner(session, actor=actor, tree=tree) + from app.core.config import get_settings + + return { + "member_provider": tree.ai_member_provider, + "recommender_provider": tree.ai_recommender_provider, + "configured_providers": configured_llm_providers(), + "default_provider": get_settings().default_llm_provider, + } + + +async def update_policy( + session: AsyncSession, + *, + actor: User, + tree: Tree, + member_provider: str | None, + recommender_provider: str | None, +) -> dict: + await _require_owner(session, actor=actor, tree=tree) + valid = _names() + for value in (member_provider, recommender_provider): + if value is not None and value not in valid: + raise Forbidden(f"'{value}' is not a configured provider") + tree.ai_member_provider = member_provider + tree.ai_recommender_provider = recommender_provider + await session.commit() + await session.refresh(tree) + return await get_policy(session, actor=actor, tree=tree) + + +# --- Resolution helpers (for the future assistant / recommender) ------------- + +def provider_name_for_member(tree: Tree) -> str | None: + """Provider an ordinary member's assistant should use, if any.""" + return tree.ai_member_provider + + +def provider_name_for_recommender(tree: Tree) -> str | None: + return tree.ai_recommender_provider + + +def provider_name_for_owner(tree: Tree, requested: str | None = None) -> str | None: + """The owner may use any configured provider; default to the requested one.""" + if requested and requested in _names(): + return requested + return tree.ai_member_provider # fall back to the member model diff --git a/backend/migrations/versions/b2c3d4e5f6a7_tree_ai_policy.py b/backend/migrations/versions/b2c3d4e5f6a7_tree_ai_policy.py new file mode 100644 index 0000000..d918e5a --- /dev/null +++ b/backend/migrations/versions/b2c3d4e5f6a7_tree_ai_policy.py @@ -0,0 +1,26 @@ +"""tree AI model policy (ai_member_provider, ai_recommender_provider) + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-06-09 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b2c3d4e5f6a7" +down_revision: str | None = "a1b2c3d4e5f6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("trees", sa.Column("ai_member_provider", sa.String(length=32), nullable=True)) + op.add_column("trees", sa.Column("ai_recommender_provider", sa.String(length=32), nullable=True)) + + +def downgrade() -> None: + op.drop_column("trees", "ai_recommender_provider") + op.drop_column("trees", "ai_member_provider") diff --git a/backend/tests/test_ai_policy.py b/backend/tests/test_ai_policy.py new file mode 100644 index 0000000..5fe6db8 --- /dev/null +++ b/backend/tests/test_ai_policy.py @@ -0,0 +1,62 @@ +"""Per-tree AI model policy: owner-only, validated against configured providers.""" + +from app.core.config import get_settings +from tests.conftest import auth, register + + +async def test_ai_policy_is_owner_only(client): + owner = auth(await register(client, "ai-o@ex.com")) + editor = auth(await register(client, "ai-x@ex.com")) + tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"] + await client.post( + f"/api/v1/trees/{tid}/members", json={"email": "ai-x@ex.com", "role": "editor"}, headers=owner + ) + + g = await client.get(f"/api/v1/trees/{tid}/ai", headers=owner) + assert g.status_code == 200 + assert g.json()["member_provider"] is None + assert g.json()["configured_providers"] == [] # nothing configured in tests + + # An editor (not owner) can neither view nor change the policy. + assert (await client.get(f"/api/v1/trees/{tid}/ai", headers=editor)).status_code == 403 + assert ( + await client.patch( + f"/api/v1/trees/{tid}/ai", + json={"member_provider": None, "recommender_provider": None}, + headers=editor, + ) + ).status_code == 403 + + +async def test_ai_policy_set_and_validate(client, monkeypatch): + monkeypatch.setattr(get_settings(), "anthropic_api_key", "sk-ant-test") + owner = auth(await register(client, "ai-set@ex.com")) + tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"] + + g = (await client.get(f"/api/v1/trees/{tid}/ai", headers=owner)).json() + assert {p["name"] for p in g["configured_providers"]} == {"anthropic"} + + # Assign the member + recommender model. + p = await client.patch( + f"/api/v1/trees/{tid}/ai", + json={"member_provider": "anthropic", "recommender_provider": "anthropic"}, + headers=owner, + ) + assert p.status_code == 200 and p.json()["member_provider"] == "anthropic" + + # A provider that isn't configured is rejected. + assert ( + await client.patch( + f"/api/v1/trees/{tid}/ai", + json={"member_provider": "openai", "recommender_provider": None}, + headers=owner, + ) + ).status_code == 403 + + # Clearing is allowed. + c = await client.patch( + f"/api/v1/trees/{tid}/ai", + json={"member_provider": None, "recommender_provider": None}, + headers=owner, + ) + assert c.status_code == 200 and c.json()["member_provider"] is None diff --git a/frontend/app/trees/[id]/ai/page.tsx b/frontend/app/trees/[id]/ai/page.tsx new file mode 100644 index 0000000..2d856df --- /dev/null +++ b/frontend/app/trees/[id]/ai/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +type Policy = components["schemas"]["TreeAiPolicyRead"]; + +// `null`/"" means "no AI for this role". The onChange(e.target.value)} + > + + {providers.map((p) => ( + + ))} + + ); + + return ( +
+

AI models

+

+ Choose which configured model each role uses. As the owner you can use any + configured provider; these settings pin members and the recommender to one. +

+ + {providers.length === 0 ? ( + + + No AI providers are configured on this deployment. Set provider + credentials in the server environment (Anthropic, OpenAI, x.AI, or + Ollama) and they'll appear here. + + + ) : ( + <> + + +
+
+
Members' assistant
+
+ The model non-owner members' AI assistant uses. +
+
+ +
+ +
+ + {msg && {msg}} +
+
+
+ +
+ Configured providers:{" "} + {providers.map((p) => `${p.name} (${p.model})`).join(", ")}. + {policy?.default_provider && ( + <> Default: {policy.default_provider}. + )}{" "} + As the owner you can use all of them. +
+ + )} +
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index 676a13f..a27288a 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -4,6 +4,7 @@ import { Archive, ArrowDownUp, BookText, + Bot, ClipboardCheck, Compass, FolderTree, @@ -151,6 +152,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { icon={UserPlus} active={pathname.startsWith(`/trees/${treeId}/members`)} /> + ; export interface components { @@ -1215,6 +1233,13 @@ export interface components { /** Updated */ updated: number; }; + /** ConfiguredProvider */ + ConfiguredProvider: { + /** Name */ + name: string; + /** Model */ + model: string; + }; /** DeceasedApply */ DeceasedApply: { /** Person Ids */ @@ -1886,6 +1911,24 @@ export interface components { /** Token */ token: string; }; + /** TreeAiPolicyRead */ + TreeAiPolicyRead: { + /** Member Provider */ + member_provider: string | null; + /** Recommender Provider */ + recommender_provider: string | null; + /** Configured Providers */ + configured_providers: components["schemas"]["ConfiguredProvider"][]; + /** Default Provider */ + default_provider: string; + }; + /** TreeAiPolicyUpdate */ + TreeAiPolicyUpdate: { + /** Member Provider */ + member_provider?: string | null; + /** Recommender Provider */ + recommender_provider?: string | null; + }; /** TreeCreate */ TreeCreate: { /** Name */ @@ -4651,4 +4694,70 @@ export interface operations { }; }; }; + get_ai_policy_api_v1_trees__tree_id__ai_get: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TreeAiPolicyRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_ai_policy_api_v1_trees__tree_id__ai_patch: { + parameters: { + query?: never; + header?: never; + path: { + tree_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TreeAiPolicyUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TreeAiPolicyRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index b94e4a7..2180b5e 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -4093,6 +4093,100 @@ } } } + }, + "/api/v1/trees/{tree_id}/ai": { + "get": { + "tags": [ + "ai" + ], + "summary": "Get Ai Policy", + "operationId": "get_ai_policy_api_v1_trees__tree_id__ai_get", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TreeAiPolicyRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "tags": [ + "ai" + ], + "summary": "Update Ai Policy", + "operationId": "update_ai_policy_api_v1_trees__tree_id__ai_patch", + "parameters": [ + { + "name": "tree_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Tree Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TreeAiPolicyUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TreeAiPolicyRead" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -4683,6 +4777,24 @@ ], "title": "CleanupResult" }, + "ConfiguredProvider": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "model": { + "type": "string", + "title": "Model" + } + }, + "type": "object", + "required": [ + "name", + "model" + ], + "title": "ConfiguredProvider" + }, "DeceasedApply": { "properties": { "person_ids": { @@ -6812,6 +6924,79 @@ ], "title": "TokenRequest" }, + "TreeAiPolicyRead": { + "properties": { + "member_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Member Provider" + }, + "recommender_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Recommender Provider" + }, + "configured_providers": { + "items": { + "$ref": "#/components/schemas/ConfiguredProvider" + }, + "type": "array", + "title": "Configured Providers" + }, + "default_provider": { + "type": "string", + "title": "Default Provider" + } + }, + "type": "object", + "required": [ + "member_provider", + "recommender_provider", + "configured_providers", + "default_provider" + ], + "title": "TreeAiPolicyRead" + }, + "TreeAiPolicyUpdate": { + "properties": { + "member_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Member Provider" + }, + "recommender_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Recommender Provider" + } + }, + "type": "object", + "title": "TreeAiPolicyUpdate" + }, "TreeCreate": { "properties": { "name": { @@ -7081,4 +7266,4 @@ } } } -} \ No newline at end of file +}