feat(helper): add interactive Q&A helper command

Add `markitect helper <QUESTION>` CLI command that answers questions
about markitect using its own documentation as LLM context. Uses
OpenRouter with openrouter/aurora-alpha by default; model is
configurable via --model flag or MARKITECT_HELPER_MODEL env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 23:28:20 +01:00
parent 41773f1320
commit 69e2ec25ff
5 changed files with 308 additions and 0 deletions

View File

@@ -7095,6 +7095,13 @@ try:
except ImportError:
pass # Prompts module not available
# Register helper Q&A command
try:
from markitect.helper.cli import helper_command
cli.add_command(helper_command)
except ImportError:
pass # Helper module not available
# Make cli function available as main entry point
main = cli

View File

@@ -0,0 +1,6 @@
"""
markitect.helper — Interactive Q&A helper for MarkiTect.
Provides a CLI command that answers questions about markitect
using its own documentation as knowledge context.
"""

101
markitect/helper/cli.py Normal file
View File

@@ -0,0 +1,101 @@
"""
CLI command for the markitect helper.
"""
import os
import sys
import click
from markitect.helper.knowledge import collect_knowledge
DEFAULT_PROVIDER = "openrouter"
DEFAULT_MODEL = "openrouter/aurora-alpha"
MODEL_ENV_VAR = "MARKITECT_HELPER_MODEL"
SYSTEM_PROMPT_TEMPLATE = (
"You are a MarkiTect expert assistant. Answer the user's question "
"based on the following MarkiTect documentation. Be concise and "
"accurate. If the documentation does not cover the question, say so.\n\n"
"{knowledge}"
)
@click.command("helper")
@click.argument("question", nargs=-1, required=True)
@click.option(
"--provider", "-p",
default=DEFAULT_PROVIDER,
type=click.Choice(["openrouter", "claude-code", "gemini", "openai"]),
show_default=True,
help="LLM provider to use.",
)
@click.option(
"--model", "-m",
default=None,
help=(
f"Model name. Overrides {MODEL_ENV_VAR} env var and the default "
f"({DEFAULT_MODEL})."
),
)
def helper_command(question, provider, model):
"""Ask a question about MarkiTect and get an answer from the docs.
\b
Examples:
markitect helper "What is markitect?"
markitect helper How do schemas work
markitect helper -m anthropic/claude-sonnet-4 "Explain templates"
"""
from markitect.llm import create_adapter
from markitect.llm.exceptions import LLMConfigurationError, LLMError
from markitect.prompts.execution.models import RunConfig
# Join multi-word question into a single string.
question_text = " ".join(question)
if not question_text.strip():
click.echo("Error: empty question.", err=True)
sys.exit(1)
# Resolve model: --model flag > env var > default.
resolved_model = model or os.environ.get(MODEL_ENV_VAR) or DEFAULT_MODEL
# Build knowledge context.
click.echo("Loading markitect knowledge base...", err=True)
knowledge = collect_knowledge()
if not knowledge:
click.echo("Warning: no documentation files found.", err=True)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(knowledge=knowledge)
# Create adapter.
try:
adapter = create_adapter(
provider=provider,
model=resolved_model,
system_prompt=system_prompt,
)
except LLMConfigurationError as exc:
click.echo(f"Configuration error: {exc}", err=True)
if "api" in str(exc).lower() or "key" in str(exc).lower():
click.echo(
"Hint: set OPENROUTER_API_KEY (or the relevant provider key) "
"in your environment.",
err=True,
)
sys.exit(1)
# Execute the question.
click.echo(f"Asking {provider} ({resolved_model})...", err=True)
try:
config = RunConfig(
model_name=resolved_model,
max_tokens=4000,
temperature=0.3,
)
response = adapter.execute_prompt(question_text, config)
except LLMError as exc:
click.echo(f"LLM error: {exc}", err=True)
sys.exit(1)
click.echo(response.content)

View File

@@ -0,0 +1,99 @@
"""
Knowledge loader for the markitect helper.
Reads markitect's own documentation files and returns them as a
concatenated string for use as LLM context.
"""
import importlib
from pathlib import Path
from typing import List
# Docs to load relative to the project root, in priority order.
_DOC_PATHS: List[str] = [
"INTRODUCTION.md",
"docs/CLI_TUTORIAL.md",
"docs/PROJECT_STRUCTURE.md",
"docs/SCHEMA_MANAGEMENT_GUIDE.md",
"docs/PLUGIN_SYSTEM.md",
"docs/ERROR_HANDLING_STRATEGY.md",
"docs/architecture/CAPABILITIES_ARCHITECTURE.md",
"docs/architecture/caching-system.md",
"docs/ASSET_MANAGEMENT_USER_GUIDE.md",
"docs/graphql_interface.md",
"examples/content-generator/TUTORIAL.md",
"examples/infospace-with-history/TUTORIAL.md",
]
# Glob patterns (relative to project root) for additional docs.
_DOC_GLOBS: List[str] = [
"docs/user-guides/*.md",
]
# Modules whose docstrings to include.
_MODULE_DOCSTRINGS: List[str] = [
"markitect.prompts",
"markitect.llm",
]
def _find_project_root() -> Path:
"""Return the markitect project root directory.
Walks up from this file (markitect/helper/knowledge.py) to find the
directory that contains ``markitect/`` as a package *and* has a
``pyproject.toml`` or ``INTRODUCTION.md``.
"""
candidate = Path(__file__).resolve().parent.parent.parent
# Verify we landed in the right place.
if (candidate / "pyproject.toml").exists() or (candidate / "INTRODUCTION.md").exists():
return candidate
# Fallback: try CWD.
cwd = Path.cwd()
if (cwd / "markitect").is_dir():
return cwd
return candidate
def collect_knowledge() -> str:
"""Load markitect documentation and return as a single string.
Reads documentation files from the project root, concatenates them
with section headers, and appends relevant module docstrings.
Missing files are silently skipped.
"""
root = _find_project_root()
sections: List[str] = []
# Fixed-path documents.
for rel_path in _DOC_PATHS:
filepath = root / rel_path
if filepath.is_file():
try:
content = filepath.read_text(encoding="utf-8")
sections.append(f"## {rel_path}\n\n{content}")
except OSError:
continue
# Glob-pattern documents.
for pattern in _DOC_GLOBS:
for filepath in sorted(root.glob(pattern)):
if filepath.is_file():
rel = filepath.relative_to(root)
try:
content = filepath.read_text(encoding="utf-8")
sections.append(f"## {rel}\n\n{content}")
except OSError:
continue
# Module docstrings.
for mod_name in _MODULE_DOCSTRINGS:
try:
mod = importlib.import_module(mod_name)
if mod.__doc__:
sections.append(f"## Module: {mod_name}\n\n{mod.__doc__.strip()}")
except ImportError:
continue
return "\n\n---\n\n".join(sections)