From a778ea68a80cd4c75aa884e08efd3ffc1a1e735a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 14:46:28 +0200 Subject: [PATCH 1/7] Extract reusable tool functions from skill implementations --- haiku_rag_slim/haiku/rag/skills/_tools.py | 219 +++++++++++++++++++++ haiku_rag_slim/haiku/rag/skills/rag.py | 227 ++++++---------------- haiku_rag_slim/haiku/rag/skills/rlm.py | 15 +- 3 files changed, 280 insertions(+), 181 deletions(-) create mode 100644 haiku_rag_slim/haiku/rag/skills/_tools.py diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py new file mode 100644 index 00000000..ecf177ac --- /dev/null +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -0,0 +1,219 @@ +from pathlib import Path +from typing import Any + +from haiku.rag.agents.research.models import Citation +from haiku.rag.tools.document import DocumentInfo +from haiku.rag.tools.qa import QAHistoryEntry + + +async def find_relevant_prior_qa( + qa_history: list[QAHistoryEntry], + query: str, + config: Any, +) -> list[QAHistoryEntry]: + from haiku.rag.embeddings import get_embedder + from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD + from haiku.rag.utils import cosine_similarity + + if not qa_history: + return [] + + embedder = get_embedder(config) + query_embedding = await embedder.embed_query(query) + + to_embed = [] + to_embed_indices = [] + for i, qa in enumerate(qa_history): + if qa.question_embedding is None: + to_embed.append(qa.question) + to_embed_indices.append(i) + + if to_embed: + new_embeddings = await embedder.embed_documents(to_embed) + for i, idx in enumerate(to_embed_indices): + qa_history[idx].question_embedding = new_embeddings[i] + + matches = [] + for qa in qa_history: + if qa.question_embedding is not None: + similarity = cosine_similarity(query_embedding, qa.question_embedding) + if similarity >= PRIOR_ANSWER_RELEVANCE_THRESHOLD: + matches.append(qa) + + return matches + + +async def skill_search( + db_path: Path, + config: Any, + query: str, + limit: int | None = None, + document_filter: str | None = None, +) -> tuple[str, list]: + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + results = await rag.search( + query, + limit=limit, + filter=document_filter, + ) + results = await rag.expand_context(results) + + formatted = "\n\n---\n\n".join( + r.format_for_agent(rank=i + 1, total=len(results)) + for i, r in enumerate(results) + ) + return formatted, list(results) + + +async def skill_list_documents( + db_path: Path, + config: Any, + limit: int | None = None, + offset: int | None = None, +) -> list[dict[str, Any]]: + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + documents = await rag.list_documents(limit, offset) + return [ + { + "id": doc.id, + "title": doc.title, + "uri": doc.uri, + "metadata": doc.metadata, + "created_at": str(doc.created_at), + "updated_at": str(doc.updated_at), + } + for doc in documents + ] + + +async def skill_get_document( + db_path: Path, + config: Any, + query: str, +) -> dict[str, Any] | None: + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + document = await rag.resolve_document(query) + if document is None: + return None + return { + "id": document.id, + "content": document.content, + "title": document.title, + "uri": document.uri, + "metadata": document.metadata, + "created_at": str(document.created_at), + "updated_at": str(document.updated_at), + } + + +async def skill_ask( + db_path: Path, + config: Any, + question: str, + qa_history: list[QAHistoryEntry] | None = None, + document_filter: str | None = None, +) -> tuple[str, list[Citation]]: + from haiku.rag.client import HaikuRAG + from haiku.rag.utils import format_citations + + ask_question = question + if qa_history: + matches = await find_relevant_prior_qa(qa_history, question, config) + if matches: + prior_parts = [] + for qa in matches: + part = f"Q: {qa.question}\nA: {qa.answer}" + if qa.citations: + part += "\n" + format_citations(qa.citations) + prior_parts.append(part) + ask_question = ( + "Context from prior questions in this session:\n\n" + + "\n\n---\n\n".join(prior_parts) + + "\n\n---\n\nCurrent question: " + + question + ) + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + answer, citations = await rag.ask( + ask_question, + filter=document_filter, + ) + + return answer, citations + + +async def skill_research( + db_path: Path, + config: Any, + question: str, + document_filter: str | None = None, +) -> tuple[str, str, str]: + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + report = await rag.research(question, filter=document_filter) + + parts = [ + f"# {report.title}", + f"\n## Executive Summary\n{report.executive_summary}", + ] + if report.main_findings: + parts.append("\n## Main Findings") + for finding in report.main_findings: + parts.append(f"- {finding}") + if report.conclusions: + parts.append("\n## Conclusions") + for conclusion in report.conclusions: + parts.append(f"- {conclusion}") + if report.limitations: + parts.append("\n## Limitations") + for limitation in report.limitations: + parts.append(f"- {limitation}") + if report.recommendations: + parts.append("\n## Recommendations") + for rec in report.recommendations: + parts.append(f"- {rec}") + parts.append(f"\n## Sources\n{report.sources_summary}") + + formatted = "\n".join(parts) + return formatted, report.title, report.executive_summary + + +async def skill_analyze( + db_path: Path, + config: Any, + question: str, + document: str | None = None, + filter: str | None = None, +) -> tuple[str, str, str | None]: + from haiku.rag.client import HaikuRAG + + async with HaikuRAG(db_path, config=config, read_only=True) as rag: + documents = [document] if document else None + result = await rag.rlm(question, documents=documents, filter=filter) + output = result.answer + if result.program: + output += f"\n\nProgram:\n{result.program}" + + return output, result.answer, result.program + + +def update_documents_state( + documents_state: list[DocumentInfo], + doc_dicts: list[dict[str, Any]], +) -> None: + for doc_dict in doc_dicts: + doc_info = DocumentInfo( + id=str(doc_dict["id"]), + title=doc_dict["title"] or "Untitled", + uri=doc_dict.get("uri") or "", + created=doc_dict.get("created_at", ""), + ) + if not any(d.id == doc_info.id for d in documents_state): + documents_state.append(doc_info) diff --git a/haiku_rag_slim/haiku/rag/skills/rag.py b/haiku_rag_slim/haiku/rag/skills/rag.py index 01283b4d..b88bb03b 100644 --- a/haiku_rag_slim/haiku/rag/skills/rag.py +++ b/haiku_rag_slim/haiku/rag/skills/rag.py @@ -64,6 +64,12 @@ 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, @@ -89,40 +95,6 @@ def create_skill( else: db_path = config.storage.data_dir / "haiku.rag.lancedb" - async def _find_relevant_prior_qa( - state: RAGState, query: str - ) -> list[QAHistoryEntry]: - from haiku.rag.embeddings import get_embedder - from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD - from haiku.rag.utils import cosine_similarity - - if not state.qa_history: - return [] - - embedder = get_embedder(config) - query_embedding = await embedder.embed_query(query) - - to_embed = [] - to_embed_indices = [] - for i, qa in enumerate(state.qa_history): - if qa.question_embedding is None: - to_embed.append(qa.question) - to_embed_indices.append(i) - - if to_embed: - new_embeddings = await embedder.embed_documents(to_embed) - for i, idx in enumerate(to_embed_indices): - state.qa_history[idx].question_embedding = new_embeddings[i] - - matches = [] - for qa in state.qa_history: - if qa.question_embedding is not None: - similarity = cosine_similarity(query_embedding, qa.question_embedding) - if similarity >= PRIOR_ANSWER_RELEVANCE_THRESHOLD: - matches.append(qa) - - return matches - async def search( ctx: RunContext[SkillRunDeps], query: str, limit: int | None = None ) -> str: @@ -134,29 +106,19 @@ def create_skill( query: The search query. limit: Maximum number of results. """ - from haiku.rag.client import HaikuRAG + from haiku.rag.skills._tools import skill_search - state = ( - ctx.deps.state - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState) - else None + state = _get_state(ctx) + formatted, results = await skill_search( + db_path, + config, + query, + limit=limit, + document_filter=state.document_filter if state else None, ) - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - results = await rag.search( - query, - limit=limit, - filter=state.document_filter if state else None, - ) - results = await rag.expand_context(results) - if state: - state.searches[query] = list(results) - - return "\n\n---\n\n".join( - r.format_for_agent(rank=i + 1, total=len(results)) - for i, r in enumerate(results) - ) + state.searches[query] = results + return formatted async def list_documents( ctx: RunContext[SkillRunDeps], @@ -169,33 +131,15 @@ def create_skill( limit: Maximum number of documents to return. offset: Number of documents to skip. """ - from haiku.rag.client import HaikuRAG - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - documents = await rag.list_documents(limit, offset) - result = [ - { - "id": doc.id, - "title": doc.title, - "uri": doc.uri, - "metadata": doc.metadata, - "created_at": str(doc.created_at), - "updated_at": str(doc.updated_at), - } - for doc in documents - ] - - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState): - for doc_dict in result: - doc_info = DocumentInfo( - id=str(doc_dict["id"]), - title=doc_dict["title"] or "Untitled", - uri=doc_dict.get("uri") or "", - created=doc_dict.get("created_at", ""), - ) - if not any(d.id == doc_info.id for d in ctx.deps.state.documents): - ctx.deps.state.documents.append(doc_info) + 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( @@ -206,32 +150,16 @@ def create_skill( Args: query: Document ID, title, or URI to look up. """ - from haiku.rag.client import HaikuRAG - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - document = await rag.resolve_document(query) - if document is None: - return None - result = { - "id": document.id, - "content": document.content, - "title": document.title, - "uri": document.uri, - "metadata": document.metadata, - "created_at": str(document.created_at), - "updated_at": str(document.updated_at), - } - - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState): - doc_info = DocumentInfo( - id=str(result["id"]), - title=result["title"] or "Untitled", - uri=result.get("uri") or "", - created=result.get("created_at", ""), - ) - if not any(d.id == doc_info.id for d in ctx.deps.state.documents): - ctx.deps.state.documents.append(doc_info) + 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: @@ -240,45 +168,25 @@ def create_skill( Args: question: The question to ask. """ - from haiku.rag.client import HaikuRAG + from haiku.rag.skills._tools import skill_ask from haiku.rag.utils import format_citations - state = ( - ctx.deps.state - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState) - else None + 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, ) - ask_question = question if state: - matches = await _find_relevant_prior_qa(state, question) - if matches: - prior_parts = [] - for qa in matches: - part = f"Q: {qa.question}\nA: {qa.answer}" - if qa.citations: - part += "\n" + format_citations(qa.citations) - prior_parts.append(part) - ask_question = ( - "Context from prior questions in this session:\n\n" - + "\n\n---\n\n".join(prior_parts) - + "\n\n---\n\nCurrent question: " - + question - ) - - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - answer, citations = await rag.ask( - ask_question, - filter=state.document_filter if state else None, - ) - - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState): - next_index = len(ctx.deps.state.citations) + 1 + next_index = len(state.citations) + 1 for citation in citations: citation.index = next_index next_index += 1 - ctx.deps.state.citations.extend(citations) - ctx.deps.state.qa_history.append( + state.citations.extend(citations) + state.qa_history.append( QAHistoryEntry(question=question, answer=answer, citations=citations) ) @@ -297,54 +205,29 @@ def create_skill( Args: question: The research question to investigate. """ - from haiku.rag.client import HaikuRAG + from haiku.rag.skills._tools import skill_research - state = ( - ctx.deps.state - if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, RAGState) - else None + state = _get_state(ctx) + formatted, title, executive_summary = await skill_research( + db_path, + config, + question, + document_filter=state.document_filter if state else None, ) - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - report = await rag.research( - question, filter=state.document_filter if state else None - ) - if state: state.reports.append( ResearchEntry( question=question, - title=report.title, - executive_summary=report.executive_summary, + title=title, + executive_summary=executive_summary, ) ) state.qa_history.append( - QAHistoryEntry(question=question, answer=report.executive_summary) + QAHistoryEntry(question=question, answer=executive_summary) ) - parts = [ - f"# {report.title}", - f"\n## Executive Summary\n{report.executive_summary}", - ] - if report.main_findings: - parts.append("\n## Main Findings") - for finding in report.main_findings: - parts.append(f"- {finding}") - if report.conclusions: - parts.append("\n## Conclusions") - for conclusion in report.conclusions: - parts.append(f"- {conclusion}") - if report.limitations: - parts.append("\n## Limitations") - for limitation in report.limitations: - parts.append(f"- {limitation}") - if report.recommendations: - parts.append("\n## Recommendations") - for rec in report.recommendations: - parts.append(f"- {rec}") - parts.append(f"\n## Sources\n{report.sources_summary}") - - return "\n".join(parts) + return formatted return Skill( metadata=skill_metadata(), diff --git a/haiku_rag_slim/haiku/rag/skills/rlm.py b/haiku_rag_slim/haiku/rag/skills/rlm.py index daa8c7cd..5fd6e461 100644 --- a/haiku_rag_slim/haiku/rag/skills/rlm.py +++ b/haiku_rag_slim/haiku/rag/skills/rlm.py @@ -88,21 +88,18 @@ def create_skill( document: Optional document ID or title to pre-load for analysis. filter: Optional SQL WHERE clause to filter documents. """ - from haiku.rag.client import HaikuRAG + from haiku.rag.skills._tools import skill_analyze - async with HaikuRAG(db_path, config=config, read_only=True) as rag: - documents = [document] if document else None - result = await rag.rlm(question, documents=documents, filter=filter) - output = result.answer - if result.program: - output += f"\n\nProgram:\n{result.program}" + 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=result.answer, - program=result.program, + answer=answer, + program=program, ) ) From 26da4a0624d1913ea1f29473ded35a9d1a80309a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 15:11:54 +0200 Subject: [PATCH 2/7] Skill generator core --- .../haiku/rag/skill_generator/__init__.py | 140 +++++++ .../rag/skill_generator/templates/SKILL.md.j2 | 54 +++ .../skill_generator/templates/__init__.py.j2 | 77 ++++ .../templates/pyproject.toml.j2 | 16 + haiku_rag_slim/haiku/rag/skills/_tools.py | 216 ++++++++++ haiku_rag_slim/haiku/rag/skills/rag.py | 158 +------- haiku_rag_slim/haiku/rag/skills/rlm.py | 45 +-- haiku_rag_slim/pyproject.toml | 1 + tests/test_skill_generator.py | 370 ++++++++++++++++++ uv.lock | 2 + 10 files changed, 885 insertions(+), 194 deletions(-) create mode 100644 haiku_rag_slim/haiku/rag/skill_generator/__init__.py create mode 100644 haiku_rag_slim/haiku/rag/skill_generator/templates/SKILL.md.j2 create mode 100644 haiku_rag_slim/haiku/rag/skill_generator/templates/__init__.py.j2 create mode 100644 haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 create mode 100644 tests/test_skill_generator.py 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" }, From ce95234cc8e141185f0f960084bbfbe3b985a470 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 15:44:46 +0200 Subject: [PATCH 3/7] CLI for create-skill; --- haiku_rag_slim/haiku/rag/cli.py | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 905e4940..47988938 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -716,5 +716,78 @@ def serve( ) +@_cli.command( + "create-skill", + help="Generate a standalone skill package with an embedded database", +) +def create_skill_cmd( # pragma: no cover + name: str = typer.Option( + ..., + "--name", + help="Skill name (must be a lowercase Python identifier)", + ), + db: Path = typer.Option( + ..., + "--db", + help="Path to the LanceDB database to embed", + ), + description: str | None = typer.Option( + None, + "--description", + help="Skill description (default: standard RAG description)", + ), + tools: str = typer.Option( + "all", + "--tools", + help="Comma-separated tool names, or 'all'", + ), + preamble: str | None = typer.Option( + None, + "--preamble", + help="Custom preamble for the skill instructions", + ), + config_file: Path | None = typer.Option( + None, + "--config-file", + help="Path to haiku.rag.yaml to embed in the skill", + ), + output: Path = typer.Option( + Path("."), + "--output", + "-o", + help="Output directory for the generated package", + ), +): + """Generate a standalone haiku.skills package with an embedded database.""" + from haiku.rag.skill_generator import ( + AVAILABLE_TOOLS, + DEFAULT_DESCRIPTION, + generate_skill, + ) + + if description is None: + description = DEFAULT_DESCRIPTION + + if tools.strip().lower() == "all": + tool_names = sorted(AVAILABLE_TOOLS) + else: + tool_names = [t.strip() for t in tools.split(",") if t.strip()] + + try: + result = generate_skill( + db_path=db, + output_dir=output, + name=name, + description=description, + tool_names=tool_names, + config_path=config_file, + preamble=preamble, + ) + typer.echo(f"Skill generated: {result}") + except ValueError as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) + + if __name__ == "__main__": # pragma: no cover cli() From 7309d133179ce077841086a161638323ef7da3a7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 16:06:46 +0200 Subject: [PATCH 4/7] Docs --- CHANGELOG.md | 7 ++++++ docs/cli.md | 58 ++++++++++++++++++++++++++++++++++++++++++++ docs/skills/index.md | 26 ++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b12204..f7a8e526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added + +- **`create-skill` CLI command**: Generate standalone skill packages with embedded LanceDB databases. Supports tool selection, custom preamble/description, and optional config embedding. Generated packages register as `haiku.skills` entry points. +- **`haiku.rag.skill_generator`**: Programmatic API for skill generation (`generate_skill()`, `render_templates()`) +- **`haiku.rag.skills._tools`**: Reusable tool implementations and `create_skill_tools()` factory shared by built-in and generated skills +- **Jinja2 dependency**: Added for skill template rendering + ## [0.35.0] - 2026-03-24 ### Added diff --git a/docs/cli.md b/docs/cli.md index 38f0f390..7918eee2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -247,6 +247,64 @@ Flags: See [RLM Agent](agents/rlm.md) for details on capabilities and configuration. +## Create Skill + +Generate a standalone skill package with an embedded database: + +```bash +haiku-rag create-skill --name myskill --db /path/to/database.lancedb +``` + +The generated package is a pip-installable Python package that registers as a `haiku.skills` entry point. + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--name` | Skill name (lowercase Python identifier, required) | — | +| `--db` | Path to LanceDB database to embed (required) | — | +| `--description` | Skill description | Standard RAG description | +| `--tools` | Comma-separated tool names, or `all` | `all` | +| `--preamble` | Custom preamble for skill instructions | Standard RAG preamble | +| `--config-file` | Path to `haiku.rag.yaml` to embed | None | +| `--output` / `-o` | Output directory | Current directory | + +### Available Tools + +`analyze`, `ask`, `get_document`, `list_documents`, `research`, `search` + +### Example + +```bash +# Generate a skill with specific tools and custom preamble +haiku-rag create-skill \ + --name medic \ + --db /path/to/medic.lancedb \ + --tools search,ask \ + --config-file /path/to/haiku.rag.yaml \ + --description "Military medic knowledge base" \ + --preamble "You are a military medic expert." + +# Install the generated package +uv pip install -e ./medic-skill + +# Use with haiku-skills +haiku-skills chat --use-entrypoints --skill medic +``` + +### Generated Package Structure + +``` +{name}-skill/ +├── pyproject.toml +└── {name}_skill/ + ├── __init__.py # create_skill() entry point + ├── SKILL.md # Skill metadata and instructions + └── assets/ + ├── {name}.lancedb/ # Embedded database + └── haiku.rag.yaml # Optional config +``` + ## Server Start services (requires at least one flag): diff --git a/docs/skills/index.md b/docs/skills/index.md index f43210b1..ffed1e87 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -39,6 +39,32 @@ agent = Agent( result = await agent.run("What documents do we have?") ``` +## Generating Custom Skills + +Use `create-skill` to generate a standalone skill package with an embedded database: + +```bash +haiku-rag create-skill \ + --name recipes \ + --db /path/to/recipes.lancedb \ + --tools search,ask \ + --description "Recipe knowledge base" \ + --preamble "You are a recipe expert." +``` + +This generates a pip-installable package (`recipes-skill/`) that bundles the database and registers as a `haiku.skills` entry point. After installing (`uv pip install -e ./recipes-skill`), the skill is automatically discovered: + +```bash +haiku-skills list --use-entrypoints +# recipes — Recipe knowledge base + +haiku-skills chat --use-entrypoints --skill recipes +``` + +Since each generated skill is self-contained with its own database and instructions, you can generate multiple skills for different domains and run them together. The agent sees each skill's description and routes questions to the appropriate knowledge base automatically. + +See [CLI: Create Skill](../cli.md#create-skill) for all options. + ## Database Path Resolution Both skills resolve the database path in the same order: From 7d50edea86ca783e5db01e6188f25d906d2bf12b Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 17:09:14 +0200 Subject: [PATCH 5/7] Fix validate_metadata layering and type annotations in _tools.py --- CHANGELOG.md | 5 +---- .../haiku/rag/skill_generator/__init__.py | 7 +++---- haiku_rag_slim/haiku/rag/skills/_tools.py | 21 ++++++++++--------- tests/test_skill_generator.py | 8 +++---- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a8e526..4a0910f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,7 @@ ### Added -- **`create-skill` CLI command**: Generate standalone skill packages with embedded LanceDB databases. Supports tool selection, custom preamble/description, and optional config embedding. Generated packages register as `haiku.skills` entry points. -- **`haiku.rag.skill_generator`**: Programmatic API for skill generation (`generate_skill()`, `render_templates()`) -- **`haiku.rag.skills._tools`**: Reusable tool implementations and `create_skill_tools()` factory shared by built-in and generated skills -- **Jinja2 dependency**: Added for skill template rendering +- **`create-skill` CLI command**: Generate standalone skill packages with embedded LanceDB databases. Generated packages register as `haiku.skills` entry points. ## [0.35.0] - 2026-03-24 diff --git a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py index ca4319fb..241925d4 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py +++ b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py @@ -36,13 +36,12 @@ def _get_env() -> Environment: def validate_metadata(name: str, description: str) -> None: + if not name.isidentifier(): + raise ValueError(f"{name!r} is not a valid Python identifier") + 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: diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 337d44b1..4cdd1710 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -5,6 +5,7 @@ from pydantic import BaseModel from pydantic_ai import RunContext from haiku.rag.agents.research.models import Citation +from haiku.rag.config.models import AppConfig from haiku.rag.tools.document import DocumentInfo from haiku.rag.tools.qa import QAHistoryEntry from haiku.skills.state import SkillRunDeps @@ -25,7 +26,7 @@ class AnalysisEntry(BaseModel): async def find_relevant_prior_qa( qa_history: list[QAHistoryEntry], query: str, - config: Any, + config: AppConfig, ) -> list[QAHistoryEntry]: from haiku.rag.embeddings import get_embedder from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD @@ -61,7 +62,7 @@ async def find_relevant_prior_qa( async def skill_search( db_path: Path, - config: Any, + config: AppConfig, query: str, limit: int | None = None, document_filter: str | None = None, @@ -85,7 +86,7 @@ async def skill_search( async def skill_list_documents( db_path: Path, - config: Any, + config: AppConfig, limit: int | None = None, offset: int | None = None, ) -> list[dict[str, Any]]: @@ -108,7 +109,7 @@ async def skill_list_documents( async def skill_get_document( db_path: Path, - config: Any, + config: AppConfig, query: str, ) -> dict[str, Any] | None: from haiku.rag.client import HaikuRAG @@ -130,7 +131,7 @@ async def skill_get_document( async def skill_ask( db_path: Path, - config: Any, + config: AppConfig, question: str, qa_history: list[QAHistoryEntry] | None = None, document_filter: str | None = None, @@ -166,7 +167,7 @@ async def skill_ask( async def skill_research( db_path: Path, - config: Any, + config: AppConfig, question: str, document_filter: str | None = None, ) -> tuple[str, str, str]: @@ -203,7 +204,7 @@ async def skill_research( async def skill_analyze( db_path: Path, - config: Any, + config: AppConfig, question: str, document: str | None = None, filter: str | None = None, @@ -235,7 +236,7 @@ def update_documents_state( documents_state.append(doc_info) -def _get_state(ctx: RunContext[SkillRunDeps], state_type: type) -> Any: +def _get_state(ctx: RunContext[SkillRunDeps], state_type: type[BaseModel]) -> Any: if ctx.deps and ctx.deps.state and isinstance(ctx.deps.state, state_type): return ctx.deps.state return None @@ -243,8 +244,8 @@ def _get_state(ctx: RunContext[SkillRunDeps], state_type: type) -> Any: def create_skill_tools( db_path: Path, - config: Any, - state_type: type, + config: AppConfig, + state_type: type[BaseModel], tool_names: list[str], ) -> dict[str, Any]: """Create tool closures for a skill. diff --git a/tests/test_skill_generator.py b/tests/test_skill_generator.py index 743eaa0f..db79217f 100644 --- a/tests/test_skill_generator.py +++ b/tests/test_skill_generator.py @@ -43,7 +43,7 @@ class TestValidateMetadata: validate_metadata("Recipes", "A skill.") def test_rejects_empty_name(self): - with pytest.raises(ValueError, match="name"): + with pytest.raises(ValueError, match="identifier"): validate_metadata("", "A skill.") def test_rejects_not_identifier(self): @@ -51,11 +51,11 @@ class TestValidateMetadata: validate_metadata("123abc", "A skill.") def test_rejects_spaces_in_name(self): - with pytest.raises(ValueError, match="name"): + with pytest.raises(ValueError, match="identifier"): validate_metadata("my recipes", "A skill.") def test_rejects_special_chars(self): - with pytest.raises(ValueError, match="name"): + with pytest.raises(ValueError, match="identifier"): validate_metadata("my@recipes", "A skill.") def test_rejects_empty_description(self): @@ -313,7 +313,7 @@ class TestGenerateSkill: def test_rejects_invalid_name(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") - with pytest.raises(ValueError, match="name"): + with pytest.raises(ValueError, match="identifier"): generate_skill( db_path=db_path, output_dir=tmp_path, From 705ce80b4fc9b7a8ea90d653d1f04bbf78316524 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 17:27:56 +0200 Subject: [PATCH 6/7] Allow all spec-compliant names --- .../haiku/rag/skill_generator/__init__.py | 10 +-- .../templates/pyproject.toml.j2 | 2 +- tests/test_skill_generator.py | 64 +++++++------------ 3 files changed, 29 insertions(+), 47 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py index 241925d4..37a3a247 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py +++ b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py @@ -36,9 +36,6 @@ def _get_env() -> Environment: def validate_metadata(name: str, description: str) -> None: - if not name.isidentifier(): - raise ValueError(f"{name!r} is not a valid Python identifier") - from haiku.skills import SkillMetadata SkillMetadata(name=name, description=description) @@ -80,16 +77,18 @@ def render_templates( if preamble is None: preamble = DEFAULT_PREAMBLE + pkg_name = name.replace("-", "_") env = _get_env() context = { "name": name, + "pkg_name": pkg_name, "description": description, "tool_names": tool_names, "preamble": preamble, } result_dir = output_dir / f"{name}-skill" - pkg_dir = result_dir / f"{name}_skill" + pkg_dir = result_dir / f"{pkg_name}_skill" assets_dir = pkg_dir / "assets" assets_dir.mkdir(parents=True) @@ -130,7 +129,8 @@ def generate_skill( preamble=preamble, ) - assets_dir = result / f"{name}_skill" / "assets" + pkg_name = name.replace("-", "_") + assets_dir = result / f"{pkg_name}_skill" / "assets" shutil.copytree(db_path, assets_dir / f"{name}.lancedb") if config_path is not None: 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 index 29616669..915d1c16 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 @@ -13,4 +13,4 @@ dependencies = [ ] [project.entry-points."haiku.skills"] -{{ name }} = "{{ name }}_skill:create_skill" +{{ name }} = "{{ pkg_name }}_skill:create_skill" diff --git a/tests/test_skill_generator.py b/tests/test_skill_generator.py index db79217f..8aa4c439 100644 --- a/tests/test_skill_generator.py +++ b/tests/test_skill_generator.py @@ -25,46 +25,11 @@ class TestAvailableTools: class TestValidateMetadata: def test_valid(self): - validate_metadata("recipes", "A skill.") + validate_metadata("my-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="identifier"): - 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="identifier"): - validate_metadata("my recipes", "A skill.") - - def test_rejects_special_chars(self): - with pytest.raises(ValueError, match="identifier"): - 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) + def test_rejects_invalid_name(self): + with pytest.raises(ValueError): + validate_metadata("Bad_Name!", "A skill.") class TestValidateTools: @@ -135,6 +100,23 @@ class TestRenderTemplates: assert (pkg / "SKILL.md").is_file() assert (pkg / "assets").is_dir() + def test_dashed_name_uses_underscores_for_python(self, tmp_path): + result = render_templates( + output_dir=tmp_path, + name="my-recipes", + description="A recipe skill.", + tool_names=["search", "ask"], + ) + assert result == tmp_path / "my-recipes-skill" + pkg = result / "my_recipes_skill" + assert (pkg / "__init__.py").is_file() + init = (pkg / "__init__.py").read_text() + assert '"my-recipes.lancedb"' in init + assert 'state_namespace="my-recipes"' in init + toml = (result / "pyproject.toml").read_text() + assert 'name = "my-recipes-skill"' in toml + assert 'my-recipes = "my_recipes_skill:create_skill"' in toml + def test_tool_names_list_matches_selection(self, tmp_path): render_templates( output_dir=tmp_path, @@ -313,11 +295,11 @@ class TestGenerateSkill: def test_rejects_invalid_name(self, tmp_path): db_path = _make_fake_lancedb(tmp_path / "test.lancedb") - with pytest.raises(ValueError, match="identifier"): + with pytest.raises(ValueError): generate_skill( db_path=db_path, output_dir=tmp_path, - name="Bad-Name", + name="Bad_Name!", description="A skill.", tool_names=["search"], ) From 75b9a172cb77896296ae067dee14376dbab6552a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 24 Mar 2026 17:30:34 +0200 Subject: [PATCH 7/7] Minor fixes --- docs/cli.md | 2 +- haiku_rag_slim/haiku/rag/cli.py | 2 +- haiku_rag_slim/haiku/rag/skill_generator/__init__.py | 7 +++++-- .../haiku/rag/skill_generator/templates/pyproject.toml.j2 | 2 +- haiku_rag_slim/haiku/rag/skills/_tools.py | 3 ++- tests/test_skill_generator.py | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7918eee2..e0d53988 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -261,7 +261,7 @@ The generated package is a pip-installable Python package that registers as a `h | Flag | Description | Default | |------|-------------|---------| -| `--name` | Skill name (lowercase Python identifier, required) | — | +| `--name` | Skill name (lowercase alphanumeric and hyphens, required) | — | | `--db` | Path to LanceDB database to embed (required) | — | | `--description` | Skill description | Standard RAG description | | `--tools` | Comma-separated tool names, or `all` | `all` | diff --git a/haiku_rag_slim/haiku/rag/cli.py b/haiku_rag_slim/haiku/rag/cli.py index 47988938..5e6f974e 100644 --- a/haiku_rag_slim/haiku/rag/cli.py +++ b/haiku_rag_slim/haiku/rag/cli.py @@ -724,7 +724,7 @@ def create_skill_cmd( # pragma: no cover name: str = typer.Option( ..., "--name", - help="Skill name (must be a lowercase Python identifier)", + help="Skill name (lowercase alphanumeric and hyphens)", ), db: Path = typer.Option( ..., diff --git a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py index 37a3a247..004db953 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/__init__.py +++ b/haiku_rag_slim/haiku/rag/skill_generator/__init__.py @@ -1,7 +1,8 @@ import pathlib import shutil +from importlib.metadata import version -from jinja2 import Environment, PackageLoader, select_autoescape +from jinja2 import Environment, PackageLoader AVAILABLE_TOOLS: set[str] = { "list_documents", @@ -28,7 +29,7 @@ DEFAULT_DESCRIPTION = ( def _get_env() -> Environment: return Environment( loader=PackageLoader("haiku.rag.skill_generator", "templates"), - autoescape=select_autoescape(), + autoescape=False, keep_trailing_newline=True, lstrip_blocks=True, trim_blocks=True, @@ -78,6 +79,7 @@ def render_templates( preamble = DEFAULT_PREAMBLE pkg_name = name.replace("-", "_") + rag_version = version("haiku.rag-slim") env = _get_env() context = { "name": name, @@ -85,6 +87,7 @@ def render_templates( "description": description, "tool_names": tool_names, "preamble": preamble, + "rag_version": rag_version, } result_dir = output_dir / f"{name}-skill" 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 index 915d1c16..e8aa675a 100644 --- a/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 +++ b/haiku_rag_slim/haiku/rag/skill_generator/templates/pyproject.toml.j2 @@ -8,7 +8,7 @@ version = "0.1.0" description = "{{ description }}" requires-python = ">=3.12" dependencies = [ - "haiku.rag-slim >= 0.35", + "haiku.rag-slim >= {{ rag_version }}", "haiku-skills >= 0.10.0", ] diff --git a/haiku_rag_slim/haiku/rag/skills/_tools.py b/haiku_rag_slim/haiku/rag/skills/_tools.py index 4cdd1710..02b7494d 100644 --- a/haiku_rag_slim/haiku/rag/skills/_tools.py +++ b/haiku_rag_slim/haiku/rag/skills/_tools.py @@ -6,6 +6,7 @@ from pydantic_ai import RunContext from haiku.rag.agents.research.models import Citation from haiku.rag.config.models import AppConfig +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.state import SkillRunDeps @@ -66,7 +67,7 @@ async def skill_search( query: str, limit: int | None = None, document_filter: str | None = None, -) -> tuple[str, list]: +) -> tuple[str, list[SearchResult]]: from haiku.rag.client import HaikuRAG async with HaikuRAG(db_path, config=config, read_only=True) as rag: diff --git a/tests/test_skill_generator.py b/tests/test_skill_generator.py index 8aa4c439..fc8309a6 100644 --- a/tests/test_skill_generator.py +++ b/tests/test_skill_generator.py @@ -165,7 +165,7 @@ class TestRenderTemplates: 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 + assert "haiku.rag-slim >= " in content def test_skill_md_conditionals(self, tmp_path): render_templates(