pyvand 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyvand-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyvand
3
+ Version: 0.1.0
4
+ Summary: A world-class, modular framework for building production-ready AI chatbots.
5
+ Author-email: Pyvand Maintainers <hello@pyvand.ai>
6
+ Project-URL: Homepage, https://github.com/arxyzan/pyvand
7
+ Project-URL: Repository, https://github.com/arxyzan/pyvand
8
+ Project-URL: Documentation, https://github.com/arxyzan/pyvand#readme
9
+ Keywords: ai,chatbot,pydantic-ai,fastapi,framework
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Python: >=3.12
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: fastapi>=0.129.0
16
+ Requires-Dist: logfire[starlette]>=4.24.0
17
+ Requires-Dist: minio>=7.2.20
18
+ Requires-Dist: pydantic-ai-slim[openai]>=1.59.0
19
+ Requires-Dist: pyjwt>=2.11.0
20
+ Requires-Dist: pymongo>=4.16.0
21
+ Requires-Dist: python-dotenv>=1.2.1
22
+ Requires-Dist: python-multipart>=0.0.22
23
+ Requires-Dist: ruff>=0.15.1
24
+ Requires-Dist: sse-starlette>=3.2.0
25
+ Requires-Dist: starlette>=0.52.1
26
+ Requires-Dist: uvicorn>=0.40.0
27
+
28
+ # Pyvand Backend Framework (Python)
29
+
30
+ **Location:** `/packages/pyvand-python`
31
+
32
+ The core Python backend engine for the Pyvand Framework. This package provides a modular [FastAPI](https://fastapi.tiangolo.com/)-based framework for building AI chatbots with [Pydantic AI](https://ai.pydantic.dev/).
33
+
34
+ ## Features
35
+
36
+ - **`PyvandApp` Factory**: Rapidly initialize a production-ready chatbot backend with standard API routes, security, and persistence.
37
+ - **Dependency Injection**: Reusable FastAPI dependencies for application-scoped resources like `Database` and `Storage` via `request.app.state`.
38
+ - **AI Agent Engine**: Orchestrate complex PydanticAI streaming agents with built-in instrumentation and event-driven architectures.
39
+ - **Atomic Balance Tracking**: Integrated per-user token and request balance monitoring and management.
40
+ - **Advanced Admin Management**: Secure API endpoints for user analytics, chat history auditing, and permission-based administration.
41
+
42
+ ## Installation
43
+
44
+ As this is part of a monorepo, it is recommended to install it as an editable dependency for local development:
45
+
46
+ ```bash
47
+ # In your app's pyproject.toml
48
+ [tool.uv.sources]
49
+ pyvand = { path = "../../../packages/pyvand-python", editable = true }
50
+ ```
51
+
52
+ ## Quick Start (Minimal App)
53
+
54
+ ```python
55
+ from pyvand.app_factory import PyvandApp
56
+
57
+ # Initialize framework
58
+ app = PyvandApp()
59
+
60
+ # Your inner FastAPI application to serve via Uvicorn
61
+ # uv run uvicorn main:app.api --reload --port 8000
62
+ api = app.api
63
+ ```
64
+
65
+ ## Key Modules
66
+
67
+ - `pyvand.app_factory`: The main application orchestrator.
68
+ - `pyvand.api.dependencies`: Standard FastAPI dependency providers (`get_database`, `authenticate`, etc.).
69
+ - `pyvand.api.routes`: Built-in modular route groups for `auth`, `admin`, `chat`, and `users`.
70
+ - `pyvand.database`: MongoDB models and atomic transaction operations.
71
+ - `pyvand.storage`: MinIO/S3 integrated file storage module.
pyvand-0.1.0/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # Pyvand Backend Framework (Python)
2
+
3
+ **Location:** `/packages/pyvand-python`
4
+
5
+ The core Python backend engine for the Pyvand Framework. This package provides a modular [FastAPI](https://fastapi.tiangolo.com/)-based framework for building AI chatbots with [Pydantic AI](https://ai.pydantic.dev/).
6
+
7
+ ## Features
8
+
9
+ - **`PyvandApp` Factory**: Rapidly initialize a production-ready chatbot backend with standard API routes, security, and persistence.
10
+ - **Dependency Injection**: Reusable FastAPI dependencies for application-scoped resources like `Database` and `Storage` via `request.app.state`.
11
+ - **AI Agent Engine**: Orchestrate complex PydanticAI streaming agents with built-in instrumentation and event-driven architectures.
12
+ - **Atomic Balance Tracking**: Integrated per-user token and request balance monitoring and management.
13
+ - **Advanced Admin Management**: Secure API endpoints for user analytics, chat history auditing, and permission-based administration.
14
+
15
+ ## Installation
16
+
17
+ As this is part of a monorepo, it is recommended to install it as an editable dependency for local development:
18
+
19
+ ```bash
20
+ # In your app's pyproject.toml
21
+ [tool.uv.sources]
22
+ pyvand = { path = "../../../packages/pyvand-python", editable = true }
23
+ ```
24
+
25
+ ## Quick Start (Minimal App)
26
+
27
+ ```python
28
+ from pyvand.app_factory import PyvandApp
29
+
30
+ # Initialize framework
31
+ app = PyvandApp()
32
+
33
+ # Your inner FastAPI application to serve via Uvicorn
34
+ # uv run uvicorn main:app.api --reload --port 8000
35
+ api = app.api
36
+ ```
37
+
38
+ ## Key Modules
39
+
40
+ - `pyvand.app_factory`: The main application orchestrator.
41
+ - `pyvand.api.dependencies`: Standard FastAPI dependency providers (`get_database`, `authenticate`, etc.).
42
+ - `pyvand.api.routes`: Built-in modular route groups for `auth`, `admin`, `chat`, and `users`.
43
+ - `pyvand.database`: MongoDB models and atomic transaction operations.
44
+ - `pyvand.storage`: MinIO/S3 integrated file storage module.
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "pyvand"
3
+ version = "0.1.0"
4
+ description = "A world-class, modular framework for building production-ready AI chatbots."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ authors = [
8
+ { name = "Pyvand Maintainers", email = "hello@pyvand.ai" },
9
+ ]
10
+ keywords = ["ai", "chatbot", "pydantic-ai", "fastapi", "framework"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Framework :: FastAPI",
14
+ "Programming Language :: Python :: 3.12",
15
+ ]
16
+ dependencies = [
17
+ "fastapi>=0.129.0",
18
+ "logfire[starlette]>=4.24.0",
19
+ "minio>=7.2.20",
20
+ "pydantic-ai-slim[openai]>=1.59.0",
21
+ "pyjwt>=2.11.0",
22
+ "pymongo>=4.16.0",
23
+ "python-dotenv>=1.2.1",
24
+ "python-multipart>=0.0.22",
25
+ "ruff>=0.15.1",
26
+ "sse-starlette>=3.2.0",
27
+ "starlette>=0.52.1",
28
+ "uvicorn>=0.40.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/arxyzan/pyvand"
33
+ Repository = "https://github.com/arxyzan/pyvand"
34
+ Documentation = "https://github.com/arxyzan/pyvand#readme"
35
+
36
+
37
+ [tool.ruff]
38
+ lint.ignore = ["C901", "E501", "E741", "W605", "F403", "F405"]
39
+ lint.select = ["C", "E", "F", "I", "W"]
40
+ line-length = 120
41
+
42
+ [tool.ruff.lint.per-file-ignores]
43
+ "__init__.py" = ["E402", "F401", "F403", "F811", "I001"]
44
+ "*test*.py" = ["F401", "F841"]
45
+
46
+ [tool.ruff.lint.isort]
47
+ lines-after-imports = 2
48
+
49
+ [tool.ruff.lint.pydocstyle]
50
+ convention = "google"
pyvand-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ from .factory import PyvandApp
2
+ from .agents import *
@@ -0,0 +1,4 @@
1
+ from .default_agent import default_agent
2
+ from .deps import AgentDeps
3
+ from .events import *
4
+ from .utils import *
@@ -0,0 +1,51 @@
1
+ from typing import Any
2
+
3
+ from pydantic_ai import Agent, RunContext
4
+
5
+ from ..constants import OPENAI_HTTP_PROXY
6
+ from .deps import AgentDeps
7
+ from .utils import get_openai_model
8
+
9
+
10
+ DEFAULT_AGENT_INSTRUCTIONS = """
11
+ You are a helpful AI assistant. Answer user questions clearly and concisely.
12
+ You can help with a wide range of topics including general knowledge, writing, analysis, and more.
13
+
14
+ Always be polite, accurate, and helpful. If you are unsure about something, let the user know.
15
+ """.strip()
16
+
17
+
18
+ def get_default_agent(
19
+ model_name: str = "gpt-5.1",
20
+ instructions: str | list[str] | None = None,
21
+ ) -> Agent[AgentDeps[Any]]:
22
+ """
23
+ Factory method to create a default agent with a customizable model and instructions.
24
+ """
25
+ model = get_openai_model(model_name, proxy=OPENAI_HTTP_PROXY)
26
+
27
+ combined_instructions = DEFAULT_AGENT_INSTRUCTIONS
28
+ if instructions:
29
+ if isinstance(instructions, list):
30
+ combined_instructions += "\n" + "\n".join(instructions)
31
+ else:
32
+ combined_instructions += "\n" + instructions
33
+
34
+ agent = Agent[AgentDeps[Any]](
35
+ name="Agent",
36
+ instructions=combined_instructions,
37
+ model=model,
38
+ )
39
+
40
+ @agent.instructions
41
+ def add_user_info(ctx: RunContext[AgentDeps[Any]]):
42
+ info = ""
43
+ if ctx.deps.user is not None:
44
+ info += f"User's name and surname is '{ctx.deps.user.name} {ctx.deps.user.surname}'"
45
+ return info
46
+
47
+ return agent
48
+
49
+
50
+ # Default instance for legacy support or internal use
51
+ default_agent = get_default_agent()
@@ -0,0 +1,38 @@
1
+ import asyncio
2
+ from typing import Any, Generic, TypeVar
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
5
+
6
+ from ..database import Database, UserDocument
7
+ from ..storage import Storage
8
+ from .events import ThreadStreamEvent
9
+
10
+
11
+ T_Context = TypeVar("T_Context")
12
+
13
+
14
+ class AgentDeps[T_Context](BaseModel):
15
+ """
16
+ Standard agent dependencies for the Pyvand framework.
17
+ Hides internal complexity (DB, Storage) from the developer.
18
+ """
19
+
20
+ model_config = ConfigDict(arbitrary_types_allowed=True)
21
+
22
+ database: Database
23
+ storage: Storage
24
+ user: UserDocument
25
+ context: T_Context
26
+
27
+ # Internal event queue for streaming custom events
28
+ _event_queue: asyncio.Queue = PrivateAttr(default_factory=asyncio.Queue)
29
+
30
+ async def stream(self, event: ThreadStreamEvent):
31
+ await self._event_queue.put(event)
32
+
33
+ async def stream_events(self):
34
+ while True:
35
+ event = await self._event_queue.get()
36
+ if event is None:
37
+ break
38
+ yield event
@@ -0,0 +1,16 @@
1
+ from typing import Annotated, Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ __all__ = ["ProgressUpdateEvent", "ThreadStreamEvent"]
7
+
8
+
9
+ class ProgressUpdateEvent(BaseModel):
10
+ event_kind: Literal["progress_update"] = "progress_update"
11
+ tool_name: str
12
+ status: Literal["running", "completed", "error"]
13
+ message: str
14
+
15
+
16
+ ThreadStreamEvent = Annotated[ProgressUpdateEvent, Field(discriminator="event_kind")]
@@ -0,0 +1,62 @@
1
+ """
2
+ A lightweight agent that generates concise chat titles (2-8 words)
3
+ using gpt-5-nano. Designed to run as a fire-and-forget background task.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pydantic_ai import Agent
12
+
13
+ from ..constants import OPENAI_HTTP_PROXY
14
+ from .utils import get_openai_model
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from ..database import Database
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ TITLE_INSTRUCTIONS = (
23
+ "You are a title generator. Given a user question and an assistant response, "
24
+ "produce a short, descriptive title of 2 to 8 words that captures the topic. "
25
+ "Output ONLY the title text — no quotes, no explanation, no punctuation at the end."
26
+ )
27
+
28
+ _title_model = get_openai_model("gpt-5-nano", proxy=OPENAI_HTTP_PROXY)
29
+
30
+ title_agent = Agent(
31
+ name="TitleAgent",
32
+ instructions=TITLE_INSTRUCTIONS,
33
+ model=_title_model,
34
+ )
35
+
36
+
37
+ async def generate_title(
38
+ question: str,
39
+ answer: str,
40
+ database: Database,
41
+ thread_id: str,
42
+ ) -> str | None:
43
+ """Generate a title for a thread and persist it. Returns the title or None on failure."""
44
+ try:
45
+ # Truncate inputs to keep the prompt small and fast
46
+ q = question[:500]
47
+ a = answer[:500]
48
+ prompt = f"User question:\n{q}\n\nAssistant response:\n{a}"
49
+
50
+ result = await title_agent.run(prompt)
51
+ title = result.output.strip()
52
+
53
+ if title:
54
+ # Enforce max length safety
55
+ if len(title) > 80:
56
+ title = title[:77] + "..."
57
+ database.update_thread_info(thread_id, title=title)
58
+ logger.info("Generated title for thread %s: %s", thread_id, title)
59
+ return title
60
+ except Exception:
61
+ logger.exception("Title generation failed for thread %s — keeping fallback title", thread_id)
62
+ return None
@@ -0,0 +1,24 @@
1
+ __all__ = ["get_openai_model"]
2
+
3
+
4
+ def get_openai_model(model_name: str, proxy: str | None = None):
5
+ """
6
+ Create OpenAI model with optional proxy.
7
+ """
8
+ import httpx
9
+ from pydantic_ai.models.openai import OpenAIChatModel
10
+ from pydantic_ai.providers.openai import OpenAIProvider
11
+
12
+ if proxy:
13
+ http_client = httpx.AsyncClient(
14
+ proxy=proxy,
15
+ timeout=30,
16
+ follow_redirects=True,
17
+ )
18
+ provider = OpenAIProvider(http_client=http_client)
19
+ else:
20
+ provider = OpenAIProvider()
21
+
22
+ openai_model = OpenAIChatModel(model_name=model_name, provider=provider)
23
+
24
+ return openai_model
@@ -0,0 +1,133 @@
1
+ import jwt
2
+ from fastapi import Depends, HTTPException, Request
3
+ from fastapi.security import APIKeyHeader
4
+
5
+ from ..constants import (
6
+ ADMIN_JWT_SECRET_KEY,
7
+ BACKEND_API_KEY,
8
+ JWT_ALGORITHM,
9
+ JWT_SECRET_KEY,
10
+ )
11
+ from ..database import Database, UserDocument
12
+ from ..enums import AdminPermission
13
+ from ..storage import Storage
14
+
15
+
16
+ api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)
17
+
18
+
19
+ # Singletons are now initialized by PyvandApp and attached to app.state
20
+ # Database and Storage instances will be retrieved from request.app.state
21
+
22
+
23
+ def get_database(request: Request) -> Database:
24
+ return request.app.state.database
25
+
26
+
27
+ def get_storage(request: Request) -> Storage:
28
+ return request.app.state.storage
29
+
30
+
31
+ def _verify_token(request: Request, secret: str, identity_key: str) -> str:
32
+ """Helper to extract and verify a Bearer token from the Authorization header."""
33
+ auth_header = request.headers.get("Authorization")
34
+ if not auth_header:
35
+ raise HTTPException(status_code=401, detail="Authorization header missing")
36
+
37
+ parts = auth_header.split()
38
+ if len(parts) != 2 or parts[0].lower() != "bearer":
39
+ raise HTTPException(status_code=401, detail="Invalid authorization header format")
40
+
41
+ token = parts[1]
42
+ try:
43
+ payload = jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
44
+ identity = payload.get(identity_key)
45
+ if not identity:
46
+ raise HTTPException(status_code=401, detail="Invalid token payload")
47
+ return identity
48
+ except jwt.ExpiredSignatureError:
49
+ raise HTTPException(status_code=401, detail="Token has expired")
50
+ except jwt.InvalidTokenError:
51
+ raise HTTPException(status_code=401, detail="Invalid token")
52
+
53
+
54
+ def authenticate_with_bearer(request: Request) -> UserDocument:
55
+ """Authenticate user by verifying JWT token from Authorization header."""
56
+ user_id = _verify_token(request, JWT_SECRET_KEY, "user_id")
57
+ database: Database = request.app.state.database
58
+ user_info = database.get_user(user_id)
59
+ if not user_info:
60
+ raise HTTPException(status_code=401, detail="User not found")
61
+ return user_info
62
+
63
+
64
+ def authenticate_admin(request: Request) -> dict:
65
+ """Authenticate admin by verifying session token."""
66
+ admin_id = _verify_token(request, ADMIN_JWT_SECRET_KEY, "admin_id")
67
+ database: Database = request.app.state.database
68
+ admin_info = database.get_admin(admin_id)
69
+ if not admin_info:
70
+ raise HTTPException(status_code=401, detail="Admin not found")
71
+
72
+ permissions = admin_info.permissions
73
+ if permissions and len(permissions) > 0 and not isinstance(permissions[0], str):
74
+ permissions = [p.value if hasattr(p, "value") else str(p) for p in permissions]
75
+
76
+ return {
77
+ "admin_id": admin_info.id,
78
+ "username": admin_info.username,
79
+ "name": admin_info.name,
80
+ "permissions": permissions,
81
+ }
82
+
83
+
84
+ def verify_api_key(api_key: str = Depends(api_key_header)) -> str:
85
+ """Verify API key for server-to-server authentication."""
86
+ if api_key != BACKEND_API_KEY:
87
+ raise HTTPException(status_code=403, detail="Invalid API key")
88
+ return api_key
89
+
90
+
91
+ def authenticate(
92
+ request: Request,
93
+ api_key: str | None = Depends(api_key_header),
94
+ ) -> dict | None:
95
+ """Authenticate with either admin token or API key."""
96
+ if api_key == BACKEND_API_KEY:
97
+ return None # API key authenticated
98
+
99
+ # Try admin authentication
100
+ return authenticate_admin(request)
101
+
102
+
103
+ def optional_authenticate(
104
+ request: Request,
105
+ api_key: str | None = Depends(api_key_header),
106
+ ) -> dict | None:
107
+ """Try to authenticate, but return None if no credentials provided."""
108
+ try:
109
+ return authenticate(request, api_key)
110
+ except HTTPException:
111
+ return None
112
+
113
+
114
+ def check_permission(admin: dict | None, permission: AdminPermission):
115
+ """Check if admin has the required permission."""
116
+ if admin is None:
117
+ # This happens when authenticated via API key
118
+ return
119
+ if permission.value not in admin.get("permissions", []):
120
+ raise HTTPException(
121
+ status_code=403,
122
+ detail=f"Permission denied: {permission.value} required",
123
+ )
124
+
125
+
126
+ def require_permission(permission: AdminPermission):
127
+ """Dependency factory to require a specific admin permission."""
128
+
129
+ def _permission_dependency(admin: dict | None = Depends(authenticate)):
130
+ check_permission(admin, permission)
131
+ return admin
132
+
133
+ return _permission_dependency
@@ -0,0 +1,67 @@
1
+ from pydantic import BaseModel
2
+
3
+ from ..enums import AdminPermission
4
+
5
+
6
+ # ------------------------------ #
7
+ # -------- User Inputs --------- #
8
+ # ------------------------------ #
9
+ class RegisterUserInput(BaseModel):
10
+ phone: str
11
+ password: str
12
+ name: str
13
+ surname: str
14
+
15
+
16
+ class LoginInput(BaseModel):
17
+ phone: str
18
+ password: str
19
+
20
+
21
+ class MessageFeedbackInput(BaseModel):
22
+ thread_id: str
23
+ message_id: str
24
+ sentiment: str # 'like' or 'dislike'
25
+
26
+
27
+ class ThreadUpdateInput(BaseModel):
28
+ title: str
29
+
30
+
31
+ # ------------------------------ #
32
+ # -------- Admin Inputs -------- #
33
+ # ------------------------------ #
34
+ class AdminRegisterInput(BaseModel):
35
+ username: str
36
+ password: str
37
+ name: str
38
+ permissions: list[AdminPermission]
39
+
40
+
41
+ class AdminUpdateInput(BaseModel):
42
+ name: str | None = None
43
+ password: str | None = None
44
+ permissions: list[AdminPermission] | None = None
45
+
46
+
47
+ class AdminLoginInput(BaseModel):
48
+ username: str
49
+ password: str
50
+
51
+
52
+ class UserCreateInput(BaseModel):
53
+ phone: str
54
+ name: str
55
+ surname: str
56
+ balance: dict | None = None
57
+
58
+
59
+ class UserUpdateInput(BaseModel):
60
+ phone: str | None = None
61
+ name: str | None = None
62
+ surname: str | None = None
63
+
64
+
65
+ class UserBalanceUpdateInput(BaseModel):
66
+ tokens: int
67
+ requests: int