Skip to content

Commit 5f6b606

Browse files
feat: enhance HelpEngine and renderers for 0.4.0
Adds discoverability (list_topics/search/suggest), per-topic depth history, step-down progression API, JSON renderer, and runtime renderer switching. Fixes preamble() against the real flat template layout, rescues depth-2 from silent dead-end, and extends precursor_warnings beyond Python. Unknown renderer names now raise ValueError; session files migrate transparently from the pre-0.4 schema. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4009673 commit 5f6b606

16 files changed

Lines changed: 1471 additions & 61 deletions

CHANGELOG.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Changelog
2+
3+
All notable changes to `attune-help` are documented here.
4+
5+
## 0.4.0 — 2026-04-11
6+
7+
### Added
8+
9+
- `HelpEngine.list_topics(type=None, limit=None)`
10+
enumerate available topic slugs.
11+
- `HelpEngine.search(query, limit=10)` — fuzzy slug
12+
search returning `(slug, score)` tuples.
13+
- `HelpEngine.suggest(topic, limit=5)` — ranked
14+
suggestions for misspelled topics.
15+
- `HelpEngine.lookup(..., suggest_on_miss=True)` — opt-in
16+
"did you mean" string when a lookup fails.
17+
- `HelpEngine.simpler(topic)` — step one depth level
18+
back down without resetting session state.
19+
- `HelpEngine.reset(topic=None)` — clear depth for one
20+
topic or the whole session.
21+
- `HelpEngine.set_renderer(name)` — swap renderer at
22+
runtime.
23+
- `renderer="json"` — deterministic structured output for
24+
apps, web dashboards, and snapshot tests.
25+
- New `attune_help.discovery` module (topic index,
26+
search, suggest).
27+
- `precursor_warnings` now recognizes JavaScript,
28+
TypeScript, JSX/TSX, Rust, Go, Ruby, and Java files.
29+
30+
### Fixed
31+
32+
- `HelpEngine.preamble()` now resolves real bundled
33+
templates. Previously it only looked at the nested
34+
`<feature>/task.md` demo layout and silently returned
35+
`None` for the flat `tasks/use-<feature>.md` tree.
36+
- Progressive depth no longer resets when users
37+
interleave topics. Per-topic depth history replaces
38+
the single-slot `last_topic`, bounded by an LRU cap
39+
of 32 topics.
40+
- Depth-2 lookups now emit a terminal prompt
41+
(`"reference — deepest level; say 'simpler' to step
42+
back"`) instead of nothing.
43+
- `render_claude_code` no longer drops the body for
44+
`concept`, `task`, or unknown template types.
45+
- `HelpEngine.get_summary()` falls back to the bundled
46+
`summaries.json` when an override directory doesn't
47+
ship one.
48+
- `auto` renderer now respects `sys.stdout.isatty()`
49+
piped output gets plain text, terminal output gets
50+
rich.
51+
52+
### Changed
53+
54+
- Unknown renderer names now raise `ValueError` in
55+
`HelpEngine.__init__` and `set_renderer()`. Previously
56+
they logged a warning and silently fell back to
57+
`plain`.
58+
- Session schema gains `topics` and `order` fields for
59+
per-topic depth tracking. Legacy session files are
60+
read-migrated transparently — no action required for
61+
existing users.
62+
63+
## 0.3.1
64+
65+
Previous release. See git history for details.

README.md

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,19 @@ engine = HelpEngine(renderer="cli")
4848
# Claude Code inline format
4949
engine = HelpEngine(renderer="claude_code")
5050

51-
# Auto-detect environment
51+
# Structured JSON (for apps, web, tests)
52+
engine = HelpEngine(renderer="json")
53+
54+
# Auto-detect environment (CLAUDE_CODE → claude_code,
55+
# interactive TTY + rich → cli, otherwise → plain)
5256
engine = HelpEngine(renderer="auto")
57+
58+
# Switch renderer at runtime
59+
engine.set_renderer("cli")
5360
```
5461

62+
Passing an unknown renderer name raises `ValueError`.
63+
5564
## Template Directory
5665

5766
Templates are markdown files with YAML frontmatter:
@@ -101,6 +110,44 @@ The `security-audit/` demo contains `concept.md`,
101110
`task.md`, and `reference.md` — the three depth
102111
levels that `/coach init` generates for each feature.
103112

113+
## Discovery
114+
115+
```python
116+
engine.list_topics() # all slugs
117+
engine.list_topics(type="concepts") # filter by type
118+
engine.search("security") # [(slug, score), ...]
119+
engine.suggest("secrity-audit") # ranked slugs
120+
```
121+
122+
Miss handling:
123+
124+
```python
125+
# Returns None by default
126+
engine.lookup("typoed-slug")
127+
128+
# Returns "No help for 'typoed-slug'. Did you mean: ..."
129+
engine.lookup("typoed-slug", suggest_on_miss=True)
130+
```
131+
132+
## Progressive Depth Controls
133+
134+
```python
135+
engine.lookup("security-audit") # concept
136+
engine.lookup("security-audit") # task
137+
engine.lookup("security-audit") # reference (depth 2)
138+
139+
engine.simpler("security-audit") # step back to task
140+
engine.simpler("security-audit") # step back to concept
141+
142+
engine.reset("security-audit") # clear one topic
143+
engine.reset() # clear all topics
144+
```
145+
146+
Topics are tracked independently — interleaving
147+
`lookup("a")` / `lookup("b")` / `lookup("a")` does **not**
148+
reset `a`'s depth. An LRU cap of 32 topics keeps session
149+
state bounded.
150+
104151
## API
105152

106153
### `HelpEngine`
@@ -116,13 +163,22 @@ HelpEngine(
116163

117164
**Methods:**
118165

119-
- `lookup(topic)` — Progressive depth lookup
166+
- `lookup(topic, *, suggest_on_miss=False)` — Progressive
167+
depth lookup with optional "did you mean" on miss
168+
- `simpler(topic)` — Step back one depth level
169+
- `reset(topic=None)` — Clear depth history for one topic
170+
or all
171+
- `list_topics(type=None, limit=None)` — Enumerate slugs
172+
- `search(query, limit=10)` — Fuzzy-search slugs
173+
- `suggest(topic, limit=5)` — Ranked slug suggestions
120174
- `get(template_id)` — Direct template access
121175
- `lookup_raw(topic)` — Returns `PopulatedTemplate`
122176
dataclass
123-
- `get_summary(skill)` — One-line skill summary
124-
- `precursor_warnings(file_path)` — File-aware
125-
warnings
177+
- `get_summary(skill)` — One-line skill summary (falls
178+
back to bundled when an override lacks it)
179+
- `precursor_warnings(file_path)` — File-aware warnings
180+
(supports Python, JS/TS, Rust, Go, Ruby, Java, …)
181+
- `set_renderer(name)` — Change renderer at runtime
126182

127183
### `SessionStorage` Protocol
128184

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "attune-help"
7-
version = "0.3.1"
7+
version = "0.4.0"
88
description = "Lightweight help runtime with progressive depth and audience adaptation."
99
readme = {file = "README.md", content-type = "text/markdown"}
1010
requires-python = ">=3.10"

src/attune_help/discovery.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Topic enumeration and fuzzy search.
2+
3+
Scans a template directory once and caches the result,
4+
keyed on the directory path plus its mtime so the cache
5+
self-invalidates when templates regenerate.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import difflib
11+
import logging
12+
import threading
13+
from pathlib import Path
14+
15+
logger = logging.getLogger(__name__)
16+
17+
_TYPE_DIRS = (
18+
"concepts",
19+
"tasks",
20+
"references",
21+
"quickstarts",
22+
"comparisons",
23+
"tips",
24+
"troubleshooting",
25+
"warnings",
26+
"errors",
27+
"faqs",
28+
"notes",
29+
)
30+
31+
_INDEX_CACHE: dict[tuple[str, float], dict[str, list[str]]] = {}
32+
_INDEX_LOCK = threading.Lock()
33+
34+
35+
def invalidate_index_cache() -> None:
36+
"""Clear the topic index cache."""
37+
with _INDEX_LOCK:
38+
_INDEX_CACHE.clear()
39+
40+
41+
def build_index(generated_dir: Path) -> dict[str, list[str]]:
42+
"""Return ``{type: [slug, ...]}`` for a template tree.
43+
44+
Cached keyed on the directory path and its current
45+
mtime — touching the directory invalidates the cache
46+
automatically.
47+
48+
Args:
49+
generated_dir: Path to the template directory.
50+
51+
Returns:
52+
Dict mapping type name (``concepts``, ``tasks``,
53+
…) to a sorted list of slugs (filename stems).
54+
"""
55+
gen = Path(generated_dir)
56+
try:
57+
mtime = gen.stat().st_mtime
58+
except OSError:
59+
return {}
60+
61+
cache_key = (str(gen.resolve()), mtime)
62+
with _INDEX_LOCK:
63+
hit = _INDEX_CACHE.get(cache_key)
64+
if hit is not None:
65+
return hit
66+
67+
index: dict[str, list[str]] = {}
68+
for type_name in _TYPE_DIRS:
69+
subdir = gen / type_name
70+
if not subdir.is_dir():
71+
continue
72+
slugs = sorted(p.stem for p in subdir.glob("*.md") if p.is_file())
73+
if slugs:
74+
index[type_name] = slugs
75+
76+
_INDEX_CACHE[cache_key] = index
77+
return index
78+
79+
80+
def list_topics(
81+
generated_dir: Path,
82+
type: str | None = None,
83+
limit: int | None = None,
84+
) -> list[str]:
85+
"""Enumerate topic slugs.
86+
87+
Args:
88+
generated_dir: Template directory.
89+
type: Optional type filter (e.g. ``"concepts"``).
90+
``None`` returns all types flattened.
91+
limit: Optional cap on returned items.
92+
93+
Returns:
94+
Sorted list of slugs.
95+
"""
96+
index = build_index(generated_dir)
97+
if type is not None:
98+
result = list(index.get(type, []))
99+
else:
100+
result = sorted({s for slugs in index.values() for s in slugs})
101+
if limit is not None:
102+
result = result[:limit]
103+
return result
104+
105+
106+
def search(
107+
generated_dir: Path,
108+
query: str,
109+
limit: int = 10,
110+
) -> list[tuple[str, float]]:
111+
"""Fuzzy-search topic slugs.
112+
113+
Uses ``difflib.SequenceMatcher`` against slug strings,
114+
plus a substring bonus so exact substrings outrank
115+
pure fuzzy matches.
116+
117+
Args:
118+
generated_dir: Template directory.
119+
query: Search text.
120+
limit: Maximum results to return.
121+
122+
Returns:
123+
List of ``(slug, score)`` tuples, best first.
124+
Scores range (0, 2]; 1.0 means perfect fuzzy
125+
match, values > 1 indicate substring hits.
126+
"""
127+
if not query:
128+
return []
129+
130+
query_l = query.lower().strip()
131+
slugs: set[str] = set()
132+
for bucket in build_index(generated_dir).values():
133+
slugs.update(bucket)
134+
135+
scored: list[tuple[str, float]] = []
136+
for slug in slugs:
137+
ratio = difflib.SequenceMatcher(None, query_l, slug).ratio()
138+
if query_l in slug:
139+
ratio += 1.0
140+
if ratio >= 0.4:
141+
scored.append((slug, ratio))
142+
143+
scored.sort(key=lambda x: (-x[1], x[0]))
144+
return scored[:limit]
145+
146+
147+
def suggest(
148+
generated_dir: Path,
149+
topic: str,
150+
limit: int = 5,
151+
) -> list[str]:
152+
"""Return fuzzy-match slugs for a missing topic.
153+
154+
Thin wrapper around :func:`search` that drops scores.
155+
156+
Args:
157+
generated_dir: Template directory.
158+
topic: The (likely misspelled) topic the caller
159+
looked up.
160+
limit: Maximum suggestions.
161+
162+
Returns:
163+
List of slugs ranked by similarity.
164+
"""
165+
return [slug for slug, _ in search(generated_dir, topic, limit=limit)]

0 commit comments

Comments
 (0)