diff --git a/modules/chat/protocols.py b/modules/chat/protocols.py new file mode 100644 index 0000000..df96174 --- /dev/null +++ b/modules/chat/protocols.py @@ -0,0 +1,124 @@ +"""Target Protocol interfaces for the Chat domain. + +These Protocols describe the *ideal* service contracts. Currently +session CRUD lives in ``service.py`` while LLM generation and +streaming live in the orchestrator; the Protocols define the target +unified facade. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from modules.chat.schemas import ( + MessageInfo, + SessionInfo, + SessionSummary, + ShareInfo, + StreamChunk, + ) + from modules.llm.schemas import LLMConfig + + +@runtime_checkable +class ChatService(Protocol): + """Unified facade for chat sessions, messages, and generation. + + Combines session/message CRUD (currently ``modules/chat/service.py``) + with LLM generation (currently orchestrator) into a single contract. + """ + + # -- Sessions ------------------------------------------------------------- + + async def create_session( + self, + *, + source: str = "admin", + source_id: str | None = None, + title: str | None = None, + system_prompt: str | None = None, + owner_id: int | None = None, + workspace_id: int = 1, + ) -> SessionInfo: + """Create a new chat session.""" + ... + + async def get_session(self, session_id: str) -> SessionInfo | None: + """Look up a session by ID.""" + ... + + async def list_sessions( + self, + *, + owner_id: int | None = None, + workspace_id: int | None = None, + ) -> list[SessionSummary]: + """List sessions as compact summaries.""" + ... + + async def delete_session(self, session_id: str) -> bool: + """Delete a session and all its messages.""" + ... + + # -- Messages (CRUD) ------------------------------------------------------ + + async def get_history(self, session_id: str) -> list[MessageInfo]: + """Return the active message branch for a session.""" + ... + + async def add_message( + self, + session_id: str, + role: str, + content: str, + *, + parent_id: str | None = None, + ) -> MessageInfo: + """Append a message to the session.""" + ... + + # -- Generation (LLM) ---------------------------------------------------- + + async def send_message( + self, + session_id: str, + content: str, + *, + llm_config: LLMConfig | None = None, + ) -> MessageInfo: + """Send a user message and return the assistant reply.""" + ... + + async def stream_message( + self, + session_id: str, + content: str, + *, + llm_config: LLMConfig | None = None, + ) -> AsyncIterator[StreamChunk]: + """Send a user message and stream the assistant reply.""" + ... + + # -- Sharing -------------------------------------------------------------- + + async def share_session( + self, + session_id: str, + user_id: int, + *, + permission: str = "read", + ) -> ShareInfo: + """Grant another user access to a session.""" + ... + + async def unshare_session( + self, + session_id: str, + user_id: int, + ) -> bool: + """Revoke a user's access to a session.""" + ... diff --git a/modules/chat/schemas.py b/modules/chat/schemas.py new file mode 100644 index 0000000..c92a880 --- /dev/null +++ b/modules/chat/schemas.py @@ -0,0 +1,99 @@ +"""Ideal data shapes for the Chat domain. + +These TypedDicts describe the *target* API contract for chat sessions, +messages, and sharing. +""" + +from __future__ import annotations + +from typing import TypedDict + + +# --------------------------------------------------------------------------- +# Sessions +# --------------------------------------------------------------------------- + + +class SessionInfo(TypedDict): + """Read-only view of a chat session.""" + + id: str + title: str + system_prompt: str | None + pinned: bool + source: str | None # "admin" | "telegram" | "widget" | "whatsapp" | "mobile" + source_id: str | None + owner_id: int | None + rag_mode: str | None # "all" | "selected" | "collection" | "none" + collection_ids: list[int] | None + created: str | None + updated: str | None + + +class SessionSummary(TypedDict): + """Compact session view for listing (no messages, no system_prompt).""" + + id: str + title: str + pinned: bool + message_count: int + last_message: str | None # first 100 chars + source: str | None + owner_id: int | None + created: str | None + updated: str | None + + +# --------------------------------------------------------------------------- +# Messages +# --------------------------------------------------------------------------- + + +class MessageInfo(TypedDict): + """Read-only view of a chat message.""" + + id: str + role: str # "user" | "assistant" | "system" + content: str + edited: bool + timestamp: str | None + parent_id: str | None + is_active: bool + metadata: dict | None + + +class StreamChunk(TypedDict, total=False): + """A single chunk from ChatService.stream_message(). + + ``content`` — text delta from the model. + ``done`` — ``True`` on the final chunk (includes ``token_usage``). + """ + + content: str + done: bool + token_usage: TokenUsage | None + + +class TokenUsage(TypedDict): + """Token budget information.""" + + tokens: int + context_window: int + percent: float + trimmed: bool + + +# --------------------------------------------------------------------------- +# Sharing +# --------------------------------------------------------------------------- + + +class ShareInfo(TypedDict): + """Read-only view of a session share entry.""" + + id: int + session_id: str + user_id: int + permission: str # "read" | "write" + shared_by: int | None + shared_at: str | None diff --git a/modules/core/protocols.py b/modules/core/protocols.py new file mode 100644 index 0000000..9a17359 --- /dev/null +++ b/modules/core/protocols.py @@ -0,0 +1,102 @@ +"""Target Protocol interfaces for the Core domain (auth & users). + +These Protocols describe the *ideal* service contracts. Currently +auth logic lives in ``auth_manager.py`` (module-level functions); +the Protocols define the target class-based facade. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + + +if TYPE_CHECKING: + from modules.core.schemas import ( + LoginResult, + RoleInfo, + UserInfo, + WorkspaceInfo, + WorkspaceMemberInfo, + ) + + +@runtime_checkable +class AuthService(Protocol): + """Authentication, token management, and RBAC. + + Currently scattered across ``auth_manager.py`` (functions) and + ``modules/core/service.py`` (UserService, RoleService, + WorkspaceService). This Protocol defines the unified target. + """ + + # -- Authentication ------------------------------------------------------- + + async def authenticate( + self, + username: str, + password: str, + ) -> LoginResult: + """Validate credentials and return an access token + user info.""" + ... + + async def validate_token(self, token: str) -> UserInfo | None: + """Decode and validate a JWT. Returns ``None`` if invalid/expired.""" + ... + + async def revoke_session(self, jti: str) -> bool: + """Revoke a single session by its JWT ID.""" + ... + + async def revoke_all_sessions(self, user_id: int) -> int: + """Revoke all active sessions for a user. Returns count revoked.""" + ... + + # -- Permissions ---------------------------------------------------------- + + async def get_permissions(self, user_id: int) -> dict[str, str]: + """Return the effective permission map for a user. + + Keys are module names, values are access levels + (``"view"`` | ``"edit"`` | ``"manage"``). + """ + ... + + async def has_permission( + self, + user_id: int, + module: str, + min_level: str = "view", + ) -> bool: + """Check whether a user meets the minimum access level for a module.""" + ... + + # -- User management ------------------------------------------------------ + + async def get_user(self, user_id: int) -> UserInfo | None: + """Look up a user by ID.""" + ... + + async def list_users( + self, + *, + workspace_id: int | None = None, + include_inactive: bool = False, + ) -> list[UserInfo]: + """List users, optionally filtered by workspace.""" + ... + + # -- Roles ---------------------------------------------------------------- + + async def get_roles(self) -> list[RoleInfo]: + """List all RBAC roles with their permission maps.""" + ... + + # -- Workspaces ----------------------------------------------------------- + + async def get_workspace(self, workspace_id: int) -> WorkspaceInfo | None: + """Look up a workspace by ID.""" + ... + + async def list_members(self, workspace_id: int) -> list[WorkspaceMemberInfo]: + """List members of a workspace.""" + ... diff --git a/modules/core/schemas.py b/modules/core/schemas.py new file mode 100644 index 0000000..d7bd5cd --- /dev/null +++ b/modules/core/schemas.py @@ -0,0 +1,112 @@ +"""Ideal data shapes for the Core domain (auth, users, workspaces). + +These TypedDicts describe the *target* API contract for +authentication, user management, and RBAC. +""" + +from __future__ import annotations + +from typing import TypedDict + + +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- + + +class UserInfo(TypedDict): + """Public user profile — no secrets.""" + + id: int + username: str + role: str # legacy role + display_name: str | None + is_active: bool + workspace_id: int + created: str | None + last_login: str | None + + +# --------------------------------------------------------------------------- +# Auth tokens +# --------------------------------------------------------------------------- + + +class TokenInfo(TypedDict): + """Decoded JWT payload.""" + + sub: str # username + user_id: int + role: str + workspace_id: int + exp: int + iat: int + jti: str + + +class LoginResult(TypedDict): + """Returned by AuthService.authenticate().""" + + access_token: str + token_type: str # "bearer" + expires_in: int # seconds + user: UserInfo + + +# --------------------------------------------------------------------------- +# Permissions / RBAC +# --------------------------------------------------------------------------- + + +class PermissionMap(TypedDict, total=False): + """Module → access level mapping. + + Keys are module names (``"channels"``, ``"knowledge"``, …). + Values are levels: ``"view"`` | ``"edit"`` | ``"manage"``. + """ + + channels: str + knowledge: str + llm: str + tts: str + monitoring: str + chat: str + crm: str + settings: str + users: str + backup: str + + +class RoleInfo(TypedDict): + """Read-only view of an RBAC role.""" + + id: int + name: str + display_name: str | None + description: str | None + is_system: bool + permissions: dict[str, str] # module → level + + +# --------------------------------------------------------------------------- +# Workspaces +# --------------------------------------------------------------------------- + + +class WorkspaceInfo(TypedDict): + """Read-only view of a workspace.""" + + id: int + name: str + slug: str + owner_id: int | None + created: str | None + + +class WorkspaceMemberInfo(TypedDict): + """Read-only view of a workspace membership.""" + + user_id: int + username: str + role_name: str + joined_at: str | None diff --git a/modules/knowledge/protocols.py b/modules/knowledge/protocols.py new file mode 100644 index 0000000..6513633 --- /dev/null +++ b/modules/knowledge/protocols.py @@ -0,0 +1,107 @@ +"""Target Protocol interfaces for the Knowledge domain. + +These Protocols describe the *ideal* service contracts that the +codebase will migrate toward. Current implementations +(``service.py``, ``wiki_rag_service.py``) do not yet fully conform — +the Protocols serve as an architectural north star. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + + +if TYPE_CHECKING: + from modules.knowledge.schemas import ( + CollectionInfo, + DocumentInfo, + FAQEntryInfo, + SearchResult, + SyncResult, + ) + + +@runtime_checkable +class KnowledgeService(Protocol): + """Unified facade for knowledge retrieval and management. + + Combines search (currently in ``wiki_rag_service.py``) with + collection/document management (currently in ``service.py``). + """ + + # -- Search --------------------------------------------------------------- + + async def search( + self, + query: str, + *, + collection_ids: list[int] | None = None, + top_k: int = 3, + max_chars: int = 2500, + ) -> list[SearchResult]: + """Semantic + BM25 search across indexed documents.""" + ... + + async def retrieve_context( + self, + query: str, + *, + collection_ids: list[int] | None = None, + top_k: int = 3, + max_chars: int = 2500, + ) -> str: + """Return pre-formatted context string for LLM prompt injection.""" + ... + + # -- Collections ---------------------------------------------------------- + + async def get_collections( + self, + *, + enabled_only: bool = False, + workspace_id: int | None = None, + ) -> list[CollectionInfo]: + """List knowledge collections, optionally filtered.""" + ... + + async def get_collection( + self, + collection_id: int, + *, + workspace_id: int | None = None, + ) -> CollectionInfo | None: + """Get a single collection by ID.""" + ... + + # -- Documents ------------------------------------------------------------ + + async def get_documents( + self, + collection_id: int, + *, + workspace_id: int | None = None, + ) -> list[DocumentInfo]: + """List documents in a collection.""" + ... + + async def sync_documents( + self, + collection_id: int, + base_dir: str, + ) -> SyncResult: + """Re-index documents from disk into the collection.""" + ... + + # -- FAQ ------------------------------------------------------------------ + + async def find_faq_answer(self, question: str) -> str | None: + """BM25-match a user question against FAQ entries.""" + ... + + async def get_faq_entries( + self, + *, + workspace_id: int | None = None, + ) -> list[FAQEntryInfo]: + """List all FAQ entries.""" + ... diff --git a/modules/knowledge/schemas.py b/modules/knowledge/schemas.py new file mode 100644 index 0000000..450ae17 --- /dev/null +++ b/modules/knowledge/schemas.py @@ -0,0 +1,95 @@ +"""Ideal data shapes for the Knowledge domain. + +These TypedDicts describe the *target* API contract, not necessarily +the current ``to_dict()`` output. As services migrate toward the +Protocol interfaces (``protocols.py``), their return types should +converge to these shapes. +""" + +from __future__ import annotations + +from typing import TypedDict + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + + +class SearchResult(TypedDict): + """Single search hit returned by KnowledgeService.search().""" + + title: str + body: str + source_file: str + score: float + collection_id: int | None + + +# --------------------------------------------------------------------------- +# Collections +# --------------------------------------------------------------------------- + + +class CollectionInfo(TypedDict): + """Read-only view of a knowledge collection.""" + + id: int + name: str + slug: str + description: str | None + enabled: bool + base_dir: str + document_count: int + created: str | None + updated: str | None + + +# --------------------------------------------------------------------------- +# Documents +# --------------------------------------------------------------------------- + + +class DocumentInfo(TypedDict): + """Read-only view of a knowledge document.""" + + id: int + filename: str + title: str + source_type: str # "manual" | "import" | "wiki" + file_size_bytes: int + section_count: int + collection_id: int | None + created: str | None + updated: str | None + + +# --------------------------------------------------------------------------- +# Sync +# --------------------------------------------------------------------------- + + +class SyncResult(TypedDict): + """Result of KnowledgeService.sync_documents().""" + + collection_id: int + documents_synced: int + sections_indexed: int + + +# --------------------------------------------------------------------------- +# FAQ +# --------------------------------------------------------------------------- + + +class FAQEntryInfo(TypedDict): + """Read-only view of a FAQ entry.""" + + id: int + question: str + answer: str + keywords: list[str] + enabled: bool + hit_count: int + created: str | None + updated: str | None diff --git a/modules/llm/protocols.py b/modules/llm/protocols.py new file mode 100644 index 0000000..eae21c4 --- /dev/null +++ b/modules/llm/protocols.py @@ -0,0 +1,55 @@ +"""Target Protocol interfaces for the LLM domain. + +These Protocols describe the *ideal* service contracts. Currently +generation logic lives in ``cloud_llm_service.py`` and the +orchestrator; the Protocols define the target facade. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, runtime_checkable + + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from modules.llm.schemas import LLMConfig, ProviderInfo, StreamChunk + + +@runtime_checkable +class LLMService(Protocol): + """High-level facade for LLM generation. + + Encapsulates backend resolution, prompt assembly, RAG context + injection, and token accounting — currently spread across + ``orchestrator.py`` and ``cloud_llm_service.py``. + """ + + async def generate( + self, + messages: list[dict[str, str]], + config: LLMConfig | None = None, + ) -> str: + """Single-shot generation. Returns the full assistant reply.""" + ... + + async def stream( + self, + messages: list[dict[str, str]], + config: LLMConfig | None = None, + ) -> AsyncIterator[StreamChunk]: + """Streaming generation. Yields content/tool-call chunks.""" + ... + + async def resolve_backend(self, backend_id: str) -> ProviderInfo | None: + """Look up a cloud provider by its ID (e.g. ``"gemini-default"``).""" + ... + + async def list_providers( + self, + *, + enabled_only: bool = False, + workspace_id: int | None = None, + ) -> list[ProviderInfo]: + """List registered cloud LLM providers.""" + ... diff --git a/modules/llm/schemas.py b/modules/llm/schemas.py new file mode 100644 index 0000000..1fd6135 --- /dev/null +++ b/modules/llm/schemas.py @@ -0,0 +1,106 @@ +"""Ideal data shapes for the LLM domain. + +These TypedDicts describe the *target* API contract for LLM +configuration, provider metadata, and generation results. +""" + +from __future__ import annotations + +from typing import TypedDict + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +class LLMConfig(TypedDict, total=False): + """Parameters passed to LLMService.generate() / stream(). + + All fields are optional — unset keys inherit from the provider or + system defaults. + """ + + backend: str # "vllm" | "cloud:" + system_prompt: str + temperature: float + max_tokens: int + top_p: float + repetition_penalty: float + rag_mode: str # "all" | "selected" | "collection" | "none" + collection_ids: list[int] + + +# --------------------------------------------------------------------------- +# Provider +# --------------------------------------------------------------------------- + + +class ProviderInfo(TypedDict): + """Read-only view of a cloud LLM provider.""" + + id: str + name: str + provider_type: str # "gemini" | "openai" | "claude" | "deepseek" | "openrouter" | ... + model_name: str + enabled: bool + is_default: bool + base_url: str | None + description: str | None + config: LLMParams + created: str | None + updated: str | None + + +class LLMParams(TypedDict, total=False): + """Runtime generation parameters stored in provider config.""" + + temperature: float + max_tokens: int + top_p: float + repetition_penalty: float + + +# --------------------------------------------------------------------------- +# Generation result +# --------------------------------------------------------------------------- + + +class StreamChunk(TypedDict, total=False): + """A single chunk from LLMService.stream(). + + Either ``content`` (text delta) or ``tool_calls`` is present. + """ + + type: str # "content" | "tool_calls" + content: str + tool_calls: list[ToolCall] + + +class ToolCall(TypedDict): + """OpenAI-compatible tool/function call.""" + + id: str + type: str # "function" + function: ToolCallFunction + + +class ToolCallFunction(TypedDict): + """Function name + serialised arguments inside a ToolCall.""" + + name: str + arguments: str # JSON string + + +# --------------------------------------------------------------------------- +# Token usage +# --------------------------------------------------------------------------- + + +class TokenUsage(TypedDict): + """Token budget information returned alongside generation.""" + + tokens: int + context_window: int + percent: float + trimmed: bool diff --git a/pyproject.toml b/pyproject.toml index df7ac04..8234925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,7 +116,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] # unused imports in __init__ -"tests/*" = ["ARG", "PLR"] # allow unused args and complexity in tests +"tests/*" = ["ARG", "PLR", "TCH"] # allow unused args, complexity, and runtime type imports in tests "finetune/*" = ["ERA"] # allow commented code in finetune scripts "app/*" = ["UP"] # allow Optional[X] style in routers (compatibility) diff --git a/tests/unit/test_protocols.py b/tests/unit/test_protocols.py new file mode 100644 index 0000000..460fe33 --- /dev/null +++ b/tests/unit/test_protocols.py @@ -0,0 +1,367 @@ +"""Tests for Protocol interfaces and TypedDict schemas. + +Verifies that: +- All schemas are importable and constructable +- All Protocols are importable and runtime_checkable +- Protocol method signatures are well-formed +""" + +import inspect + +from modules.chat.protocols import ChatService +from modules.chat.schemas import ( + MessageInfo, + SessionInfo, + SessionSummary, + ShareInfo, + StreamChunk, +) +from modules.core.protocols import AuthService +from modules.core.schemas import ( + LoginResult, + RoleInfo, + UserInfo, + WorkspaceInfo, + WorkspaceMemberInfo, +) +from modules.knowledge.protocols import KnowledgeService +from modules.knowledge.schemas import ( + CollectionInfo, + DocumentInfo, + FAQEntryInfo, + SearchResult, + SyncResult, +) +from modules.llm.protocols import LLMService +from modules.llm.schemas import ( + LLMConfig, + ProviderInfo, + ToolCall, +) +from modules.llm.schemas import StreamChunk as LLMStreamChunk +from modules.llm.schemas import TokenUsage as LLMTokenUsage + + +# --------------------------------------------------------------------------- +# Schema construction +# --------------------------------------------------------------------------- + + +class TestKnowledgeSchemas: + def test_search_result(self): + r: SearchResult = { + "title": "Setup", + "body": "Install with pip", + "source_file": "install", + "score": 0.85, + "collection_id": 1, + } + assert r["score"] == 0.85 + + def test_collection_info(self): + c: CollectionInfo = { + "id": 1, + "name": "Docs", + "slug": "docs", + "description": None, + "enabled": True, + "base_dir": "/data/docs", + "document_count": 5, + "created": "2026-01-01T00:00:00", + "updated": None, + } + assert c["document_count"] == 5 + + def test_document_info(self): + d: DocumentInfo = { + "id": 1, + "filename": "readme.md", + "title": "README", + "source_type": "manual", + "file_size_bytes": 1024, + "section_count": 3, + "collection_id": 1, + "created": None, + "updated": None, + } + assert d["source_type"] == "manual" + + def test_sync_result(self): + s: SyncResult = { + "collection_id": 1, + "documents_synced": 10, + "sections_indexed": 42, + } + assert s["sections_indexed"] == 42 + + def test_faq_entry_info(self): + f: FAQEntryInfo = { + "id": 1, + "question": "What is this?", + "answer": "An AI secretary", + "keywords": ["ai", "secretary"], + "enabled": True, + "hit_count": 7, + "created": None, + "updated": None, + } + assert f["hit_count"] == 7 + + +class TestLLMSchemas: + def test_llm_config(self): + cfg: LLMConfig = { + "backend": "cloud:gemini-default", + "temperature": 0.7, + "max_tokens": 512, + } + assert cfg["backend"].startswith("cloud:") + + def test_provider_info(self): + p: ProviderInfo = { + "id": "gemini-default", + "name": "Gemini", + "provider_type": "gemini", + "model_name": "gemini-2.0-flash", + "enabled": True, + "is_default": True, + "base_url": None, + "description": None, + "config": {"temperature": 0.7}, + "created": None, + "updated": None, + } + assert p["is_default"] + + def test_stream_chunk(self): + c: LLMStreamChunk = {"type": "content", "content": "Hello"} + assert c["type"] == "content" + + def test_tool_call(self): + tc: ToolCall = { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": '{"q": "test"}'}, + } + assert tc["function"]["name"] == "search" + + def test_token_usage(self): + u: LLMTokenUsage = { + "tokens": 150, + "context_window": 8192, + "percent": 1.8, + "trimmed": False, + } + assert not u["trimmed"] + + +class TestChatSchemas: + def test_session_info(self): + s: SessionInfo = { + "id": "abc-123", + "title": "Test chat", + "system_prompt": None, + "pinned": False, + "source": "admin", + "source_id": None, + "owner_id": 1, + "rag_mode": "all", + "collection_ids": [1, 2], + "created": "2026-01-01T00:00:00", + "updated": None, + } + assert s["rag_mode"] == "all" + + def test_session_summary(self): + s: SessionSummary = { + "id": "abc-123", + "title": "Test chat", + "pinned": False, + "message_count": 5, + "last_message": "Hello...", + "source": "admin", + "owner_id": 1, + "created": None, + "updated": None, + } + assert s["message_count"] == 5 + + def test_message_info(self): + m: MessageInfo = { + "id": "msg-1", + "role": "assistant", + "content": "Hello!", + "edited": False, + "timestamp": "2026-01-01T00:00:00", + "parent_id": None, + "is_active": True, + "metadata": None, + } + assert m["role"] == "assistant" + + def test_stream_chunk(self): + c: StreamChunk = {"content": "Hi", "done": False} + assert c["content"] == "Hi" + + def test_share_info(self): + s: ShareInfo = { + "id": 1, + "session_id": "abc-123", + "user_id": 2, + "permission": "read", + "shared_by": 1, + "shared_at": "2026-01-01T00:00:00", + } + assert s["permission"] == "read" + + +class TestCoreSchemas: + def test_user_info(self): + u: UserInfo = { + "id": 1, + "username": "admin", + "role": "admin", + "display_name": "Administrator", + "is_active": True, + "workspace_id": 1, + "created": None, + "last_login": None, + } + assert u["is_active"] + + def test_login_result(self): + r: LoginResult = { + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 86400, + "user": { + "id": 1, + "username": "admin", + "role": "admin", + "display_name": None, + "is_active": True, + "workspace_id": 1, + "created": None, + "last_login": None, + }, + } + assert r["token_type"] == "bearer" + + def test_role_info(self): + r: RoleInfo = { + "id": 1, + "name": "admin", + "display_name": "Administrator", + "description": "Full access", + "is_system": True, + "permissions": {"channels": "manage", "knowledge": "manage"}, + } + assert r["is_system"] + + def test_workspace_info(self): + w: WorkspaceInfo = { + "id": 1, + "name": "Default", + "slug": "default", + "owner_id": 1, + "created": None, + } + assert w["slug"] == "default" + + def test_workspace_member_info(self): + m: WorkspaceMemberInfo = { + "user_id": 1, + "username": "admin", + "role_name": "admin", + "joined_at": None, + } + assert m["role_name"] == "admin" + + +# --------------------------------------------------------------------------- +# Protocol importability & runtime_checkable +# --------------------------------------------------------------------------- + + +class TestProtocols: + def test_knowledge_service_is_protocol(self): + assert hasattr(KnowledgeService, "__protocol_attrs__") or issubclass( + KnowledgeService, object + ) + + def test_llm_service_is_protocol(self): + assert hasattr(LLMService, "__protocol_attrs__") or issubclass(LLMService, object) + + def test_chat_service_is_protocol(self): + assert hasattr(ChatService, "__protocol_attrs__") or issubclass(ChatService, object) + + def test_auth_service_is_protocol(self): + assert hasattr(AuthService, "__protocol_attrs__") or issubclass(AuthService, object) + + def test_knowledge_service_methods(self): + methods = { + "search", + "retrieve_context", + "get_collections", + "get_collection", + "get_documents", + "sync_documents", + "find_faq_answer", + "get_faq_entries", + } + actual = { + name + for name, _ in inspect.getmembers(KnowledgeService, predicate=inspect.isfunction) + if not name.startswith("_") + } + assert methods <= actual, f"Missing: {methods - actual}" + + def test_llm_service_methods(self): + methods = {"generate", "stream", "resolve_backend", "list_providers"} + actual = { + name + for name, _ in inspect.getmembers(LLMService, predicate=inspect.isfunction) + if not name.startswith("_") + } + assert methods <= actual, f"Missing: {methods - actual}" + + def test_chat_service_methods(self): + methods = { + "create_session", + "get_session", + "list_sessions", + "delete_session", + "get_history", + "add_message", + "send_message", + "stream_message", + "share_session", + "unshare_session", + } + actual = { + name + for name, _ in inspect.getmembers(ChatService, predicate=inspect.isfunction) + if not name.startswith("_") + } + assert methods <= actual, f"Missing: {methods - actual}" + + def test_auth_service_methods(self): + methods = { + "authenticate", + "validate_token", + "revoke_session", + "revoke_all_sessions", + "get_permissions", + "has_permission", + "get_user", + "list_users", + "get_roles", + "get_workspace", + "list_members", + } + actual = { + name + for name, _ in inspect.getmembers(AuthService, predicate=inspect.isfunction) + if not name.startswith("_") + } + assert methods <= actual, f"Missing: {methods - actual}"