diff --git a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py new file mode 100644 index 00000000..ca4319fb --- /dev/null +++ b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py @@ -0,0 +1,140 @@ +import pathlib +import shutil + +from jinja2 import Environment, PackageLoader, select_autoescape + +AVAILABLE_TOOLS: set[str] = { + "list_documents", + "get_document", + "search", + "ask", + "research", + "analyze", +} + +DEFAULT_PREAMBLE = ( + "You are a RAG (Retrieval Augmented Generation) assistant " + "with access to a document knowledge base.\n" + "Use your tools to search and answer questions. " + "Never make up information — always use tools to get facts " + "from the knowledge base." +) + +DEFAULT_DESCRIPTION = ( + "Search, retrieve and analyze documents using RAG (Retrieval Augmented Generation)." +) + + +def _get_env() -> Environment: + return Environment( + loader=PackageLoader("haiku.rag.skill_generator", "templates"), + autoescape=select_autoescape(), + keep_trailing_newline=True, + lstrip_blocks=True, + trim_blocks=True, + ) + + +def validate_metadata(name: str, description: str) -> None: + from haiku.skills import SkillMetadata + + SkillMetadata(name=name, description=description) + if not name.isidentifier(): + raise ValueError(f"{name!r} is not a valid Python identifier") + if not name.islower(): + raise ValueError(f"{name!r} must be lowercase") + + +def validate_tools(tools: list[str]) -> None: + if not tools: + raise ValueError("tools must contain at least one tool") + unknown = set(tools) - AVAILABLE_TOOLS + if unknown: + raise ValueError( + f"Unknown tools: {', '.join(sorted(unknown))}." + f" Available: {', '.join(sorted(AVAILABLE_TOOLS))}" + ) + + +def validate_db_path(db_path: pathlib.Path) -> None: + if not db_path.exists(): + raise ValueError(f"db_path does not exist: {db_path}") + if not db_path.is_dir(): + raise ValueError(f"db_path is not a directory: {db_path}") + + +def validate_output_dir(output_dir: pathlib.Path, name: str) -> None: + if not output_dir.exists(): + raise ValueError(f"output_dir does not exist: {output_dir}") + target = output_dir / f"{name}-skill" + if target.exists(): + raise ValueError(f"Target directory already exists: {target}") + + +def render_templates( + output_dir: pathlib.Path, + name: str, + description: str, + tool_names: list[str], + preamble: str | None = None, +) -> pathlib.Path: + if preamble is None: + preamble = DEFAULT_PREAMBLE + + env = _get_env() + context = { + "name": name, + "description": description, + "tool_names": tool_names, + "preamble": preamble, + } + + result_dir = output_dir / f"{name}-skill" + pkg_dir = result_dir / f"{name}_skill" + assets_dir = pkg_dir / "assets" + assets_dir.mkdir(parents=True) + + # Render pyproject.toml + template = env.get_template("pyproject.toml.j2") + (result_dir / "pyproject.toml").write_text(template.render(context)) + + # Render __init__.py + template = env.get_template("__init__.py.j2") + (pkg_dir / "__init__.py").write_text(template.render(context)) + + # Render SKILL.md + template = env.get_template("SKILL.md.j2") + (pkg_dir / "SKILL.md").write_text(template.render(context)) + + return result_dir + + +def generate_skill( + db_path: pathlib.Path, + output_dir: pathlib.Path, + name: str, + description: str, + tool_names: list[str], + config_path: pathlib.Path | None = None, + preamble: str | None = None, +) -> pathlib.Path: + validate_metadata(name, description) + validate_tools(tool_names) + validate_db_path(db_path) + validate_output_dir(output_dir, name) + + result = render_templates( + output_dir=output_dir, + name=name, + description=description, + tool_names=tool_names, + preamble=preamble, + ) + + assets_dir = result / f"{name}_skill" / "assets" + shutil.copytree(db_path, assets_dir / f"{name}.lancedb") + + if config_path is not None: + shutil.copy2(config_path, assets_dir / "haiku.rag.yaml") + + return result diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 new file mode 100644 index 00000000..8bf07475 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 @@ -0,0 +1,54 @@ +--- +name: {{ name }} +description: {{ description }} +--- + +# {{ name }} + +{{ preamble }} + +## How to decide which tool to use +{% if "ask" in tool_names %} + +**Default rule:** If the user is asking a question, use **ask**. Only use **search** when the user explicitly wants to browse or find passages. +{% endif %} +{% if "list_documents" in tool_names %} +- **list_documents** — Use when the user wants to browse or see what documents are available (e.g., "what documents do you have?", "show me the documents", "list available docs"). +{% endif %} +{% if "get_document" in tool_names %} +- **get_document** — Use when the user wants the full content of a specific document (e.g., "get the paper about X", "show me document Y"). Accepts a document ID, title, or URI — partial matches work. +{% endif %} +{% if "search" in tool_names %} +- **search** — Use when the user wants to browse, explore, or find specific passages across documents (e.g., "search for embeddings", "find mentions of transformers"). Returns all matching results as sources. +{% endif %} +{% if "ask" in tool_names %} +- **ask** — Use for factual questions that need a synthesized answer (e.g., "what is DocLayNet?", "explain the methodology"). Searches, synthesizes, and returns only the chunks actually used as citations. Always include the citations in your response. +{% endif %} +{% if "research" in tool_names %} +- **research** — Deep multi-agent research that produces comprehensive reports. **Only use when the user explicitly requests deep research** (e.g., "do a deep research on X", "research this topic thoroughly"). Never call this tool on your own — it is slow and expensive. +{% endif %} +{% if "analyze" in tool_names %} +- **analyze** — Use for complex analytical questions that require computation, aggregation, or data traversal across documents (e.g., "how many pages?", "compare table 3 across documents", "calculate average word count"). Executes Python code in a sandboxed interpreter. +{% endif %} +{% if "search" in tool_names %} + +## When search returns irrelevant results + +If your first search returns results that clearly don't match the question, **do not keep searching with variations**. Instead: +{% if "ask" in tool_names %} +- Use **ask** if the question is factual +{% endif %} +- Report that the knowledge base doesn't contain relevant information +{% endif %} +{% if "get_document" in tool_names %} + +## When the user mentions a specific document + +If the user says "search in [doc]", "find in [doc]", or "answer from [doc]": +- Extract the **topic** as the `query`/`question` parameter +- Use **get_document** or **list_documents** first to identify the document, then search/ask with a filter + +Examples: +- "search for embeddings in the ML paper" -> first identify "ML paper", then search for "embeddings" +- "what does the DocLayNet paper say about annotations?" -> ask with question="what are the annotation methods?" +{% endif %} diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 new file mode 100644 index 00000000..47c94b89 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 @@ -0,0 +1,77 @@ +from pathlib import Path + +from pydantic import BaseModel, Field + +from haiku.skills.models import Skill +from haiku.skills.parser import parse_skill_md +{% if "ask" in tool_names or "research" in tool_names %} +from haiku.rag.agents.research.models import Citation +{% endif %} +{% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %} +from haiku.rag.tools.document import DocumentInfo +{% endif %} +{% if "ask" in tool_names %} +from haiku.rag.tools.qa import QAHistoryEntry +{% endif %} +{% if "search" in tool_names %} +from haiku.rag.store.models.chunk import SearchResult +{% endif %} +{% if "research" in tool_names %} +from haiku.rag.skills._tools import ResearchEntry +{% endif %} +{% if "analyze" in tool_names %} +from haiku.rag.skills._tools import AnalysisEntry +{% endif %} + +_TOOL_NAMES = {{ tool_names | tojson }} + +_ASSETS_DIR = Path(__file__).resolve().parent / "assets" +_DB_PATH = _ASSETS_DIR / "{{ name }}.lancedb" +_CONFIG_PATH = _ASSETS_DIR / "haiku.rag.yaml" + + +class SkillState(BaseModel): +{% if "ask" in tool_names or "research" in tool_names %} + citations: list[Citation] = Field(default_factory=list) +{% endif %} +{% if "ask" in tool_names %} + qa_history: list[QAHistoryEntry] = Field(default_factory=list) +{% endif %} + document_filter: str | None = None +{% if "search" in tool_names %} + searches: dict[str, list[SearchResult]] = Field(default_factory=dict) +{% endif %} +{% if "search" in tool_names or "list_documents" in tool_names or "get_document" in tool_names %} + documents: list[DocumentInfo] = Field(default_factory=list) +{% endif %} +{% if "research" in tool_names %} + reports: list[ResearchEntry] = Field(default_factory=list) +{% endif %} +{% if "analyze" in tool_names %} + analyses: list[AnalysisEntry] = Field(default_factory=list) +{% endif %} + + +def _get_config(): + if _CONFIG_PATH.exists(): + from haiku.rag.config import AppConfig, load_yaml_config + + return AppConfig.model_validate(load_yaml_config(_CONFIG_PATH)) + from haiku.rag.config import get_config + + return get_config() + + +def create_skill() -> Skill: + from haiku.rag.skills._tools import create_skill_tools + + metadata, instructions = parse_skill_md(Path(__file__).parent / "SKILL.md") + config = _get_config() + tools = create_skill_tools(_DB_PATH, config, SkillState, _TOOL_NAMES) + return Skill( + metadata=metadata, + instructions=instructions, + tools=list(tools.values()), + state_type=SkillState, + state_namespace="{{ name }}", + ) diff --git a/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 b/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 new file mode 100644 index 00000000..29616669 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{ name }}-skill" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "haiku.rag-slim >= 0.35", + "haiku-skills >= 0.10.0", +] + +[project.entry-points."haiku.skills"] +{{ name }} = "{{ name }}_skill:create_skill" diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index ecf177ac..337d44b1 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -1,9 +1,25 @@ from pathlib import Path from typing import Any +from pydantic import BaseModel +from pydantic_ai import RunContext + from haiku.rag.agents.research.models import Citation from haiku.rag.tools.document import DocumentInfo from haiku.rag.tools.qa import QAHistoryEntry +from haiku.skills.state import SkillRunDeps + + +class ResearchEntry(BaseModel): + question: str + title: str + executive_summary: str + + +class AnalysisEntry(BaseModel): + question: str + answer: str + program: str | None = None async def find_relevant_prior_qa( @@ -217,3 +233,203 @@ def update_documents_state( ) if not any(d.id == doc_info.id for d in documents_state): documents_state.append(doc_info) + + +def _get_state(ctx: RunContext[SkillRunDeps], state_type: type) -> Any: + if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, state_type): + return ctx.deps.state + return None + + +def create_skill_tools( + db_path: Path, + config: Any, + state_type: type, + tool_names: list[str], +) -> dict[str, Any]: + """Create tool closures for a skill. + + Returns a dict mapping tool name to async callable. + Each tool extracts state from RunContext, calls the shared implementation, + and updates state. + """ + tools: dict[str, Any] = {} + + if "search" in tool_names: + + async def search( + ctx: RunContext[SkillRunDeps], query: str, limit: int | None = None + ) -> str: + """Search the knowledge base using hybrid search (vector + full-text). + + Returns ranked results with content and metadata. + + Args: + query: The search query. + limit: Maximum number of results. + """ + state = _get_state(ctx, state_type) + formatted, results = await skill_search( + db_path, + config, + query, + limit=limit, + document_filter=state.document_filter if state else None, + ) + if state: + state.searches[query] = results + return formatted + + tools["search"] = search + + if "list_documents" in tool_names: + + async def list_documents( + ctx: RunContext[SkillRunDeps], + limit: int | None = None, + offset: int | None = None, + ) -> list[dict[str, Any]]: + """List documents in the knowledge base with optional pagination. + + Args: + limit: Maximum number of documents to return. + offset: Number of documents to skip. + """ + result = await skill_list_documents(db_path, config, limit, offset) + state = _get_state(ctx, state_type) + if state: + update_documents_state(state.documents, result) + return result + + tools["list_documents"] = list_documents + + if "get_document" in tool_names: + + async def get_document( + ctx: RunContext[SkillRunDeps], query: str + ) -> dict[str, Any] | None: + """Retrieve a document by ID, title, or URI. + + Args: + query: Document ID, title, or URI to look up. + """ + result = await skill_get_document(db_path, config, query) + if result is not None: + state = _get_state(ctx, state_type) + if state: + update_documents_state(state.documents, [result]) + return result + + tools["get_document"] = get_document + + if "ask" in tool_names: + + async def ask(ctx: RunContext[SkillRunDeps], question: str) -> str: + """Ask a question and get an answer with citations from the knowledge base. + + Args: + question: The question to ask. + """ + from haiku.rag.utils import format_citations + + state = _get_state(ctx, state_type) + answer, citations = await skill_ask( + db_path, + config, + question, + qa_history=state.qa_history if state else None, + document_filter=state.document_filter if state else None, + ) + + if state: + next_index = len(state.citations) + 1 + for citation in citations: + citation.index = next_index + next_index += 1 + state.citations.extend(citations) + state.qa_history.append( + QAHistoryEntry( + question=question, answer=answer, citations=citations + ) + ) + + if citations: + answer += "\n\n" + format_citations(citations) + + return answer + + tools["ask"] = ask + + if "research" in tool_names: + + async def research(ctx: RunContext[SkillRunDeps], question: str) -> str: + """Conduct deep multi-agent research on a question. + + Iteratively searches, analyzes, and synthesizes information from the + knowledge base to produce a comprehensive research report. + Only use when the user explicitly requests deep research. + + Args: + question: The research question to investigate. + """ + state = _get_state(ctx, state_type) + formatted, title, executive_summary = await skill_research( + db_path, + config, + question, + document_filter=state.document_filter if state else None, + ) + + if state: + state.reports.append( + ResearchEntry( + question=question, + title=title, + executive_summary=executive_summary, + ) + ) + state.qa_history.append( + QAHistoryEntry(question=question, answer=executive_summary) + ) + + return formatted + + tools["research"] = research + + if "analyze" in tool_names: + + async def analyze( + ctx: RunContext[SkillRunDeps], + question: str, + document: str | None = None, + filter: str | None = None, + ) -> str: + """Answer complex analytical questions using code execution. + + Use this for questions requiring computation, aggregation, or + data traversal across documents. + + Args: + question: The question to answer. + document: Optional document ID or title to pre-load for analysis. + filter: Optional SQL WHERE clause to filter documents. + """ + output, answer, program = await skill_analyze( + db_path, config, question, document=document, filter=filter + ) + + state = _get_state(ctx, state_type) + if state: + state.analyses.append( + AnalysisEntry( + question=question, + answer=answer, + program=program, + ) + ) + + return output + + tools["analyze"] = analyze + + return tools diff --git a/haiku_rag_slim/haiku/rag/skills/rag.py b/haiku_rag_slim/haiku/rag/skills/rag.py index b88bb03b..da81680d 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag.py +++ b/haiku_rag_slim/haiku/rag/skills/rag.py @@ -4,15 +4,14 @@ from pathlib import Path from typing import Any from pydantic import BaseModel, Field -from pydantic_ai import RunContext from haiku.rag.agents.research.models import Citation +from haiku.rag.skills._tools import ResearchEntry from haiku.rag.store.models.chunk import SearchResult from haiku.rag.tools.document import DocumentInfo from haiku.rag.tools.qa import QAHistoryEntry from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md -from haiku.skills.state import SkillRunDeps AGENT_PREAMBLE = """You are a helpful research assistant powered by haiku.rag, a knowledge base system. @@ -22,11 +21,7 @@ CRITICAL RULES: 3. When a skill returns citations, always include them in your response """ - -class ResearchEntry(BaseModel): - question: str - title: str - executive_summary: str +_RAG_TOOLS = ["search", "list_documents", "get_document", "ask", "research"] class RAGState(BaseModel): @@ -64,12 +59,6 @@ def state_metadata() -> StateMetadata: ) -def _get_state(ctx: RunContext[SkillRunDeps]) -> RAGState | None: - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState): - return ctx.deps.state - return None - - def create_skill( db_path: Path | None = None, config: Any = None, @@ -84,6 +73,7 @@ def create_skill( config: haiku.rag AppConfig instance. If None, uses get_config(). """ from haiku.rag.config import get_config + from haiku.rag.skills._tools import create_skill_tools if config is None: config = get_config() @@ -95,152 +85,14 @@ def create_skill( else: db_path = config.storage.data_dir / "haiku.rag.lancedb" - async def search( - ctx: RunContext[SkillRunDeps], query: str, limit: int | None = None - ) -> str: - """Search the knowledge base using hybrid search (vector + full-text). - - Returns ranked results with content and metadata. - - Args: - query: The search query. - limit: Maximum number of results. - """ - from haiku.rag.skills._tools import skill_search - - state = _get_state(ctx) - formatted, results = await skill_search( - db_path, - config, - query, - limit=limit, - document_filter=state.document_filter if state else None, - ) - if state: - state.searches[query] = results - return formatted - - async def list_documents( - ctx: RunContext[SkillRunDeps], - limit: int | None = None, - offset: int | None = None, - ) -> list[dict[str, Any]]: - """List documents in the knowledge base with optional pagination. - - Args: - limit: Maximum number of documents to return. - offset: Number of documents to skip. - """ - from haiku.rag.skills._tools import ( - skill_list_documents, - update_documents_state, - ) - - result = await skill_list_documents(db_path, config, limit, offset) - state = _get_state(ctx) - if state: - update_documents_state(state.documents, result) - return result - - async def get_document( - ctx: RunContext[SkillRunDeps], query: str - ) -> dict[str, Any] | None: - """Retrieve a document by ID, title, or URI. - - Args: - query: Document ID, title, or URI to look up. - """ - from haiku.rag.skills._tools import ( - skill_get_document, - update_documents_state, - ) - - result = await skill_get_document(db_path, config, query) - if result is not None: - state = _get_state(ctx) - if state: - update_documents_state(state.documents, [result]) - return result - - async def ask(ctx: RunContext[SkillRunDeps], question: str) -> str: - """Ask a question and get an answer with citations from the knowledge base. - - Args: - question: The question to ask. - """ - from haiku.rag.skills._tools import skill_ask - from haiku.rag.utils import format_citations - - state = _get_state(ctx) - answer, citations = await skill_ask( - db_path, - config, - question, - qa_history=state.qa_history if state else None, - document_filter=state.document_filter if state else None, - ) - - if state: - next_index = len(state.citations) + 1 - for citation in citations: - citation.index = next_index - next_index += 1 - state.citations.extend(citations) - state.qa_history.append( - QAHistoryEntry(question=question, answer=answer, citations=citations) - ) - - if citations: - answer += "\n\n" + format_citations(citations) - - return answer - - async def research(ctx: RunContext[SkillRunDeps], question: str) -> str: - """Conduct deep multi-agent research on a question. - - Iteratively searches, analyzes, and synthesizes information from the - knowledge base to produce a comprehensive research report. - Only use when the user explicitly requests deep research. - - Args: - question: The research question to investigate. - """ - from haiku.rag.skills._tools import skill_research - - state = _get_state(ctx) - formatted, title, executive_summary = await skill_research( - db_path, - config, - question, - document_filter=state.document_filter if state else None, - ) - - if state: - state.reports.append( - ResearchEntry( - question=question, - title=title, - executive_summary=executive_summary, - ) - ) - state.qa_history.append( - QAHistoryEntry(question=question, answer=executive_summary) - ) - - return formatted + tools = create_skill_tools(db_path, config, RAGState, _RAG_TOOLS) return Skill( metadata=skill_metadata(), source=SkillSource.ENTRYPOINT, path=_skill_path, instructions=instructions(), - tools=[ - search, - list_documents, - get_document, - ask, - research, - ], + tools=list(tools.values()), state_type=STATE_TYPE, state_namespace=STATE_NAMESPACE, ) diff --git a/haiku_rag_slim/haiku/rag/skills/rlm.py b/haiku_rag_slim/haiku/rag/skills/rlm.py index 5fd6e461..463e524f 100644 --- a/haiku_rag_slim/haiku/rag/skills/rlm.py +++ b/haiku_rag_slim/haiku/rag/skills/rlm.py @@ -4,17 +4,10 @@ from pathlib import Path from typing import Any from pydantic import BaseModel -from pydantic_ai import RunContext +from haiku.rag.skills._tools import AnalysisEntry from haiku.skills.models import Skill, SkillMetadata, SkillSource, StateMetadata from haiku.skills.parser import parse_skill_md -from haiku.skills.state import SkillRunDeps - - -class AnalysisEntry(BaseModel): - question: str - answer: str - program: str | None = None class RLMState(BaseModel): @@ -61,6 +54,7 @@ def create_skill( config: haiku.rag AppConfig instance. If None, uses get_config(). """ from haiku.rag.config import get_config + from haiku.rag.skills._tools import create_skill_tools if config is None: config = get_config() @@ -72,45 +66,14 @@ def create_skill( else: db_path = config.storage.data_dir / "haiku.rag.lancedb" - async def analyze( - ctx: RunContext[SkillRunDeps], - question: str, - document: str | None = None, - filter: str | None = None, - ) -> str: - """Answer complex analytical questions using code execution. - - Use this for questions requiring computation, aggregation, or - data traversal across documents. - - Args: - question: The question to answer. - document: Optional document ID or title to pre-load for analysis. - filter: Optional SQL WHERE clause to filter documents. - """ - from haiku.rag.skills._tools import skill_analyze - - output, answer, program = await skill_analyze( - db_path, config, question, document=document, filter=filter - ) - - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RLMState): - ctx.deps.state.analyses.append( - AnalysisEntry( - question=question, - answer=answer, - program=program, - ) - ) - - return output + tools = create_skill_tools(db_path, config, RLMState, ["analyze"]) return Skill( metadata=skill_metadata(), source=SkillSource.ENTRYPOINT, path=_skill_path, instructions=instructions(), - tools=[analyze], + tools=list(tools.values()), state_type=STATE_TYPE, state_namespace=STATE_NAMESPACE, ) diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml index 6a97c92b..82dae172 100644 --- a/haiku_rag_slim/pyproject.toml +++ b/haiku_rag_slim/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "docling-core>=2.70.2", "haiku.skills>=0.10.0", "httpx>=0.28.1", + "jinja2>=3.1.0", "jsonpatch>=1.33", "lancedb==0.29.2", "pathspec>=1.0.4", diff --git a/tests/test_skill_generator.py b/tests/test_skill_generator.py new file mode 100644 index 00000000..743eaa0f --- /dev/null +++ b/tests/test_skill_generator.py @@ -0,0 +1,370 @@ +import pytest + +from haiku.rag.skill_generator import ( + AVAILABLE_TOOLS, + generate_skill, + render_templates, + validate_db_path, + validate_metadata, + validate_output_dir, + validate_tools, +) + + +class TestAvailableTools: + def test_all_tools_present(self): + assert AVAILABLE_TOOLS == { + "list_documents", + "get_document", + "search", + "ask", + "research", + "analyze", + } + + +class TestValidateMetadata: + def test_valid(self): + validate_metadata("recipes", "A skill.") + + def test_valid_with_numbers(self): + validate_metadata("recipes123", "A skill.") + + def test_rejects_underscores(self): + with pytest.raises(ValueError, match="name"): + validate_metadata("my_recipes", "A skill.") + + def test_rejects_hyphens(self): + with pytest.raises(ValueError, match="identifier"): + validate_metadata("my-recipes", "A skill.") + + def test_rejects_uppercase(self): + with pytest.raises(ValueError, match="lowercase"): + validate_metadata("Recipes", "A skill.") + + def test_rejects_empty_name(self): + with pytest.raises(ValueError, match="name"): + validate_metadata("", "A skill.") + + def test_rejects_not_identifier(self): + with pytest.raises(ValueError, match="identifier"): + validate_metadata("123abc", "A skill.") + + def test_rejects_spaces_in_name(self): + with pytest.raises(ValueError, match="name"): + validate_metadata("my recipes", "A skill.") + + def test_rejects_special_chars(self): + with pytest.raises(ValueError, match="name"): + validate_metadata("my@recipes", "A skill.") + + def test_rejects_empty_description(self): + with pytest.raises(ValueError, match="description"): + validate_metadata("recipes", "") + + def test_rejects_too_long_description(self): + with pytest.raises(ValueError, match="description"): + validate_metadata("recipes", "x" * 1025) + + +class TestValidateTools: + def test_valid_single_tool(self): + validate_tools(["search"]) + + def test_valid_multiple_tools(self): + validate_tools(["list_documents", "get_document", "search", "ask"]) + + def test_valid_all_tools(self): + validate_tools(list(AVAILABLE_TOOLS)) + + def test_rejects_empty(self): + with pytest.raises(ValueError, match="at least one"): + validate_tools([]) + + def test_rejects_unknown_tool(self): + with pytest.raises(ValueError, match="Unknown"): + validate_tools(["search", "bogus"]) + + +class TestValidateDbPath: + def test_valid_path(self, tmp_path): + db_path = tmp_path / "test.lancedb" + db_path.mkdir() + validate_db_path(db_path) + + def test_rejects_nonexistent(self, tmp_path): + db_path = tmp_path / "nonexistent.lancedb" + with pytest.raises(ValueError, match="does not exist"): + validate_db_path(db_path) + + def test_rejects_file(self, tmp_path): + db_path = tmp_path / "test.lancedb" + db_path.touch() + with pytest.raises(ValueError, match="not a directory"): + validate_db_path(db_path) + + +class TestValidateOutputDir: + def test_valid_output_dir(self, tmp_path): + validate_output_dir(tmp_path, "recipes") + + def test_rejects_nonexistent(self, tmp_path): + output_dir = tmp_path / "nonexistent" + with pytest.raises(ValueError, match="does not exist"): + validate_output_dir(output_dir, "recipes") + + def test_rejects_existing_target(self, tmp_path): + target = tmp_path / "recipes-skill" + target.mkdir() + with pytest.raises(ValueError, match="already exists"): + validate_output_dir(tmp_path, "recipes") + + +class TestRenderTemplates: + def test_output_structure(self, tmp_path): + result = render_templates( + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["list_documents", "get_document", "search", "ask"], + ) + assert result == tmp_path / "recipes-skill" + assert result.is_dir() + pkg = result / "recipes_skill" + assert (pkg / "__init__.py").is_file() + assert (pkg / "SKILL.md").is_file() + assert (pkg / "assets").is_dir() + + def test_tool_names_list_matches_selection(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="docs", + description="A docs skill.", + tool_names=["search", "ask"], + ) + init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" + content = init.read_text() + assert '["search", "ask"]' in content + + def test_create_skill_tools_called(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="docs", + description="A docs skill.", + tool_names=list(AVAILABLE_TOOLS), + ) + init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" + content = init.read_text() + assert ( + "create_skill_tools(_DB_PATH, config, SkillState, _TOOL_NAMES)" in content + ) + + def test_tool_names_in_init(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search", "ask"], + ) + init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" + content = init.read_text() + assert '"search"' in content + assert '"ask"' in content + + def test_pyproject_toml(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search"], + ) + toml = tmp_path / "recipes-skill" / "pyproject.toml" + content = toml.read_text() + assert 'name = "recipes-skill"' in content + assert 'description = "A recipe skill."' in content + assert 'recipes = "recipes_skill:create_skill"' in content + assert "haiku.rag-slim >= 0.35" in content + + def test_skill_md_conditionals(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="docs", + description="A docs skill.", + tool_names=["search"], + ) + skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" + content = skill_md.read_text() + assert "**search**" in content + assert "**ask**" not in content + assert "**list_documents**" not in content + assert "**research**" not in content + assert "**analyze**" not in content + + def test_skill_md_includes_all_selected(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="docs", + description="A docs skill.", + tool_names=["search", "ask", "analyze"], + ) + skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" + content = skill_md.read_text() + assert "**search**" in content + assert "**ask**" in content + assert "**analyze**" in content + + def test_custom_preamble(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="docs", + description="A docs skill.", + tool_names=["search"], + preamble="You are a docs expert.", + ) + skill_md = tmp_path / "docs-skill" / "docs_skill" / "SKILL.md" + content = skill_md.read_text() + assert "You are a docs expert." in content + + def test_state_namespace_is_skill_name(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search"], + ) + init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" + content = init.read_text() + assert 'state_namespace="recipes"' in content + + def test_analyze_state_fields(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="docs", + description="A docs skill.", + tool_names=["search", "analyze"], + ) + init = tmp_path / "docs-skill" / "docs_skill" / "__init__.py" + content = init.read_text() + assert "analyses" in content + + def test_imports_from_shared_tools(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search", "ask", "analyze"], + ) + init = tmp_path / "recipes-skill" / "recipes_skill" / "__init__.py" + content = init.read_text() + assert "from haiku.rag.skills._tools import create_skill_tools" in content + + def test_generated_python_is_valid(self, tmp_path): + render_templates( + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=list(AVAILABLE_TOOLS), + ) + pkg = tmp_path / "recipes-skill" / "recipes_skill" + for py_file in pkg.glob("*.py"): + source = py_file.read_text() + compile(source, str(py_file), "exec") + + +def _make_fake_lancedb(path): + path.mkdir() + (path / "data.lance").touch() + return path + + +class TestGenerateSkill: + def test_end_to_end(self, tmp_path): + db_path = _make_fake_lancedb(tmp_path / "test.lancedb") + result = generate_skill( + db_path=db_path, + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search", "ask"], + ) + assert result == tmp_path / "recipes-skill" + assets = result / "recipes_skill" / "assets" + assert (assets / "recipes.lancedb").is_dir() + assert (assets / "recipes.lancedb" / "data.lance").is_file() + assert not (assets / "haiku.rag.yaml").exists() + + def test_with_config(self, tmp_path): + db_path = _make_fake_lancedb(tmp_path / "test.lancedb") + config_file = tmp_path / "haiku.rag.yaml" + config_file.write_text("storage:\n data_dir: /tmp\n") + result = generate_skill( + db_path=db_path, + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search"], + config_path=config_file, + ) + assets = result / "recipes_skill" / "assets" + assert (assets / "haiku.rag.yaml").is_file() + assert (assets / "haiku.rag.yaml").read_text() == ( + "storage:\n data_dir: /tmp\n" + ) + + def test_rejects_invalid_name(self, tmp_path): + db_path = _make_fake_lancedb(tmp_path / "test.lancedb") + with pytest.raises(ValueError, match="name"): + generate_skill( + db_path=db_path, + output_dir=tmp_path, + name="Bad-Name", + description="A skill.", + tool_names=["search"], + ) + + def test_rejects_invalid_tools(self, tmp_path): + db_path = _make_fake_lancedb(tmp_path / "test.lancedb") + with pytest.raises(ValueError, match="Unknown"): + generate_skill( + db_path=db_path, + output_dir=tmp_path, + name="recipes", + description="A skill.", + tool_names=["bogus"], + ) + + def test_rejects_nonexistent_db(self, tmp_path): + with pytest.raises(ValueError, match="does not exist"): + generate_skill( + db_path=tmp_path / "nope.lancedb", + output_dir=tmp_path, + name="recipes", + description="A skill.", + tool_names=["search"], + ) + + def test_rejects_existing_target(self, tmp_path): + db_path = _make_fake_lancedb(tmp_path / "test.lancedb") + (tmp_path / "recipes-skill").mkdir() + with pytest.raises(ValueError, match="already exists"): + generate_skill( + db_path=db_path, + output_dir=tmp_path, + name="recipes", + description="A skill.", + tool_names=["search"], + ) + + def test_with_preamble(self, tmp_path): + db_path = _make_fake_lancedb(tmp_path / "test.lancedb") + result = generate_skill( + db_path=db_path, + output_dir=tmp_path, + name="recipes", + description="A recipe skill.", + tool_names=["search"], + preamble="You are a recipe expert.", + ) + skill_md = result / "recipes_skill" / "SKILL.md" + content = skill_md.read_text() + assert "You are a recipe expert." in content diff --git a/uv.lock b/uv.lock index 62ac6ea6..2dc071cf 100644 --- a/uv.lock +++ b/uv.lock @@ -1506,6 +1506,7 @@ dependencies = [ { name = "docling-core" }, { name = "haiku-skills" }, { name = "httpx" }, + { name = "jinja2" }, { name = "jsonpatch" }, { name = "lancedb" }, { name = "pathspec" }, @@ -1571,6 +1572,7 @@ requires-dist = [ { name = "docling-core", specifier = ">=2.70.2" }, { name = "haiku-skills", specifier = ">=0.10.0" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "jinja2", specifier = ">=3.1.0" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "lancedb", specifier = "==0.29.2" }, { name = "mxbai-rerank", marker = "extra == 'mxbai'", specifier = ">=0.1.6" },