mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 10:52:15 +00:00
* chore: create learning subdirectory structure (routing, agents, intelligence) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: extract classify_query to routing/_utils.py Move the classify_query() function and its regex patterns into a shared utility module so multiple routing policies can import it without depending on the full trace_policy module. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move routing files to learning/routing/ subdirectory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: create LearnedRouterPolicy merging trace-driven + SFT routing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add conditional Algolia DocSearch integration Add Algolia DocSearch as an optional search upgrade — native lunr.js search remains the default until credentials are configured. Includes CDN assets, Jinja2 conditional config injection, init script with graceful fallback, light/dark theme CSS, improved search tokenization for snake_case/dotted identifiers, and search boosts for key pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move agent_evolver and skill_discovery to learning/agents/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move learning/orchestrator to learning/intelligence/orchestrator Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: delete removed learning policies, rewrite __init__.py, clean up api_routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SFT/GRPO/DSPy/GEPA config dataclasses, update LearningConfig Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add general-purpose SFT trainer (intelligence/sft_trainer.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update stale imports in multi_model_router example Update imports to use new learning/routing/ paths after the subdirectory reorganization. Replace BanditRouterPolicy with LearnedRouterPolicy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add general-purpose GRPO trainer (intelligence/grpo_trainer.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add DSPy agent optimizer (agents/dspy_optimizer.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add GEPA agent optimizer (agents/gepa_optimizer.py) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add learning-dspy and learning-gepa optional dependency extras Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update integration test to check for learned policy instead of grpo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: clean up stale APIs and unused params in examples - deep_research: remove system_prompt and max_turns params not accepted by Jarvis.ask(), inline system prompt into the query instead - doc_qa: remove unused --top-k CLI arg that was never passed to the API - multi_model_router: fix select_model() call to match single-arg signature Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: import SFT/GRPO trainers in intelligence/__init__.py for registry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove .md file changes from PR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: restore search boost frontmatter for key docs pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
7.6 KiB
Python
220 lines
7.6 KiB
Python
"""Tests for SkillDiscovery — mining recurring tool sequences from traces."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import List
|
|
|
|
from openjarvis.learning.agents.skill_discovery import SkillDiscovery
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class _Step:
|
|
step_type: str = "tool_call"
|
|
tool_name: str = ""
|
|
name: str = "" # fallback
|
|
|
|
|
|
@dataclass
|
|
class _StepEnum:
|
|
"""Step with an enum-like step_type that has a .value attribute."""
|
|
|
|
class _StepType:
|
|
def __init__(self, val: str) -> None:
|
|
self.value = val
|
|
|
|
def __str__(self) -> str:
|
|
return self.value
|
|
|
|
step_type: object = None
|
|
tool_name: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.step_type is None:
|
|
self.step_type = self._StepType("tool_call")
|
|
|
|
|
|
@dataclass
|
|
class _Trace:
|
|
query: str = ""
|
|
outcome: float = 1.0
|
|
steps: list = field(default_factory=list)
|
|
|
|
|
|
def _make_trace(tools: List[str], outcome: float = 1.0, query: str = "") -> _Trace:
|
|
steps = [_Step(step_type="tool_call", tool_name=t) for t in tools]
|
|
return _Trace(query=query, outcome=outcome, steps=steps)
|
|
|
|
|
|
def _make_dict_trace(tools: List[str], outcome: float = 1.0, query: str = "") -> dict:
|
|
steps = [{"step_type": "tool_call", "tool_name": t} for t in tools]
|
|
return {"query": query, "outcome": outcome, "steps": steps}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSkillDiscovery:
|
|
def test_empty_traces(self):
|
|
sd = SkillDiscovery()
|
|
result = sd.analyze_traces([])
|
|
assert result == []
|
|
assert sd.discovered_skills == []
|
|
|
|
def test_single_trace_below_threshold(self):
|
|
"""One trace cannot meet min_frequency=3."""
|
|
sd = SkillDiscovery(min_frequency=3)
|
|
traces = [_make_trace(["web_search", "file_write"], outcome=1.0)]
|
|
result = sd.analyze_traces(traces)
|
|
assert result == []
|
|
|
|
def test_recurring_sequence(self):
|
|
"""3+ traces with same 2-tool sequence should be discovered."""
|
|
sd = SkillDiscovery(min_frequency=3, min_outcome=0.5)
|
|
traces = [
|
|
_make_trace(["web_search", "file_write"], outcome=0.9, query=f"q{i}")
|
|
for i in range(5)
|
|
]
|
|
result = sd.analyze_traces(traces)
|
|
assert len(result) >= 1
|
|
# The web_search_file_write sequence should appear
|
|
names = [s.name for s in result]
|
|
assert "web_search_file_write" in names
|
|
skill = [s for s in result if s.name == "web_search_file_write"][0]
|
|
assert skill.frequency == 5
|
|
assert skill.tool_sequence == ["web_search", "file_write"]
|
|
assert skill.avg_outcome >= 0.5
|
|
|
|
def test_outcome_threshold(self):
|
|
"""Low-outcome sequences should be filtered out."""
|
|
sd = SkillDiscovery(min_frequency=3, min_outcome=0.8)
|
|
traces = [
|
|
_make_trace(["a", "b"], outcome=0.3)
|
|
for _ in range(5)
|
|
]
|
|
result = sd.analyze_traces(traces)
|
|
assert result == []
|
|
|
|
def test_sequence_length_limits(self):
|
|
"""Sequences shorter than min or longer than max should be excluded."""
|
|
# Only length-1 tools (below min_sequence_length=2)
|
|
sd = SkillDiscovery(
|
|
min_frequency=2, min_sequence_length=2, max_sequence_length=3,
|
|
)
|
|
short_traces = [_make_trace(["a"], outcome=1.0) for _ in range(5)]
|
|
result = sd.analyze_traces(short_traces)
|
|
assert result == []
|
|
|
|
# Long sequence: with max_sequence_length=2, a 4-tool sequence
|
|
# should only produce subsequences of length 2
|
|
sd2 = SkillDiscovery(
|
|
min_frequency=3, min_sequence_length=2, max_sequence_length=2,
|
|
)
|
|
long_traces = [
|
|
_make_trace(["a", "b", "c", "d"], outcome=1.0)
|
|
for _ in range(3)
|
|
]
|
|
result2 = sd2.analyze_traces(long_traces)
|
|
# All discovered skills should have exactly 2 tools
|
|
for skill in result2:
|
|
assert len(skill.tool_sequence) == 2
|
|
|
|
def test_to_skill_manifests(self):
|
|
"""Verify manifest dict format has expected keys."""
|
|
sd = SkillDiscovery(min_frequency=2, min_outcome=0.0)
|
|
traces = [
|
|
_make_trace(["calc", "save"], outcome=0.9)
|
|
for _ in range(3)
|
|
]
|
|
sd.analyze_traces(traces)
|
|
manifests = sd.to_skill_manifests()
|
|
assert len(manifests) >= 1
|
|
m = manifests[0]
|
|
assert "name" in m
|
|
assert "description" in m
|
|
assert "steps" in m
|
|
assert "metadata" in m
|
|
assert m["metadata"]["auto_discovered"] is True
|
|
assert m["metadata"]["frequency"] >= 2
|
|
assert isinstance(m["steps"], list)
|
|
for step in m["steps"]:
|
|
assert "tool" in step
|
|
assert "params" in step
|
|
|
|
def test_sort_by_quality(self):
|
|
"""Higher frequency*outcome skills should come first."""
|
|
sd = SkillDiscovery(min_frequency=2, min_outcome=0.0)
|
|
# Group A: high freq, high outcome
|
|
traces_a = [
|
|
_make_trace(["alpha", "beta"], outcome=1.0)
|
|
for _ in range(10)
|
|
]
|
|
# Group B: low freq, low outcome
|
|
traces_b = [
|
|
_make_trace(["gamma", "delta"], outcome=0.3)
|
|
for _ in range(2)
|
|
]
|
|
result = sd.analyze_traces(traces_a + traces_b)
|
|
assert len(result) >= 2
|
|
# First should be the higher quality one
|
|
assert result[0].name == "alpha_beta"
|
|
q0 = result[0].frequency * result[0].avg_outcome
|
|
q1 = result[1].frequency * result[1].avg_outcome
|
|
assert q0 >= q1
|
|
|
|
def test_dict_traces(self):
|
|
"""Test with dict-format traces instead of objects."""
|
|
sd = SkillDiscovery(min_frequency=3, min_outcome=0.5)
|
|
traces = [
|
|
_make_dict_trace(
|
|
["read", "compute", "write"], outcome=0.8, query=f"task {i}",
|
|
)
|
|
for i in range(4)
|
|
]
|
|
result = sd.analyze_traces(traces)
|
|
assert len(result) >= 1
|
|
# At minimum, 2-tool subsequences should be found
|
|
all_tools = []
|
|
for skill in result:
|
|
all_tools.extend(skill.tool_sequence)
|
|
assert "read" in all_tools or "compute" in all_tools
|
|
|
|
def test_example_inputs_captured(self):
|
|
"""Example queries should be stored (up to 3)."""
|
|
sd = SkillDiscovery(min_frequency=3, min_outcome=0.5)
|
|
traces = [
|
|
_make_trace(
|
|
["search", "summarize"],
|
|
outcome=0.9,
|
|
query=f"Find info about topic {i}",
|
|
)
|
|
for i in range(5)
|
|
]
|
|
result = sd.analyze_traces(traces)
|
|
assert len(result) >= 1
|
|
skill = result[0]
|
|
assert len(skill.example_inputs) > 0
|
|
# Max 3 examples stored
|
|
assert len(skill.example_inputs) <= 3
|
|
assert all("Find info about topic" in q for q in skill.example_inputs)
|
|
|
|
def test_enum_step_type(self):
|
|
"""Steps with enum-style step_type (has .value) should work."""
|
|
sd = SkillDiscovery(min_frequency=3, min_outcome=0.0)
|
|
traces = []
|
|
for i in range(4):
|
|
steps = [
|
|
_StepEnum(tool_name="tool_a"),
|
|
_StepEnum(tool_name="tool_b"),
|
|
]
|
|
traces.append(_Trace(query=f"q{i}", outcome=0.9, steps=steps))
|
|
result = sd.analyze_traces(traces)
|
|
assert len(result) >= 1
|
|
names = [s.name for s in result]
|
|
assert "tool_a_tool_b" in names
|