Skip to content

Commit 7c50b68

Browse files
authored
Merge pull request #183 from bogdanbaciu21/skoc-002-propagate-skill-hint-to-taskrecord
feat(sleep): propagate harvested skill hints into mined tasks
2 parents 7da46ae + 74c7057 commit 7c50b68

5 files changed

Lines changed: 229 additions & 2 deletions

File tree

‎skillopt_sleep/dream.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from skillopt_sleep.consolidate import ConsolidationResult, consolidate
2323
from skillopt_sleep.types import TaskRecord
2424

25-
2625
# ── synthetic augmentation ("dream up" variants of today's tasks) ─────────────
2726

2827
_WRAPPERS = [
@@ -49,6 +48,7 @@ def dream_augment(real_tasks: List[TaskRecord], *, factor: int = 1) -> List[Task
4948
judge=dict(t.judge), system=t.system,
5049
tags=list(t.tags) + ["dream"], split="train",
5150
origin="dream", derived_from=t.id,
51+
skill_hint=t.skill_hint,
5252
))
5353
return out
5454

@@ -90,6 +90,7 @@ def recall_similar(new_tasks: List[TaskRecord], history: List[TaskRecord],
9090
reference=h.reference, judge=dict(h.judge), system=h.system,
9191
tags=list(h.tags) + ["recall"], split="train", origin="real",
9292
derived_from=h.id,
93+
skill_hint=h.skill_hint,
9394
))
9495
return out
9596

‎skillopt_sleep/llm_miner.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from skillopt_sleep import prompts as prompt_registry
2626
from skillopt_sleep.backend import Backend, _extract_json
27+
from skillopt_sleep.mine import session_skill_hint
2728
from skillopt_sleep.types import SessionDigest, TaskRecord
2829

2930

@@ -65,13 +66,15 @@ def _mk_task(d: SessionDigest, obj: Dict[str, Any], idx: int) -> TaskRecord | No
6566
reference_kind="rule", judge={"kind": "rule", "checks": clean_checks},
6667
outcome="success" if satisfied else "fail",
6768
tags=["mined:llm"], source_sessions=[d.session_id],
69+
skill_hint=session_skill_hint(d),
6870
)
6971
if rubric:
7072
return TaskRecord(
7173
id=tid, project=d.project, intent=intent,
7274
reference_kind="rubric", reference=rubric,
7375
outcome="success" if satisfied else "fail",
7476
tags=["mined:llm"], source_sessions=[d.session_id],
77+
skill_hint=session_skill_hint(d),
7578
)
7679
return None # not checkable -> drop
7780

‎skillopt_sleep/mine.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import os
1919
import re
2020
from collections import Counter
21-
from typing import Any, Callable, List, Optional, Set, Tuple
21+
from typing import Callable, List, Optional, Set, Tuple
2222

2323
from skillopt_sleep.backend import CursorBackendError
2424
from skillopt_sleep.types import SessionDigest, TaskRecord
@@ -34,6 +34,18 @@ def _short(text: str, n: int = 600) -> str:
3434
return text if len(text) <= n else text[:n] + " …"
3535

3636

37+
def session_skill_hint(digest: SessionDigest) -> str:
38+
"""Return the session's unambiguous skill, or "" when there is none.
39+
40+
A session that invoked exactly one skill names the skill its tasks
41+
exercised. Zero skills (unknown) and several skills (ambiguous) both
42+
return "", which leaves those tasks in the existing catch-all path.
43+
"""
44+
skills = [s for s in (digest.skills_used or []) if s]
45+
unique = list(dict.fromkeys(skills))
46+
return unique[0] if len(unique) == 1 else ""
47+
48+
3749
def _looks_negative(signals: List[str]) -> bool:
3850
return any(s.startswith("neg:") for s in signals)
3951

@@ -196,6 +208,7 @@ def heuristic_mine(
196208
reference="",
197209
tags=tags,
198210
source_sessions=[d.session_id],
211+
skill_hint=session_skill_hint(d),
199212
)
200213
)
201214
if len(tasks) >= max_tasks:
@@ -206,7 +219,10 @@ def heuristic_mine(
206219
def dedup_tasks(tasks: List[TaskRecord]) -> List[TaskRecord]:
207220
"""Merge tasks sharing an id (same project+intent across sessions)."""
208221
by_id: dict = {}
222+
hints_by_id: dict = {}
209223
for t in tasks:
224+
if t.skill_hint:
225+
hints_by_id.setdefault(t.id, set()).add(t.skill_hint)
210226
if t.id in by_id:
211227
ex = by_id[t.id]
212228
ex.source_sessions = list(dict.fromkeys(ex.source_sessions + t.source_sessions))
@@ -216,6 +232,9 @@ def dedup_tasks(tasks: List[TaskRecord]) -> List[TaskRecord]:
216232
ex.outcome = t.outcome
217233
else:
218234
by_id[t.id] = t
235+
for task_id, task in by_id.items():
236+
hints = hints_by_id.get(task_id, set())
237+
task.skill_hint = next(iter(hints)) if len(hints) == 1 else ""
219238
return list(by_id.values())
220239

221240

‎skillopt_sleep/types.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ class TaskRecord:
8080
# allowed into val/test, which is the anti-overfitting guarantee.
8181
origin: str = "real"
8282
derived_from: str = "" # for dream tasks: the real task id it varies
83+
# Which skill this task exercised, when the source session invoked exactly
84+
# one. Empty means unknown or ambiguous, which keeps the task in the
85+
# existing managed-skill catch-all path. Kept last for positional compatibility.
86+
skill_hint: str = ""
8387

8488
def to_dict(self) -> Dict[str, Any]:
8589
return asdict(self)

‎tests/test_sleep_skill_hint.py‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Tests for propagating harvested skill hints into mined tasks (issue #120).
2+
3+
Pure-stdlib (unittest), deterministic, no API key, no third-party deps.
4+
Run: python -m pytest tests/test_sleep_skill_hint.py
5+
"""
6+
from __future__ import annotations
7+
8+
import unittest
9+
from itertools import permutations, product
10+
11+
from skillopt_sleep.dream import dream_augment, recall_similar
12+
from skillopt_sleep.llm_miner import _mk_task
13+
from skillopt_sleep.mine import dedup_tasks, heuristic_mine, session_skill_hint
14+
from skillopt_sleep.types import SessionDigest, TaskRecord
15+
16+
17+
def _digest(session_id="s1", skills=(), project="/repo/example", prompt="add a login form"):
18+
return SessionDigest(
19+
session_id=session_id,
20+
project=project,
21+
user_prompts=[prompt],
22+
assistant_finals=["done"],
23+
tools_used=["Skill"] if skills else ["Read"],
24+
skills_used=list(skills),
25+
)
26+
27+
28+
class TestTaskRecordSkillHint(unittest.TestCase):
29+
def test_defaults_to_empty_string(self):
30+
self.assertEqual(TaskRecord(id="t1", project="/repo/example", intent="x").skill_hint, "")
31+
32+
def test_serialization_roundtrip(self):
33+
task = TaskRecord(
34+
id="t1", project="/repo/example", intent="x", skill_hint="example-skill"
35+
)
36+
self.assertEqual(task.to_dict()["skill_hint"], "example-skill")
37+
self.assertEqual(TaskRecord.from_dict(task.to_dict()).skill_hint, "example-skill")
38+
39+
def test_legacy_payload_without_skill_hint_still_loads(self):
40+
legacy = {"id": "t1", "project": "/repo/example", "intent": "x", "split": "val"}
41+
task = TaskRecord.from_dict(legacy)
42+
self.assertEqual(task.skill_hint, "")
43+
self.assertEqual(task.split, "val")
44+
45+
def test_existing_positional_fields_keep_their_slots(self):
46+
task = TaskRecord(
47+
"t1", "/repo/example", "x", "context", "system", "attempt",
48+
"success", "exact", "answer", {}, ["tag"], ["s1"],
49+
"val", "dream", "source-task",
50+
)
51+
self.assertEqual(task.split, "val")
52+
self.assertEqual(task.origin, "dream")
53+
self.assertEqual(task.derived_from, "source-task")
54+
self.assertEqual(task.skill_hint, "")
55+
56+
57+
class TestSessionSkillHint(unittest.TestCase):
58+
def test_single_skill_is_unambiguous(self):
59+
self.assertEqual(session_skill_hint(_digest(skills=["example-skill"])), "example-skill")
60+
61+
def test_no_skill_is_empty(self):
62+
self.assertEqual(session_skill_hint(_digest()), "")
63+
64+
def test_several_skills_are_ambiguous(self):
65+
digest = _digest(skills=["example-skill", "second-skill"])
66+
self.assertEqual(session_skill_hint(digest), "")
67+
68+
def test_repeated_single_skill_stays_unambiguous(self):
69+
digest = _digest(skills=["example-skill", "example-skill"])
70+
self.assertEqual(session_skill_hint(digest), "example-skill")
71+
72+
73+
class TestHeuristicMineSkillHint(unittest.TestCase):
74+
def test_hint_propagates_from_session(self):
75+
tasks = heuristic_mine([_digest(skills=["example-skill"])])
76+
self.assertEqual([t.skill_hint for t in tasks], ["example-skill"])
77+
78+
def test_ambiguous_and_absent_hints_stay_in_catch_all(self):
79+
tasks = heuristic_mine([
80+
_digest(session_id="s1", skills=["example-skill", "second-skill"]),
81+
_digest(session_id="s2", prompt="write the release notes"),
82+
])
83+
self.assertEqual(len(tasks), 2)
84+
self.assertEqual([t.skill_hint for t in tasks], ["", ""])
85+
86+
def test_dedup_keeps_agreeing_hint_and_clears_conflicting_one(self):
87+
agreed = dedup_tasks([
88+
TaskRecord(id="t1", project="/p", intent="x", skill_hint="example-skill",
89+
source_sessions=["s1"]),
90+
TaskRecord(id="t1", project="/p", intent="x", skill_hint="example-skill",
91+
source_sessions=["s2"]),
92+
])
93+
self.assertEqual([t.skill_hint for t in agreed], ["example-skill"])
94+
95+
filled = dedup_tasks([
96+
TaskRecord(id="t1", project="/p", intent="x", source_sessions=["s1"]),
97+
TaskRecord(id="t1", project="/p", intent="x", skill_hint="example-skill",
98+
source_sessions=["s2"]),
99+
])
100+
self.assertEqual([t.skill_hint for t in filled], ["example-skill"])
101+
102+
conflicting = dedup_tasks([
103+
TaskRecord(id="t1", project="/p", intent="x", skill_hint="example-skill",
104+
source_sessions=["s1"]),
105+
TaskRecord(id="t1", project="/p", intent="x", skill_hint="second-skill",
106+
source_sessions=["s2"]),
107+
])
108+
self.assertEqual([t.skill_hint for t in conflicting], [""])
109+
110+
def test_dedup_conflict_is_permutation_independent(self):
111+
sequences = set(permutations(["skill-a", "skill-b", "skill-a"]))
112+
sequences.update(permutations(["skill-b", "skill-a", "skill-b"]))
113+
for hints in sequences:
114+
with self.subTest(hints=hints):
115+
tasks = [
116+
TaskRecord(id="t1", project="/p", intent="x", skill_hint=hint,
117+
source_sessions=[f"s{idx}"])
118+
for idx, hint in enumerate(hints)
119+
]
120+
self.assertEqual(dedup_tasks(tasks)[0].skill_hint, "")
121+
122+
def test_dedup_matches_final_non_empty_hint_set_exhaustively(self):
123+
for length in range(1, 5):
124+
for hints in product(("", "skill-a", "skill-b"), repeat=length):
125+
with self.subTest(hints=hints):
126+
distinct = {hint for hint in hints if hint}
127+
expected = next(iter(distinct)) if len(distinct) == 1 else ""
128+
tasks = [
129+
TaskRecord(id="t1", project="/p", intent="x", skill_hint=hint)
130+
for hint in hints
131+
]
132+
self.assertEqual(dedup_tasks(tasks)[0].skill_hint, expected)
133+
134+
def test_dedup_ignores_empty_hints_when_resolving_final_set(self):
135+
cases = [
136+
(("", "skill-a", ""), "skill-a"),
137+
(("skill-a", "", "skill-a"), "skill-a"),
138+
(("", "", ""), ""),
139+
(("skill-a", "", "skill-b"), ""),
140+
]
141+
for hints, expected in cases:
142+
with self.subTest(hints=hints):
143+
tasks = [
144+
TaskRecord(id="t1", project="/p", intent="x", skill_hint=hint)
145+
for hint in hints
146+
]
147+
self.assertEqual(dedup_tasks(tasks)[0].skill_hint, expected)
148+
149+
150+
class TestDreamAndRecallSkillHint(unittest.TestCase):
151+
def test_dream_augment_preserves_hint(self):
152+
source = TaskRecord(
153+
id="t1", project="/p", intent="add validation", skill_hint="example-skill"
154+
)
155+
dreamed = dream_augment([source], factor=1)
156+
self.assertEqual(len(dreamed), 1)
157+
self.assertEqual(dreamed[0].skill_hint, "example-skill")
158+
159+
def test_recall_similar_preserves_hint(self):
160+
new = TaskRecord(id="new", project="/p", intent="add form validation")
161+
historical = TaskRecord(
162+
id="old", project="/p", intent="improve form validation",
163+
skill_hint="example-skill",
164+
)
165+
recalled = recall_similar([new], [historical], 1)
166+
self.assertEqual(len(recalled), 1)
167+
self.assertEqual(recalled[0].skill_hint, "example-skill")
168+
169+
170+
class TestLlmMinerSkillHint(unittest.TestCase):
171+
def test_rule_task_carries_the_hint(self):
172+
task = _mk_task(
173+
_digest(skills=["example-skill"]),
174+
{"intent": "add a login form", "checks": [{"op": "contains", "arg": "form"}]},
175+
0,
176+
)
177+
self.assertIsNotNone(task)
178+
self.assertEqual(task.reference_kind, "rule")
179+
self.assertEqual(task.skill_hint, "example-skill")
180+
181+
def test_rubric_task_carries_the_hint(self):
182+
task = _mk_task(
183+
_digest(skills=["example-skill"]),
184+
{"intent": "add a login form", "rubric": "mentions validation"},
185+
0,
186+
)
187+
self.assertEqual(task.reference_kind, "rubric")
188+
self.assertEqual(task.skill_hint, "example-skill")
189+
190+
def test_ambiguous_session_leaves_hint_empty(self):
191+
task = _mk_task(
192+
_digest(skills=["example-skill", "second-skill"]),
193+
{"intent": "add a login form", "rubric": "mentions validation"},
194+
0,
195+
)
196+
self.assertEqual(task.skill_hint, "")
197+
198+
199+
if __name__ == "__main__":
200+
unittest.main()

0 commit comments

Comments
 (0)