diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml new file mode 100644 index 0000000..26126cd --- /dev/null +++ b/.github/workflows/test-template.yml @@ -0,0 +1,86 @@ +name: Test Cookiecutter Template + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + test-template: + name: Test Cookiecutter Template + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if template directory changed + id: template-changed + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- template/) + if [ -z "$CHANGED" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No changes in template/ — skipping template tests." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Changes detected in template/:" + echo "$CHANGED" + fi + + - name: Set up Python + if: steps.template-changed.outputs.changed == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install uv and cookiecutter + if: steps.template-changed.outputs.changed == 'true' + run: pip install uv cookiecutter + + - name: Run cookiecutter template + if: steps.template-changed.outputs.changed == 'true' + run: | + cookiecutter template/ --no-input project_name="PR Test" + + - name: Install project dependencies + if: steps.template-changed.outputs.changed == 'true' + working-directory: pr-test + run: uv sync + + - name: Validate QType YAML + if: steps.template-changed.outputs.changed == 'true' + working-directory: pr-test + env: + OPENAI_API_KEY: DUMMY_KEY_FOR_VALIDATION + run: uv run qtype validate pr-test.qtype.yaml + + - name: Regenerate tools YAML and check for drift + if: steps.template-changed.outputs.changed == 'true' + working-directory: pr-test + run: | + uv run qtype convert module pr_test.tools \ + -o pr-test.tools.qtype.yaml.new + if ! diff -q pr-test.tools.qtype.yaml pr-test.tools.qtype.yaml.new; then + echo "❌ tools.qtype.yaml is out of sync with tools.py." + echo "Run 'qtype convert module pr_test.tools -o pr-test.tools.qtype.yaml'" + echo "and commit the result." + diff pr-test.tools.qtype.yaml pr-test.tools.qtype.yaml.new + exit 1 + fi + echo "✅ tools.qtype.yaml matches tools.py" + + - name: Set up Docker Buildx + if: steps.template-changed.outputs.changed == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + if: steps.template-changed.outputs.changed == 'true' + working-directory: pr-test + run: docker build -t pr-test . diff --git a/template/cookiecutter.json b/template/cookiecutter.json new file mode 100644 index 0000000..a24951a --- /dev/null +++ b/template/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "project_name": "My Project", + "__slug": "{{ cookiecutter.project_name.lower().replace(' ', '-') }}", + "__module": "{{ cookiecutter.__slug.replace('-', '_') }}" +} diff --git a/template/{{cookiecutter.__slug}}/.vscode/launch.json b/template/{{cookiecutter.__slug}}/.vscode/launch.json new file mode 100644 index 0000000..107f24b --- /dev/null +++ b/template/{{cookiecutter.__slug}}/.vscode/launch.json @@ -0,0 +1,42 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "QType Serve (debug with reload)", + "type": "debugpy", + "request": "launch", + "module": "qtype.cli", + "args": [ + "serve", + "--reload", + "{{ cookiecutter.__slug }}.qtype.yaml" + ], + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "justMyCode": false, + "envFile": "${workspaceFolder}/.env" + }, + { + "name": "QType Run (CLI)", + "type": "debugpy", + "request": "launch", + "module": "qtype.cli", + "args": [ + "run", + "{{ cookiecutter.__slug }}.qtype.yaml", + "${input:cliArgs}" + ], + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "justMyCode": false, + "envFile": "${workspaceFolder}/.env" + } + ], + "inputs": [ + { + "id": "cliArgs", + "description": "Additional CLI arguments (e.g. '--flow ask --input \\'{}\\'')", + "type": "promptString" + } + ] +} diff --git a/template/{{cookiecutter.__slug}}/Dockerfile b/template/{{cookiecutter.__slug}}/Dockerfile new file mode 100644 index 0000000..aff00ab --- /dev/null +++ b/template/{{cookiecutter.__slug}}/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install uv for fast dependency management +RUN pip install --no-cache-dir uv + +# Copy project definition first and install dependencies (cached layer) +COPY pyproject.toml . +RUN uv pip install --system . + +# Copy the Python module source (changes more often than dependencies) +COPY {{ cookiecutter.__module }}/ ./{{ cookiecutter.__module }}/ + +# Copy QType application files +COPY *.qtype.yaml . + +# Expose the default QType server port +EXPOSE 8000 + +# Start the QType server +CMD ["qtype", "serve", "{{ cookiecutter.__slug }}.qtype.yaml"] diff --git a/template/{{cookiecutter.__slug}}/README.md b/template/{{cookiecutter.__slug}}/README.md new file mode 100644 index 0000000..47d1ea9 --- /dev/null +++ b/template/{{cookiecutter.__slug}}/README.md @@ -0,0 +1,85 @@ +# {{ cookiecutter.project_name }} + +A QType AI application. This project was generated from the +[QType cookiecutter template](https://github.com/bazaarvoice/qtype/tree/main/template). + +## Prerequisites + +- Python 3.10+ +- [uv](https://docs.astral.sh/uv/) (recommended) or pip +- An OpenAI API key (set `OPENAI_API_KEY` in a `.env` file) + +## Setup + +```bash +# Install dependencies +uv sync + +# Create a .env file with your API key +echo "OPENAI_API_KEY=sk-..." > .env +``` + +## Validate the application + +Check your QType YAML for syntax errors, reference issues, and semantic problems: + +```bash +qtype validate {{ cookiecutter.__slug }}.qtype.yaml +``` + +## Run the application + +Start the QType server with auto-reload enabled for development: + +```bash +qtype serve --reload {{ cookiecutter.__slug }}.qtype.yaml +``` + +The server will start at . Open your browser to see the +interactive UI. + +## Run from the command line + +Invoke a flow directly without starting the server: + +```bash +qtype run {{ cookiecutter.__slug }}.qtype.yaml \ + --flow ask \ + --input '{"user_name": "Alice", "user_question": "What is machine learning?"}' +``` + +## Generate tool definitions from Python + +Regenerate `{{ cookiecutter.__slug }}.tools.qtype.yaml` from +`{{ cookiecutter.__module }}/tools.py` whenever you add or modify tool functions: + +```bash +qtype convert module {{ cookiecutter.__module }}.tools \ + -o {{ cookiecutter.__slug }}.tools.qtype.yaml +``` + +## Project structure + +``` +{{ cookiecutter.__slug }}/ +├── {{ cookiecutter.__module }}/ # Python tools package +│ ├── __init__.py +│ └── tools.py # Custom tool functions +├── {{ cookiecutter.__slug }}.qtype.yaml # Main QType application +├── {{ cookiecutter.__slug }}.tools.qtype.yaml # Generated tool definitions +├── pyproject.toml # Project metadata and dependencies +├── Dockerfile # Container image definition +└── .vscode/ + └── launch.json # VS Code debug configurations +``` + +## Docker + +Build and run the application in a container: + +```bash +docker build -t {{ cookiecutter.__slug }} . + +# Pass API keys at runtime (never bake secrets into the image) +docker run -p 8000:8000 --env-file .env {{ cookiecutter.__slug }} +``` diff --git a/template/{{cookiecutter.__slug}}/pyproject.toml b/template/{{cookiecutter.__slug}}/pyproject.toml new file mode 100644 index 0000000..486a5c8 --- /dev/null +++ b/template/{{cookiecutter.__slug}}/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ cookiecutter.__slug }}" +version = "0.0.1" +description = "{{ cookiecutter.project_name }} - A QType AI Application" +requires-python = ">=3.10" +dependencies = [ + "qtype[interpreter,mcp]", +] + +[tool.hatch.build.targets.wheel] +packages = ["{{ cookiecutter.__module }}"] + +[tool.uv] +package = true diff --git a/template/{{cookiecutter.__slug}}/{{cookiecutter.__module}}/__init__.py b/template/{{cookiecutter.__slug}}/{{cookiecutter.__module}}/__init__.py new file mode 100644 index 0000000..b4fcb54 --- /dev/null +++ b/template/{{cookiecutter.__slug}}/{{cookiecutter.__module}}/__init__.py @@ -0,0 +1 @@ +"""{{ cookiecutter.project_name }} tools package.""" diff --git a/template/{{cookiecutter.__slug}}/{{cookiecutter.__module}}/tools.py b/template/{{cookiecutter.__slug}}/{{cookiecutter.__module}}/tools.py new file mode 100644 index 0000000..32dda25 --- /dev/null +++ b/template/{{cookiecutter.__slug}}/{{cookiecutter.__module}}/tools.py @@ -0,0 +1,58 @@ +"""Tools module for {{ cookiecutter.project_name }}. + +This module provides custom tools that can be used in QType flows. +Run the following to regenerate the QType tool definitions: + + qtype convert module {{ cookiecutter.__module }}.tools \\ + -o {{ cookiecutter.__slug }}.tools.qtype.yaml +""" + +from pydantic import BaseModel + + +class TextAnalysisResult(BaseModel): + """Result of analyzing a block of text.""" + + word_count: int + char_count: int + sentence_count: int + avg_word_length: float + + +def greet(name: str) -> str: + """Greet a person by name. + + Args: + name: The person's name to greet. + + Returns: + A personalized greeting message. + """ + return f"Hello, {name}! Welcome to {{ cookiecutter.project_name }}." + + +def analyze_text(text: str) -> TextAnalysisResult: + """Analyze text and return statistics. + + Args: + text: The text to analyze. + + Returns: + TextAnalysisResult containing word count, character count, + sentence count, and average word length. + """ + words = text.split() + sentences = [ + s.strip() + for s in text.replace("!", ".").replace("?", ".").split(".") + if s.strip() + ] + avg_word_length = ( + sum(len(w) for w in words) / len(words) if words else 0.0 + ) + return TextAnalysisResult( + word_count=len(words), + char_count=len(text), + sentence_count=len(sentences), + avg_word_length=round(avg_word_length, 2), + ) diff --git a/template/{{cookiecutter.__slug}}/{{cookiecutter.__slug}}.qtype.yaml b/template/{{cookiecutter.__slug}}/{{cookiecutter.__slug}}.qtype.yaml new file mode 100644 index 0000000..d8ffcb2 --- /dev/null +++ b/template/{{cookiecutter.__slug}}/{{cookiecutter.__slug}}.qtype.yaml @@ -0,0 +1,98 @@ +# {{ cookiecutter.project_name }} +# +# A QType AI application that greets users and answers questions. +# +# Validate with: +# qtype validate {{ cookiecutter.__slug }}.qtype.yaml +# +# Run with: +# qtype serve --reload {{ cookiecutter.__slug }}.qtype.yaml +# +# Or from the CLI: +# qtype run {{ cookiecutter.__slug }}.qtype.yaml \ +# --flow ask \ +# --input '{"user_name": "Alice", "user_question": "What is machine learning?"}' + +id: {{ cookiecutter.__slug }} +description: {{ cookiecutter.project_name }} - A QType AI application + +# Import tools defined in {{ cookiecutter.__module }}/tools.py +references: + - !include {{ cookiecutter.__slug }}.tools.qtype.yaml + +auths: + - type: api_key + id: openai_auth + api_key: ${OPENAI_API_KEY} + host: https://api.openai.com + +models: + - type: Model + id: gpt-4o-mini + provider: openai + model_id: gpt-4o-mini + auth: openai_auth + inference_params: + temperature: 0.7 + +flows: + - id: ask + description: > + Greets the user by name and answers their question using an LLM. + Demonstrates tool invocation chained with an LLM inference step. + inputs: + - user_name + - user_question + outputs: + - answer + + variables: + - id: user_name + type: text + - id: user_question + type: text + - id: greeting + type: text + - id: formatted_prompt + type: text + - id: answer + type: text + + steps: + # Step 1: Call the greet tool with the user's name + - id: greet_user + type: InvokeTool + tool: {{ cookiecutter.__module }}.tools.greet + input_bindings: + name: user_name + output_bindings: + greet_result: greeting + outputs: + - greeting + + # Step 2: Build the LLM prompt using the greeting and the question + - id: build_prompt + type: PromptTemplate + template: | + {greeting} + + The user asks: {user_question} + + Please provide a clear, helpful, and concise answer. + inputs: + - greeting + - user_question + outputs: + - formatted_prompt + + # Step 3: Send the prompt to the LLM and get an answer + - id: generate_answer + type: LLMInference + model: gpt-4o-mini + system_message: | + You are a helpful AI assistant for {{ cookiecutter.project_name }}. + Be friendly, concise, and accurate in your responses. + inputs: + - formatted_prompt + outputs: + - answer diff --git a/template/{{cookiecutter.__slug}}/{{cookiecutter.__slug}}.tools.qtype.yaml b/template/{{cookiecutter.__slug}}/{{cookiecutter.__slug}}.tools.qtype.yaml new file mode 100644 index 0000000..e4b6777 --- /dev/null +++ b/template/{{cookiecutter.__slug}}/{{cookiecutter.__slug}}.tools.qtype.yaml @@ -0,0 +1,41 @@ +auths: [] +description: Tools created from Python module {{ cookiecutter.__module }}.tools +flows: [] +id: {{ cookiecutter.__module }}.tools +indexes: [] +memories: [] +models: [] +references: [] +tools: +- description: Analyze text and return statistics. + function_name: analyze_text + id: {{ cookiecutter.__module }}.tools.analyze_text + inputs: + - id: text + type: text + module_path: {{ cookiecutter.__module }}.tools + name: analyze_text + outputs: + - id: analyze_text_result + type: TextAnalysisResult + type: PythonFunctionTool +- description: Greet a person by name. + function_name: greet + id: {{ cookiecutter.__module }}.tools.greet + inputs: + - id: name + type: text + module_path: {{ cookiecutter.__module }}.tools + name: greet + outputs: + - id: greet_result + type: text + type: PythonFunctionTool +types: +- description: Result of analyzing a block of text. + id: TextAnalysisResult + properties: + avg_word_length: float + char_count: int + sentence_count: int + word_count: int