appgenerator-cli 1.0.0__py3-none-any.whl
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.
- appgenerator_cli-1.0.0.dist-info/METADATA +213 -0
- appgenerator_cli-1.0.0.dist-info/RECORD +48 -0
- appgenerator_cli-1.0.0.dist-info/WHEEL +4 -0
- appgenerator_cli-1.0.0.dist-info/entry_points.txt +4 -0
- pyforge/__init__.py +10 -0
- pyforge/commands/__init__.py +1 -0
- pyforge/commands/create.py +60 -0
- pyforge/generator.py +248 -0
- pyforge/main.py +32 -0
- pyforge/templates/ai/.env.example +29 -0
- pyforge/templates/ai/.gitignore +47 -0
- pyforge/templates/ai/Dockerfile +22 -0
- pyforge/templates/ai/README.md +97 -0
- pyforge/templates/ai/app/__init__.py +1 -0
- pyforge/templates/ai/app/agents/__init__.py +1 -0
- pyforge/templates/ai/app/agents/assistant.py +100 -0
- pyforge/templates/ai/app/chains/__init__.py +1 -0
- pyforge/templates/ai/app/chains/rag.py +50 -0
- pyforge/templates/ai/app/config.py +47 -0
- pyforge/templates/ai/app/tools/__init__.py +1 -0
- pyforge/templates/ai/app/tools/registry.py +19 -0
- pyforge/templates/ai/app/tools/search.py +34 -0
- pyforge/templates/ai/docker-compose.yml +39 -0
- pyforge/templates/ai/main.py +40 -0
- pyforge/templates/ai/pyproject.toml +28 -0
- pyforge/templates/ai/tests/__init__.py +1 -0
- pyforge/templates/ai/tests/conftest.py +21 -0
- pyforge/templates/ai/tests/test_agent.py +53 -0
- pyforge/templates/fastapi/.env.example +17 -0
- pyforge/templates/fastapi/.gitignore +42 -0
- pyforge/templates/fastapi/Dockerfile +27 -0
- pyforge/templates/fastapi/README.md +68 -0
- pyforge/templates/fastapi/app/__init__.py +1 -0
- pyforge/templates/fastapi/app/api/__init__.py +1 -0
- pyforge/templates/fastapi/app/api/v1/__init__.py +1 -0
- pyforge/templates/fastapi/app/api/v1/health.py +25 -0
- pyforge/templates/fastapi/app/config.py +45 -0
- pyforge/templates/fastapi/app/db/__init__.py +1 -0
- pyforge/templates/fastapi/app/db/session.py +35 -0
- pyforge/templates/fastapi/app/dependencies.py +12 -0
- pyforge/templates/fastapi/app/main.py +58 -0
- pyforge/templates/fastapi/app/models/__init__.py +4 -0
- pyforge/templates/fastapi/app/models/base.py +23 -0
- pyforge/templates/fastapi/docker-compose.yml +39 -0
- pyforge/templates/fastapi/pyproject.toml +29 -0
- pyforge/templates/fastapi/tests/__init__.py +1 -0
- pyforge/templates/fastapi/tests/conftest.py +46 -0
- pyforge/templates/fastapi/tests/test_health.py +13 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.so
|
|
5
|
+
*.egg
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.eggs/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
|
|
16
|
+
# uv
|
|
17
|
+
.uv/
|
|
18
|
+
|
|
19
|
+
# Environment
|
|
20
|
+
.env
|
|
21
|
+
!.env.example
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
|
|
28
|
+
# Testing
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.coverage
|
|
31
|
+
htmlcov/
|
|
32
|
+
|
|
33
|
+
# Type checking
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
|
|
36
|
+
# Vector DB / embeddings
|
|
37
|
+
chroma_db/
|
|
38
|
+
*.faiss
|
|
39
|
+
*.pkl
|
|
40
|
+
|
|
41
|
+
# LangSmith / LangChain cache
|
|
42
|
+
.langchain.db
|
|
43
|
+
langchain_cache/
|
|
44
|
+
|
|
45
|
+
# Logs
|
|
46
|
+
*.log
|
|
47
|
+
logs/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# ── Build stage ────────────────────────────────────────────────────────────────
|
|
2
|
+
FROM python:3.12-slim AS builder
|
|
3
|
+
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
|
|
6
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
7
|
+
|
|
8
|
+
COPY pyproject.toml uv.lock* ./
|
|
9
|
+
RUN uv sync --frozen --no-dev
|
|
10
|
+
|
|
11
|
+
# ── Runtime stage ──────────────────────────────────────────────────────────────
|
|
12
|
+
FROM python:3.12-slim AS runtime
|
|
13
|
+
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
|
|
16
|
+
COPY --from=builder /app/.venv /app/.venv
|
|
17
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
18
|
+
|
|
19
|
+
COPY app ./app
|
|
20
|
+
COPY main.py ./main.py
|
|
21
|
+
|
|
22
|
+
CMD ["python", "main.py"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# {{ project_name }}
|
|
2
|
+
|
|
3
|
+
A production-ready LangChain / LangGraph AI application, scaffolded by [AppGenerator](https://github.com/yourname/appgenerator-cli).
|
|
4
|
+
|
|
5
|
+
## Tech Stack
|
|
6
|
+
|
|
7
|
+
- **[LangChain](https://python.langchain.com/)** — LLM orchestration framework
|
|
8
|
+
- **[LangGraph](https://langchain-ai.github.io/langgraph/)** — stateful agent graphs
|
|
9
|
+
- **[LangChain-OpenAI](https://python.langchain.com/docs/integrations/chat/openai)** — OpenAI chat models
|
|
10
|
+
- **[LangChain-Ollama](https://python.langchain.com/docs/integrations/chat/ollama)** — local Ollama models
|
|
11
|
+
- **[ChromaDB](https://docs.trychroma.com/)** — local vector store for RAG
|
|
12
|
+
- **[Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)** — typed config from env
|
|
13
|
+
- **[uv](https://docs.astral.sh/uv/)** — blazing-fast package management
|
|
14
|
+
{% if postgres %}- **pgvector** — PostgreSQL vector extension
|
|
15
|
+
{% endif %}{% if redis %}- **Redis** — semantic caching layer
|
|
16
|
+
{% endif %}{% if docker %}- **Docker** — containerisation
|
|
17
|
+
{% endif %}
|
|
18
|
+
|
|
19
|
+
## Project Structure
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
{{ project_name }}/
|
|
23
|
+
├── main.py # Interactive REPL entry point
|
|
24
|
+
├── app/
|
|
25
|
+
│ ├── config.py # Typed settings (pydantic-settings)
|
|
26
|
+
│ ├── agents/
|
|
27
|
+
│ │ ├── __init__.py
|
|
28
|
+
│ │ └── assistant.py # LangGraph ReAct agent
|
|
29
|
+
│ ├── chains/
|
|
30
|
+
│ │ ├── __init__.py
|
|
31
|
+
│ │ └── rag.py # RAG chain example
|
|
32
|
+
│ ├── tools/
|
|
33
|
+
│ │ ├── __init__.py
|
|
34
|
+
│ │ ├── registry.py # Tool registry (add tools here)
|
|
35
|
+
│ │ └── search.py # Web search tool stub
|
|
36
|
+
├── tests/
|
|
37
|
+
│ ├── conftest.py
|
|
38
|
+
│ ├── test_agent.py
|
|
39
|
+
│ └── __init__.py
|
|
40
|
+
├── .env.example
|
|
41
|
+
├── .gitignore
|
|
42
|
+
└── pyproject.toml
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Getting Started
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# 1. Copy and configure your secrets
|
|
49
|
+
cp .env.example .env
|
|
50
|
+
# Edit .env — add your OPENAI_API_KEY
|
|
51
|
+
|
|
52
|
+
# 2. Run the interactive assistant
|
|
53
|
+
uv run python main.py
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Using Ollama (Local Models)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Install Ollama: https://ollama.com
|
|
60
|
+
ollama pull llama3.2
|
|
61
|
+
|
|
62
|
+
# Update .env
|
|
63
|
+
OLLAMA_MODEL=llama3.2
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then swap the LLM in `app/agents/assistant.py`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from langchain_ollama import ChatOllama
|
|
70
|
+
self._llm = ChatOllama(model=settings.ollama_model, base_url=settings.ollama_base_url)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Adding Tools
|
|
74
|
+
|
|
75
|
+
Edit `app/tools/registry.py` and add your tools to `get_tools()`:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from app.tools.my_tool import my_custom_tool
|
|
79
|
+
|
|
80
|
+
def get_tools():
|
|
81
|
+
return [web_search_tool, my_custom_tool]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Running Tests
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv run pytest
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## LangSmith Tracing (optional)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# .env
|
|
94
|
+
LANGCHAIN_TRACING_V2=true
|
|
95
|
+
LANGCHAIN_API_KEY=ls__your_key
|
|
96
|
+
LANGCHAIN_PROJECT={{ project_name }}
|
|
97
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""{{ project_name }} AI application package."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent definitions."""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AssistantAgent — a simple LangGraph ReAct agent wired to the configured LLM.
|
|
3
|
+
|
|
4
|
+
Extend this class to add memory, tools, and custom graph nodes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
11
|
+
from langchain_openai import ChatOpenAI
|
|
12
|
+
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
13
|
+
|
|
14
|
+
from app.config import settings
|
|
15
|
+
from app.tools.registry import get_tools
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AssistantAgent:
|
|
19
|
+
"""A LangGraph-powered conversational agent."""
|
|
20
|
+
|
|
21
|
+
SYSTEM_PROMPT = (
|
|
22
|
+
"You are a helpful, concise, and accurate assistant. "
|
|
23
|
+
"Use the available tools when appropriate. "
|
|
24
|
+
"If you don't know something, say so honestly."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._llm = ChatOpenAI(
|
|
29
|
+
model=settings.openai_model,
|
|
30
|
+
api_key=settings.openai_api_key,
|
|
31
|
+
temperature=0.2,
|
|
32
|
+
)
|
|
33
|
+
self._tools = get_tools()
|
|
34
|
+
self._llm_with_tools = self._llm.bind_tools(self._tools)
|
|
35
|
+
self._graph = self._build_graph()
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------ #
|
|
38
|
+
# Public
|
|
39
|
+
# ------------------------------------------------------------------ #
|
|
40
|
+
|
|
41
|
+
async def invoke(self, user_message: str, history: list[dict[str, str]] | None = None) -> str:
|
|
42
|
+
"""Run the agent and return the assistant's reply."""
|
|
43
|
+
messages = [SystemMessage(content=self.SYSTEM_PROMPT)]
|
|
44
|
+
|
|
45
|
+
for msg in (history or []):
|
|
46
|
+
if msg["role"] == "user":
|
|
47
|
+
messages.append(HumanMessage(content=msg["content"]))
|
|
48
|
+
# assistant messages are added by LangGraph internally
|
|
49
|
+
|
|
50
|
+
messages.append(HumanMessage(content=user_message))
|
|
51
|
+
|
|
52
|
+
result = await self._graph.ainvoke({"messages": messages})
|
|
53
|
+
return result["messages"][-1].content # type: ignore[index]
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------ #
|
|
56
|
+
# Graph construction
|
|
57
|
+
# ------------------------------------------------------------------ #
|
|
58
|
+
|
|
59
|
+
def _build_graph(self) -> Any:
|
|
60
|
+
"""Build a minimal ReAct graph: call model → maybe call tools → end."""
|
|
61
|
+
builder = StateGraph(MessagesState)
|
|
62
|
+
|
|
63
|
+
builder.add_node("agent", self._call_model)
|
|
64
|
+
builder.add_node("tools", self._call_tools)
|
|
65
|
+
|
|
66
|
+
builder.add_edge(START, "agent")
|
|
67
|
+
builder.add_conditional_edges("agent", self._should_use_tools)
|
|
68
|
+
builder.add_edge("tools", "agent")
|
|
69
|
+
|
|
70
|
+
return builder.compile()
|
|
71
|
+
|
|
72
|
+
async def _call_model(self, state: MessagesState) -> dict[str, Any]:
|
|
73
|
+
response = await self._llm_with_tools.ainvoke(state["messages"])
|
|
74
|
+
return {"messages": [response]}
|
|
75
|
+
|
|
76
|
+
async def _call_tools(self, state: MessagesState) -> dict[str, Any]:
|
|
77
|
+
from langchain_core.messages import ToolMessage
|
|
78
|
+
|
|
79
|
+
last_message = state["messages"][-1]
|
|
80
|
+
tool_map = {t.name: t for t in self._tools}
|
|
81
|
+
results = []
|
|
82
|
+
|
|
83
|
+
for tool_call in last_message.tool_calls: # type: ignore[attr-defined]
|
|
84
|
+
tool = tool_map.get(tool_call["name"])
|
|
85
|
+
if tool is None:
|
|
86
|
+
output = f"Error: unknown tool '{tool_call['name']}'"
|
|
87
|
+
else:
|
|
88
|
+
output = await tool.ainvoke(tool_call["args"])
|
|
89
|
+
results.append(
|
|
90
|
+
ToolMessage(content=str(output), tool_call_id=tool_call["id"])
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return {"messages": results}
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _should_use_tools(state: MessagesState) -> str:
|
|
97
|
+
last = state["messages"][-1]
|
|
98
|
+
if hasattr(last, "tool_calls") and last.tool_calls: # type: ignore[attr-defined]
|
|
99
|
+
return "tools"
|
|
100
|
+
return END
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reusable LangChain chain definitions."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retrieval-Augmented Generation (RAG) chain.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from app.chains.rag import build_rag_chain
|
|
6
|
+
chain = build_rag_chain(vectorstore)
|
|
7
|
+
answer = await chain.ainvoke({"question": "What is LangGraph?"})
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from langchain_core.output_parsers import StrOutputParser
|
|
12
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
13
|
+
from langchain_core.runnables import RunnablePassthrough
|
|
14
|
+
from langchain_core.vectorstores import VectorStore
|
|
15
|
+
from langchain_openai import ChatOpenAI
|
|
16
|
+
|
|
17
|
+
from app.config import settings
|
|
18
|
+
|
|
19
|
+
RAG_PROMPT = ChatPromptTemplate.from_template(
|
|
20
|
+
"""You are a helpful assistant. Answer the question using ONLY the provided context.
|
|
21
|
+
If the answer is not in the context, say "I don't know".
|
|
22
|
+
|
|
23
|
+
Context:
|
|
24
|
+
{context}
|
|
25
|
+
|
|
26
|
+
Question: {question}
|
|
27
|
+
|
|
28
|
+
Answer:"""
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_rag_chain(vectorstore: VectorStore):
|
|
33
|
+
"""Build a RAG chain from a pre-loaded vector store."""
|
|
34
|
+
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
|
|
35
|
+
llm = ChatOpenAI(
|
|
36
|
+
model=settings.openai_model,
|
|
37
|
+
api_key=settings.openai_api_key,
|
|
38
|
+
temperature=0,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def format_docs(docs: list) -> str:
|
|
42
|
+
return "\n\n".join(doc.page_content for doc in docs)
|
|
43
|
+
|
|
44
|
+
chain = (
|
|
45
|
+
{"context": retriever | format_docs, "question": RunnablePassthrough()}
|
|
46
|
+
| RAG_PROMPT
|
|
47
|
+
| llm
|
|
48
|
+
| StrOutputParser()
|
|
49
|
+
)
|
|
50
|
+
return chain
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed settings for the AI application, loaded from .env.
|
|
3
|
+
"""
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
|
11
|
+
|
|
12
|
+
# App
|
|
13
|
+
app_name: str = "{{ project_name }}"
|
|
14
|
+
app_env: str = "development"
|
|
15
|
+
debug: bool = True
|
|
16
|
+
|
|
17
|
+
# LLM — OpenAI
|
|
18
|
+
openai_api_key: str = ""
|
|
19
|
+
openai_model: str = "gpt-4o-mini"
|
|
20
|
+
|
|
21
|
+
# LLM — Ollama (local)
|
|
22
|
+
ollama_base_url: str = "http://localhost:11434"
|
|
23
|
+
ollama_model: str = "llama3.2"
|
|
24
|
+
|
|
25
|
+
# Vector store
|
|
26
|
+
chroma_persist_dir: str = "./chroma_db"
|
|
27
|
+
{% if postgres %}
|
|
28
|
+
# pgvector
|
|
29
|
+
pgvector_url: str = ""
|
|
30
|
+
{% endif %}
|
|
31
|
+
{% if redis %}
|
|
32
|
+
# Redis
|
|
33
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
34
|
+
{% endif %}
|
|
35
|
+
|
|
36
|
+
# LangSmith tracing (set via env vars)
|
|
37
|
+
langchain_tracing_v2: bool = False
|
|
38
|
+
langchain_api_key: str = ""
|
|
39
|
+
langchain_project: str = "{{ project_name }}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@lru_cache
|
|
43
|
+
def get_settings() -> Settings:
|
|
44
|
+
return Settings()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
settings = get_settings()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Custom LangChain tools."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool registry — add your custom tools here and they are automatically
|
|
3
|
+
bound to the AssistantAgent.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool
|
|
8
|
+
|
|
9
|
+
from app.tools.search import web_search_tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_tools() -> list[BaseTool]:
|
|
13
|
+
"""Return all tools available to the agent."""
|
|
14
|
+
return [
|
|
15
|
+
web_search_tool,
|
|
16
|
+
# Add more tools here, e.g.:
|
|
17
|
+
# calculator_tool,
|
|
18
|
+
# retriever_tool,
|
|
19
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example tool: simple web search stub.
|
|
3
|
+
|
|
4
|
+
Replace the implementation with a real provider such as:
|
|
5
|
+
- Tavily: `pip install langchain-tavily`
|
|
6
|
+
- SerpAPI: `pip install google-search-results`
|
|
7
|
+
- DuckDuckGo: `pip install duckduckgo-search`
|
|
8
|
+
"""
|
|
9
|
+
from langchain_core.tools import tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@tool
|
|
13
|
+
async def web_search_tool(query: str) -> str:
|
|
14
|
+
"""Search the web for up-to-date information on a given query.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
query: The search query string.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A string summarising the top search results.
|
|
21
|
+
"""
|
|
22
|
+
# ── Stub — replace with a real search provider ──────────────────────
|
|
23
|
+
# Example using Tavily (recommended for LangChain agents):
|
|
24
|
+
#
|
|
25
|
+
# from langchain_community.tools.tavily_search import TavilySearchResults
|
|
26
|
+
# search = TavilySearchResults(max_results=3)
|
|
27
|
+
# results = await search.ainvoke({"query": query})
|
|
28
|
+
# return "\n".join(r["content"] for r in results)
|
|
29
|
+
#
|
|
30
|
+
# ────────────────────────────────────────────────────────────────────
|
|
31
|
+
return (
|
|
32
|
+
f"[web_search stub] No live results for '{query}'. "
|
|
33
|
+
"Configure a real search provider in app/tools/search.py."
|
|
34
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
version: "3.9"
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
app:
|
|
5
|
+
build: .
|
|
6
|
+
env_file: .env
|
|
7
|
+
volumes:
|
|
8
|
+
- ./chroma_db:/app/chroma_db
|
|
9
|
+
{% if redis or postgres %}depends_on:{% endif %}
|
|
10
|
+
{% if postgres %}- postgres{% endif %}
|
|
11
|
+
{% if redis %}- redis{% endif %}
|
|
12
|
+
restart: unless-stopped
|
|
13
|
+
|
|
14
|
+
{% if postgres %}
|
|
15
|
+
postgres:
|
|
16
|
+
image: pgvector/pgvector:pg16
|
|
17
|
+
environment:
|
|
18
|
+
POSTGRES_DB: {{ package_name }}_vectors
|
|
19
|
+
POSTGRES_USER: postgres
|
|
20
|
+
POSTGRES_PASSWORD: postgres
|
|
21
|
+
ports:
|
|
22
|
+
- "5432:5432"
|
|
23
|
+
volumes:
|
|
24
|
+
- pg_data:/var/lib/postgresql/data
|
|
25
|
+
restart: unless-stopped
|
|
26
|
+
{% endif %}
|
|
27
|
+
|
|
28
|
+
{% if redis %}
|
|
29
|
+
redis:
|
|
30
|
+
image: redis:7-alpine
|
|
31
|
+
ports:
|
|
32
|
+
- "6379:6379"
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
{% endif %}
|
|
35
|
+
|
|
36
|
+
{% if postgres %}
|
|
37
|
+
volumes:
|
|
38
|
+
pg_data:
|
|
39
|
+
{% endif %}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
{{ project_name }} — LangChain / LangGraph application entry point.
|
|
3
|
+
|
|
4
|
+
Run:
|
|
5
|
+
uv run python main.py
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from app.agents.assistant import AssistantAgent
|
|
10
|
+
from app.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def main() -> None:
|
|
14
|
+
print(f"🤖 {settings.app_name} starting up …\n")
|
|
15
|
+
|
|
16
|
+
agent = AssistantAgent()
|
|
17
|
+
|
|
18
|
+
# Interactive REPL
|
|
19
|
+
print("Type your message (Ctrl-C to quit):\n")
|
|
20
|
+
history: list[dict[str, str]] = []
|
|
21
|
+
|
|
22
|
+
while True:
|
|
23
|
+
try:
|
|
24
|
+
user_input = input("You: ").strip()
|
|
25
|
+
except (KeyboardInterrupt, EOFError):
|
|
26
|
+
print("\nGoodbye!")
|
|
27
|
+
break
|
|
28
|
+
|
|
29
|
+
if not user_input:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
response = await agent.invoke(user_input, history=history)
|
|
33
|
+
print(f"AI: {response}\n")
|
|
34
|
+
|
|
35
|
+
history.append({"role": "user", "content": user_input})
|
|
36
|
+
history.append({"role": "assistant", "content": response})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.2,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "{{ project_name }}"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A LangChain / LangGraph AI application"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[tool.uv.build-backend]
|
|
13
|
+
module-name = "app"
|
|
14
|
+
module-root = ""
|
|
15
|
+
|
|
16
|
+
[tool.ruff]
|
|
17
|
+
line-length = 100
|
|
18
|
+
target-version = "py310"
|
|
19
|
+
select = ["E", "F", "I", "N", "UP"]
|
|
20
|
+
|
|
21
|
+
[tool.mypy]
|
|
22
|
+
python_version = "3.10"
|
|
23
|
+
strict = true
|
|
24
|
+
ignore_missing_imports = true
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test suite for {{ project_name }}."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared test fixtures for the AI application."""
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture
|
|
8
|
+
def mock_openai():
|
|
9
|
+
"""Patch ChatOpenAI so tests never hit the real API."""
|
|
10
|
+
with patch("langchain_openai.ChatOpenAI") as mock_cls:
|
|
11
|
+
instance = mock_cls.return_value
|
|
12
|
+
instance.ainvoke = AsyncMock(return_value=_fake_message("Test response"))
|
|
13
|
+
instance.bind_tools = lambda tools: instance
|
|
14
|
+
yield instance
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _fake_message(content: str):
|
|
18
|
+
from langchain_core.messages import AIMessage
|
|
19
|
+
msg = AIMessage(content=content)
|
|
20
|
+
msg.tool_calls = []
|
|
21
|
+
return msg
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tests for the AssistantAgent."""
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from langchain_core.messages import AIMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.asyncio
|
|
9
|
+
async def test_agent_returns_string_response():
|
|
10
|
+
"""Agent.invoke() should return a plain string."""
|
|
11
|
+
fake_ai_msg = AIMessage(content="Hello, I am your assistant!")
|
|
12
|
+
fake_ai_msg.tool_calls = [] # no tool calls → goes straight to END
|
|
13
|
+
|
|
14
|
+
with (
|
|
15
|
+
patch("langchain_openai.ChatOpenAI") as mock_llm_cls,
|
|
16
|
+
patch("app.tools.registry.get_tools", return_value=[]),
|
|
17
|
+
):
|
|
18
|
+
mock_llm = MagicMock()
|
|
19
|
+
mock_llm.bind_tools.return_value = mock_llm
|
|
20
|
+
mock_llm.ainvoke = AsyncMock(return_value=fake_ai_msg)
|
|
21
|
+
mock_llm_cls.return_value = mock_llm
|
|
22
|
+
|
|
23
|
+
from app.agents.assistant import AssistantAgent
|
|
24
|
+
|
|
25
|
+
agent = AssistantAgent()
|
|
26
|
+
result = await agent.invoke("Hello")
|
|
27
|
+
|
|
28
|
+
assert isinstance(result, str)
|
|
29
|
+
assert len(result) > 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_agent_passes_history():
|
|
34
|
+
"""Agent should include prior history in the prompt."""
|
|
35
|
+
fake_ai_msg = AIMessage(content="I remember!")
|
|
36
|
+
fake_ai_msg.tool_calls = []
|
|
37
|
+
|
|
38
|
+
with (
|
|
39
|
+
patch("langchain_openai.ChatOpenAI") as mock_llm_cls,
|
|
40
|
+
patch("app.tools.registry.get_tools", return_value=[]),
|
|
41
|
+
):
|
|
42
|
+
mock_llm = MagicMock()
|
|
43
|
+
mock_llm.bind_tools.return_value = mock_llm
|
|
44
|
+
mock_llm.ainvoke = AsyncMock(return_value=fake_ai_msg)
|
|
45
|
+
mock_llm_cls.return_value = mock_llm
|
|
46
|
+
|
|
47
|
+
from app.agents.assistant import AssistantAgent
|
|
48
|
+
|
|
49
|
+
agent = AssistantAgent()
|
|
50
|
+
history = [{"role": "user", "content": "My name is Alice"}]
|
|
51
|
+
result = await agent.invoke("What is my name?", history=history)
|
|
52
|
+
|
|
53
|
+
assert isinstance(result, str)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ── Application ──────────────────────────────────────────────
|
|
2
|
+
APP_NAME="{{ project_name }}"
|
|
3
|
+
APP_ENV=development # development | staging | production
|
|
4
|
+
DEBUG=true
|
|
5
|
+
SECRET_KEY=change-me-to-a-random-secret
|
|
6
|
+
|
|
7
|
+
# ── Server ────────────────────────────────────────────────────
|
|
8
|
+
HOST=0.0.0.0
|
|
9
|
+
PORT=8000
|
|
10
|
+
|
|
11
|
+
# ── Database ──────────────────────────────────────────────────
|
|
12
|
+
{% if postgres %}DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/{{ package_name }}_db
|
|
13
|
+
{% else %}DATABASE_URL=sqlite+aiosqlite:///./{{ package_name }}.db
|
|
14
|
+
{% endif %}
|
|
15
|
+
{% if redis %}# ── Redis ────────────────────────────────────────────────────
|
|
16
|
+
REDIS_URL=redis://localhost:6379/0
|
|
17
|
+
{% endif %}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.so
|
|
5
|
+
*.egg
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.eggs/
|
|
10
|
+
|
|
11
|
+
# Virtual environments
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
|
|
16
|
+
# uv
|
|
17
|
+
.uv/
|
|
18
|
+
|
|
19
|
+
# Environment
|
|
20
|
+
.env
|
|
21
|
+
!.env.example
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
|
|
28
|
+
# Testing
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.coverage
|
|
31
|
+
htmlcov/
|
|
32
|
+
|
|
33
|
+
# Type checking
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
|
|
36
|
+
# DB
|
|
37
|
+
*.db
|
|
38
|
+
*.sqlite3
|
|
39
|
+
|
|
40
|
+
# Logs
|
|
41
|
+
*.log
|
|
42
|
+
logs/
|