Skill generator core
This commit is contained in:
parent
a778ea68a8
commit
26da4a0624
10 changed files with 885 additions and 194 deletions
140
haiku_rag_slim/haiku/rag/skill_generator/__init__.py
Normal file
140
haiku_rag_slim/haiku/rag/skill_generator/__init__.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 }}",
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
370
tests/test_skill_generator.py
Normal file
370
tests/test_skill_generator.py
Normal file
|
|
@ -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
|
||||
2
uv.lock
2
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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue