Compare commits

...

3 Commits

Author SHA1 Message Date
ejlewis ef41b6b30a enhancements 2026-04-19 22:18:10 -05:00
ejlewis 3eff77aa1a Add implementation plan for tag enhancement
Four tasks: taxonomy append, prompt rewrite, tag cap bump, and
end-to-end verification against the Morning Person note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:15:34 -05:00
ejlewis 43d708c834 Add design spec for tag enhancement
Covers taxonomy seeding with a personal-narrative cluster, prompt
rewrite to stop force-fitting taxonomy tags, and raised combined
tag cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:13:07 -05:00
3 changed files with 340 additions and 5 deletions
@@ -0,0 +1,230 @@
# Tag Enhancement Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop the tagger from force-fitting taxonomy tags onto notes that don't match (e.g., tagging a memoir as "productivity"), by seeding a personal-narrative cluster into the taxonomy and rewriting the prompt to permit zero-taxonomy-tag output when nothing fits.
**Architecture:** Single-script tool; all changes land in `tag-notes.py` (system prompt + tag cap) and `tag-taxonomy.yaml` (new cluster). No new files, no new dependencies, no test harness added. Verification is a manual re-run against a known problem note.
**Tech Stack:** Python, PyYAML, ruamel.yaml, local LM Studio (OpenAI-compatible) endpoint.
**Note on testing:** This project has no test infrastructure (per `CLAUDE.md`: "There are no tests, linter, or build step"). The tagger's correctness is judged by LLM output quality against real notes, not unit tests. Each task below ends in a manual spot-check where meaningful; end-to-end verification lives in Task 4.
**Spec:** `docs/superpowers/specs/2026-04-19-tag-enhancement-design.md`
---
## Task 1: Add Personal Narrative cluster to taxonomy
**Files:**
- Modify: `tag-taxonomy.yaml` (append at end of file)
- [ ] **Step 1: Append the new cluster**
Append these lines to the end of `tag-taxonomy.yaml` (there is currently a `# Personal Interests` cluster ending with `- gardening`; add a blank line after `gardening`, then this block):
```yaml
# Personal Narrative & Life
- memoir
- personal-essay
- reflection
- family
- parenting
- recovery
- mental-health
- aging
- relationships
- childhood
- identity
```
- [ ] **Step 2: Verify the YAML still parses**
Run: `python3 -c "import yaml; print(len(yaml.safe_load(open('tag-taxonomy.yaml'))['tags']))"`
Expected: prints an integer equal to the old count + 11 (the old file had 31 tags, so expect `42`). If the number isn't old-count + 11, the YAML is malformed — fix indentation before moving on.
- [ ] **Step 3: Commit**
```bash
git add tag-taxonomy.yaml
git commit -m "Add personal-narrative cluster to tag taxonomy"
```
---
## Task 2: Rewrite the system prompt in `request_metadata`
**Files:**
- Modify: `tag-notes.py:127-145` (the `system_prompt` f-string inside `request_metadata`)
**Context:** Current prompt forces 1-5 taxonomy tags and tells the LLM to be "conservative" about new suggestions. We're flipping both: allow 0 taxonomy tags when nothing fits, and let new suggestions go up to 5.
- [ ] **Step 1: Replace the system_prompt f-string**
In `tag-notes.py`, find the current `system_prompt` assignment inside `request_metadata` (starts at line 127):
```python
system_prompt = f"""You analyze markdown notes and return structured metadata.
Return ONLY valid JSON in this exact shape:
{{
"tags_from_taxonomy": ["tag1", "tag2"],
"new_tag_suggestions": ["newtag1"],
"seo_title_suffix": "Short descriptor that will follow the note title",
"seo_description": "Factual summary between {SEO_DESC_MIN} and {SEO_DESC_MAX} characters.",
"seo_keywords": ["keyword1", "keyword2"]
}}
Rules:
- tags_from_taxonomy: 1-5 tags drawn from the existing taxonomy that best fit the content.
- new_tag_suggestions: 0-2 NEW tags, only when content truly warrants it (be conservative).
- seo_title_suffix: a short, clean, non-clickbaity descriptor of the note. Do NOT include the note title or a leading colon — only the text that would follow "<title>: ". Aim for 4-10 words.
- seo_description: a clean factual summary, STRICTLY between {SEO_DESC_MIN} and {SEO_DESC_MAX} characters inclusive. Count characters carefully before responding.
- seo_keywords: 10-15 relevant keywords, no duplicates.
Existing tag taxonomy: {taxonomy_str}"""
```
Replace it with:
```python
system_prompt = f"""You analyze markdown notes and return structured metadata.
Return ONLY valid JSON in this exact shape:
{{
"tags_from_taxonomy": ["tag1", "tag2"],
"new_tag_suggestions": ["newtag1"],
"seo_title_suffix": "Short descriptor that will follow the note title",
"seo_description": "Factual summary between {SEO_DESC_MIN} and {SEO_DESC_MAX} characters.",
"seo_keywords": ["keyword1", "keyword2"]
}}
Rules:
- Tags should describe what the note is substantively about, not topics it merely mentions in passing.
- tags_from_taxonomy: 0-5 tags drawn from the existing taxonomy, ONLY when they genuinely fit. Do NOT force a taxonomy tag — return an empty list if nothing truly applies.
- new_tag_suggestions: 0-5 NEW tags when the taxonomy doesn't adequately cover the content. Each must be a reusable category (not hyper-specific to one note). Use lowercase-hyphenated style (e.g., personal-essay).
- seo_title_suffix: a short, clean, non-clickbaity descriptor of the note. Do NOT include the note title or a leading colon — only the text that would follow "<title>: ". Aim for 4-10 words.
- seo_description: a clean factual summary, STRICTLY between {SEO_DESC_MIN} and {SEO_DESC_MAX} characters inclusive. Count characters carefully before responding.
- seo_keywords: 10-15 relevant keywords, no duplicates.
Existing tag taxonomy: {taxonomy_str}"""
```
The three substantive changes:
1. Added philosophy line: `- Tags should describe what the note is substantively about, not topics it merely mentions in passing.`
2. `tags_from_taxonomy: 1-5 ... best fit the content.``0-5 ... ONLY when they genuinely fit. Do NOT force a taxonomy tag — return an empty list if nothing truly applies.`
3. `new_tag_suggestions: 0-2 ... be conservative).``0-5 NEW tags when the taxonomy doesn't adequately cover the content. Each must be a reusable category (not hyper-specific to one note). Use lowercase-hyphenated style (e.g., personal-essay).`
- [ ] **Step 2: Syntax check the module**
Run: `python3 -c "import ast; ast.parse(open('tag-notes.py').read()); print('ok')"`
Expected: prints `ok`. If it prints a SyntaxError, the f-string braces or quotes are wrong — fix before moving on.
- [ ] **Step 3: Commit**
```bash
git add tag-notes.py
git commit -m "Allow zero-taxonomy-tag output and more new tag suggestions"
```
---
## Task 3: Raise the combined tag cap from 5 to 8
**Files:**
- Modify: `tag-notes.py:225` (inside `process_note`, in the `if needs_tags:` block)
- [ ] **Step 1: Change the slice**
Find this line in `tag-notes.py` (inside `process_note`, ~line 225):
```python
combined = list(dict.fromkeys(list(taxonomy_tags) + list(new_suggestions)))[:5]
```
Change to:
```python
combined = list(dict.fromkeys(list(taxonomy_tags) + list(new_suggestions)))[:8]
```
- [ ] **Step 2: Syntax check**
Run: `python3 -c "import ast; ast.parse(open('tag-notes.py').read()); print('ok')"`
Expected: prints `ok`.
- [ ] **Step 3: Commit**
```bash
git add tag-notes.py
git commit -m "Raise combined tag cap from 5 to 8"
```
---
## Task 4: End-to-end verification against the problem note
**Files:**
- Modify (temporarily): the "Becoming a Morning Person" note in `~/Documents/ejl-zk/40 Public/41 Notes/`
**Context:** The tagger only touches fields that are currently empty. To force reprocessing we clear the `tags:` field on the problem note, run the script, and inspect the result.
- [ ] **Step 1: Locate the note**
Run: `find ~/Documents/ejl-zk/40\ Public/41\ Notes/ -iname "*morning*person*.md"`
Expected: prints one file path. Note it for the following steps (referred to below as `$NOTE`).
- [ ] **Step 2: Confirm LM Studio is up**
Run: `curl -s http://localhost:1234/v1/models | head -c 200`
Expected: JSON response listing at least one model, including `openai/gpt-oss-20b` (the value of `MODEL_NAME`). If the request fails, start LM Studio and load the model before continuing.
- [ ] **Step 3: Clear the existing tags field**
Open `$NOTE` in an editor and set the `tags:` frontmatter value to an empty list (`tags: []`) or delete the line entirely. Save. Do NOT clear other fields — the script won't touch already-populated ones, which is the desired isolation.
- [ ] **Step 4: Run the tagger**
Run: `cd ~/bin/note-tagger && ./tag-notes.py`
Expected: the script processes every note; for the Morning Person note it prints a line like ` + Added tags: memoir, personal-essay, family, recovery, aging, ...` and does NOT print `productivity` or `learning` in that line.
- [ ] **Step 5: Inspect the frontmatter**
Open `$NOTE` and inspect the `tags:` block.
Pass criteria:
- Contains at least 3 of: `memoir`, `personal-essay`, `reflection`, `family`, `parenting`, `recovery`, `aging`, `relationships`, `childhood`, `identity`.
- Does NOT contain `productivity` or `learning`.
- Has ≤ 8 tags total (enforces the Task 3 cap).
Fail criteria (any triggers a rethink — do NOT patch around it):
- Still includes `productivity` or `learning`.
- Zero tags written.
- More than 8 tags.
If it fails: the follow-up noted in the spec is to add a single few-shot example to the prompt. Stop and report the failing output; don't silently escalate to that change.
- [ ] **Step 6: Spot-check one other already-tagged note**
Run: `git status` in the notes vault (if it's a git repo) OR simply open one other note that already had tags before this run. Confirm its `tags:` field was NOT modified (the script's "only touch empty fields" invariant must still hold).
Expected: no change to that note's tags. If there IS a change, the empty-check logic regressed — stop and investigate.
- [ ] **Step 7: No commit for this task**
Task 4 is verification only. Any prompt-tuning follow-up is out of scope for this plan.
---
## Out of scope (from spec)
- Two-pass LLM classification.
- Few-shot examples in the prompt (follow-up candidate only if Task 4 fails).
- Changes to `seo_*` fields, `CONTENT_CHAR_LIMIT`, retry flow, slug derivation, or YAML round-tripping.
@@ -0,0 +1,97 @@
# Tag Enhancement Design
Date: 2026-04-19
## Problem
The tagger is producing wrong tags for personal-narrative content. Concrete example: the essay "Becoming a Morning Person" — a memoir about life stages, family, recovery, aging, and parenting — was tagged `productivity` and `learning`.
Root cause is twofold:
1. **Taxonomy gap.** `tag-taxonomy.yaml` has no categories that cover personal narrative, memoir, family, or reflection. The closest fits the LLM can find are productivity-adjacent tags from the "Knowledge & Learning" cluster.
2. **Prompt bias.** The system prompt in `request_metadata` (tag-notes.py:127) requires 1-5 taxonomy tags (no zero option) and tells the LLM to be "conservative" about new tag suggestions (0-2 max). Together these force the model to pick taxonomy tags even when none genuinely apply, and discourage it from proposing the new categories that would better describe the content.
## Goals
- The "Becoming a Morning Person" essay should be tagged with memoir/personal-narrative concepts, not productivity/learning.
- Future notes that fall outside the current taxonomy should surface new tag suggestions rather than force-fit existing ones.
- Taxonomy can grow over time via the existing `new_tag_accumulator` → end-of-run prompt flow — no change to that mechanism.
## Non-Goals
- No two-pass LLM classification. Single call per note stays.
- No few-shot examples in the prompt for this iteration. May be added as a follow-up if the 20B local model underperforms on the rewritten prompt.
- No change to `seo_title_suffix`, `seo_description`, `seo_keywords`, `CONTENT_CHAR_LIMIT`, the SEO-description retry flow, YAML round-tripping, or the slug derivation.
## Changes
### 1. Taxonomy additions (`tag-taxonomy.yaml`)
Add a new cluster at the end of the file:
```yaml
# Personal Narrative & Life
- memoir
- personal-essay
- reflection
- family
- parenting
- recovery
- mental-health
- aging
- relationships
- childhood
- identity
```
Rationale: these are deliberately broad and reusable. `sobriety` was considered and rejected — not expected to be a recurring theme. `recovery` is retained as a broader concept (recovery from any kind of setback, not only substance-related).
### 2. Prompt rewrite in `request_metadata` (tag-notes.py:127)
Three changes to the system prompt:
**a. Add a tagging philosophy sentence** at the top of the `Rules:` section:
> Tags should describe what the note is substantively about, not topics it merely mentions in passing.
**b. Allow zero taxonomy tags.** Replace:
> `tags_from_taxonomy: 1-5 tags drawn from the existing taxonomy that best fit the content.`
with:
> `tags_from_taxonomy: 0-5 tags drawn from the existing taxonomy, ONLY when they genuinely fit. Do NOT force a taxonomy tag — return an empty list if nothing truly applies.`
**c. Loosen new-tag suggestions.** Replace:
> `new_tag_suggestions: 0-2 NEW tags, only when content truly warrants it (be conservative).`
with:
> `new_tag_suggestions: 0-5 NEW tags when the taxonomy doesn't adequately cover the content. Each must be a reusable category (not hyper-specific to one note). Use lowercase-hyphenated style (e.g., personal-essay).`
### 3. Raise the combined tag cap (tag-notes.py:225)
Change `[:5]` to `[:8]`:
```python
combined = list(dict.fromkeys(list(taxonomy_tags) + list(new_suggestions)))[:8]
```
Memoir and reflection-style notes often legitimately touch 6-8 distinct themes; capping at 5 was causing otherwise-accurate tags to be dropped.
## Verification
After implementation:
1. Open the "Becoming a Morning Person" note in `~/Documents/ejl-zk/40 Public/41 Notes/` and clear its `tags:` frontmatter field (set to empty list).
2. Run `./tag-notes.py`.
3. Confirm the new `tags` value includes memoir/personal-essay-style tags (e.g., `memoir`, `personal-essay`, `family`, `recovery`, `aging`, `reflection`) and does NOT include `productivity` or `learning`.
4. Spot-check 1-2 other notes that already have reasonable tags — confirm the rewrite didn't regress them. (All LLM-backed fields are only touched when empty, so notes with existing tags won't be reprocessed at all.)
If the tags still look off, the follow-up is to add a single few-shot example to the system prompt showing a memoir case with zero taxonomy tags and all new suggestions — not included in this change.
## Files Touched
- `tag-taxonomy.yaml` — append new cluster
- `tag-notes.py``request_metadata` system prompt (~20 lines) and the `[:5]``[:8]` cap
+13 -5
View File
@@ -89,7 +89,7 @@ def parse_json_response(content):
return None
def call_llm_json(system_prompt, user_prompt, max_tokens=900):
def call_llm_json(system_prompt, user_prompt, max_tokens=4000):
payload = {
"model": MODEL_NAME,
"messages": [
@@ -101,14 +101,22 @@ def call_llm_json(system_prompt, user_prompt, max_tokens=900):
"response_format": {"type": "text"},
}
try:
response = requests.post(LM_STUDIO_URL, json=payload, timeout=120)
response = requests.post(LM_STUDIO_URL, json=payload, timeout=600)
if not response.ok:
print(f" ! LLM error: {response.status_code} {response.reason}")
print(f" body: {response.text[:500]}")
return None
result = response.json()
content = result['choices'][0]['message']['content']
return parse_json_response(content)
choice = result['choices'][0]
content = choice['message'].get('content') or ''
parsed = parse_json_response(content)
if parsed is None:
finish = choice.get('finish_reason')
print(f" ! LLM returned no parseable JSON (finish_reason={finish})")
if finish == 'length':
print(" Hit max_tokens — reasoning model burned the budget before emitting output.")
print(f" content: {content[:500]!r}")
return parsed
except Exception as e:
print(f" ! LLM error: {e}")
return None
@@ -161,7 +169,7 @@ Your previous description was {len(previous_desc)} characters, outside the allow
"{previous_desc}"
Rewrite it to fit strictly within {SEO_DESC_MIN}-{SEO_DESC_MAX} characters."""
result = call_llm_json(system_prompt, user_prompt, max_tokens=400)
result = call_llm_json(system_prompt, user_prompt)
if result:
return (result.get('seo_description') or '').strip()
return ''