obsidian-sorter: automated note filing for an Obsidian vault

Classifies notes from an inbox folder and files them, with a human
approval step. Zero third-party dependencies.

- type mirrors folder (200 projects / 300 areas / 400 Personal Family)
- TypeSafe JEV for semantic classification (~$0.0001 per note)
- watcher writes a proposal INTO the note; the owner ticks a checkbox
- systemd user service on .27, 10s poll
- 15/15 functional tests
This commit is contained in:
sam
2026-09-26 06:53:24 +10:00
commit 33ec9b5bc5
16 changed files with 3783 additions and 0 deletions

418
sorter/tier2.py Normal file
View File

@@ -0,0 +1,418 @@
"""
Tier 2 — JEV semantic classifier.
Because type mirrors folder, JEV only has to answer two things:
1. Which bucket does this belong to? project / area / family
2. Which existing folder is its home?
The type then falls out of the chosen folder. That is the whole model.
"""
from __future__ import annotations
import re
from .classify import TYPE_TO_TOC, TYPE_TO_TEMPLATE
from .jev import CostTracker, JevClient, JevError
from .models import Note, Proposal, type_from_folder
from .vault import Vault
# The three buckets, described the way Sam thinks about them.
BUCKET_DESCRIPTIONS: dict[str, str] = {
"project": (
"Work in progress. Something being built, fixed, or changed, with a "
"goal and an end state. Language of plans, ideas, TODOs, 'needs to be "
"built', 'original plan was', future tense."
),
"area": (
"An ongoing area of interest that has documentation and notes. "
"Something maintained or studied over time with no end date. "
"Reference material, how-to notes, system documentation."
),
"family": (
"Family and personal life. STRICTLY NON-WORK — no computers, dev-ops, "
"software, networking, coding or IoT. Recipes, holidays, family events, "
"hobbies, personal finance, films, books, art."
),
}
# Folders that are never filing destinations.
EXCLUDED_FOLDER_PARTS = ("inbox", "templates", ".trash", "100 Table of Contents")
# Words never worth proposing as a tag.
TAG_STOPWORDS = {
"note", "notes", "idea", "build", "building", "create", "using",
"with", "from", "that", "this", "into", "about", "setup", "guide",
"project", "tool", "tools", "test", "testing", "make", "made", "new",
"add", "adds", "added", "need", "needs", "want", "wants", "could",
"would", "should", "have", "been", "will", "then", "than", "when",
}
# Manual clarifications for folders whose purpose cannot be inferred from the
# titles of notes already inside them. These override the auto-generated
# "contains: ..." description, and are the place to teach the sorter a rule.
FOLDER_NOTES: dict[str, str] = {
"200 projects/260 Ideas to Build Create Make": (
"UNSTARTED ideas and concepts for things that could exist. Work has NOT "
"begun. Language of 'idea', 'could', 'might', 'what if', 'someday', "
"'would be great if'. This is a parking lot for future possibilities."
),
"200 projects/260 Ideas to Build Create Make": (
"Ideas for things to BUILD — software, code, apps, electronics, "
"hardware, gadgets, devices. Anything involving programming or physical "
"making. Work has NOT begun. This is where TECHNICAL ideas live, even "
"when they are also commercial or could make money."
),
"400 Personal Family/410 Business Ideas": (
"Commercial or business concepts that are NOT primarily technical builds "
"— a service, a shop, a venture, a product concept with no coding or "
"electronics involved. If the idea involves building software or "
"hardware, it belongs in 260 Ideas to Build Create Make instead."
),
"200 projects/250 Electronics": (
"Electronics and hardware builds ACTIVELY IN PROGRESS — parts ordered, "
"wiring, firmware, enclosures, assembly. Language of 'building', "
"'ordered', 'TODO', 'currently working on', 'in progress'."
),
"200 projects/Tools Software WebUI": (
"Software tools and web UIs being built or actively developed."
),
"300 areas/355 AI Research and Techniques": (
"Reference notes and research about AI techniques — memory, planning, "
"models, approaches. Documentation rather than something being built."
),
"300 areas/395 AI Tools to try": (
"AI tools and services noted for evaluation or adoption. Reference "
"material about the tool, not work to build one."
),
"300 areas/390 Web Pages and Tools To Try": (
"Useful websites, tools and resources worth keeping. Reference material."
),
}
class JevClassifier:
"""Tier 2 classifier backed by TypeSafe JEV."""
def __init__(
self,
vault: Vault,
client: JevClient,
tracker: CostTracker | None = None,
max_body_chars: int = 2500,
):
self.vault = vault
self.client = client
self.tracker = tracker or CostTracker()
self.max_body_chars = max_body_chars
self._folder_desc: dict[str | None, dict[str, str]] = {}
self._tag_cache: dict[str, int] | None = None
self._tag_threshold = 0.65
# ------------------------------------------------------------------- tags
def _tag_counts(self) -> dict[str, int]:
"""Every tag already used in the vault, with how often."""
if self._tag_cache is not None:
return self._tag_cache
counts: dict[str, int] = {}
for note in self.vault.scan():
for t in (note.tags or []):
if t:
counts[t.lower()] = counts.get(t.lower(), 0) + 1
self._tag_cache = counts
return counts
def candidate_tags(self, note: Note, limit: int = 8) -> list[str]:
"""
Tags worth asking JEV about: existing tags that appear in the note,
plus a few new candidates drawn from the title.
"""
text = f"{note.title or note.stem} {note.body}".lower().replace("-", " ")
counts = self._tag_counts()
hits: list[tuple[int, str]] = []
for tag, count in counts.items():
needle = tag.replace("-", " ").strip()
if not needle:
continue
# WORD BOUNDARIES: without these, 'pi' matches inside 'pile' and
# 'lights' matches inside 'highlights'.
if re.search(r"\b" + re.escape(needle) + r"\b", text):
hits.append((count, tag))
hits.sort(key=lambda kv: -kv[0])
existing_hits = [t for _c, t in hits[:limit]]
# New candidates from the title, so genuinely new tags are possible.
existing = set(counts)
fresh: list[str] = []
for word in re.findall(r"[a-z][a-z0-9]{3,}", (note.title or note.stem).lower()):
if word in TAG_STOPWORDS or word in existing:
continue
fresh.append(word)
if len(fresh) >= 4:
break
everything = existing_hits + fresh
kept = [
w for w in fresh
if not any(w != o and w in o for o in everything)
]
return self._dedupe_variants(existing_hits + kept)
@staticmethod
def _dedupe_variants(tags: list[str]) -> list[str]:
"""
Drop a tag when a longer tag already covers it.
'obsidian-sorter' subsumes 'obsidian' and 'sorter', so the short forms go.
The more specific tag always wins.
"""
out: list[str] = []
for tag in tags:
if tag in out:
continue
out.append(tag)
return [
t for t in out
if not any(t != o and t in o for o in out)
]
# ------------------------------------------------------------------ setup
def _candidate_folders(self, bucket: str | None = None) -> list[str]:
out = []
for f in self.vault.folders():
low = f.lower()
if any(part in low for part in EXCLUDED_FOLDER_PARTS):
continue
if bucket and type_from_folder(f) != bucket:
continue
out.append(f)
return out
def _folder_descriptions(self, bucket: str | None = None) -> dict[str, str]:
"""Describe each folder with a couple of real example note titles."""
if bucket in self._folder_desc:
return self._folder_desc[bucket]
by_folder: dict[str, list[str]] = {}
for note in self.vault.scan():
if not note.folder:
continue
low = note.folder.lower()
if any(part in low for part in EXCLUDED_FOLDER_PARTS):
continue
by_folder.setdefault(note.folder, []).append(note.display_name)
desc: dict[str, str] = {}
for folder in self._candidate_folders(bucket):
if folder in FOLDER_NOTES:
desc[folder] = f"{folder} — {FOLDER_NOTES[folder]}"
continue
examples = by_folder.get(folder, [])[:3]
if examples:
desc[folder] = f"{folder} — contains: " + "; ".join(examples)
else:
desc[folder] = f"{folder} — (currently empty)"
self._folder_desc[bucket] = desc
return desc
# ------------------------------------------------------------- build input
def _build_state(self, note: Note) -> dict:
body = (note.body or "").strip()
if len(body) > self.max_body_chars:
body = body[: self.max_body_chars] + " …[truncated]"
state: dict = {
"title": note.title or note.stem,
"content": body,
}
if note.tags:
state["existing_tags_on_note"] = note.tags
return state
def _build_questions(self, bucket: str | None = None,
tag_candidates: list[str] | None = None) -> dict:
from .jev import choice, noul
questions = {
"bucket": choice(
"What kind of note is this? Judge by whether the work is "
"FINISHED or ONGOING, and whether it is work at all.",
BUCKET_DESCRIPTIONS,
),
"target_folder": choice(
"Which existing vault folder is the best home for this note? "
"Judge by what each folder already contains.",
self._folder_descriptions(bucket),
),
"needs_new_folder": noul(
"Does this note need a NEW folder that does not exist yet?",
true="No listed folder is a good fit and a new one is needed",
false="One of the listed folders is a good fit",
),
}
# One batched question per tag candidate — same request, no extra cost.
for i, tag in enumerate(tag_candidates or []):
questions[f"tag_{i}"] = noul(
f'Should this note carry the tag "{tag}"?',
true="the note is substantially about this",
false="incidental, unrelated, or too vague to be useful",
)
return questions
# -------------------------------------------------------------- classifying
def classify(self, note: Note, bucket_hint: str | None = None) -> Proposal:
"""
Classify a note.
`bucket_hint` (usually from the author's own tag) constrains the folder
choices to a single bucket, so a note tagged 'project' can never be filed
into an area folder.
"""
try:
tags_offered = self.candidate_tags(note)
result = self.client.ask(
self._build_state(note),
self._build_questions(bucket_hint, tags_offered),
)
except JevError as e:
return self._failed(note, f"JEV unavailable: {e}")
self.tracker.record(result)
if not result.ok:
return self._failed(note, f"JEV request failed: {result.error}")
bucket_ans = result.answer("bucket")
folder_ans = result.answer("target_folder")
new_ans = result.answer("needs_new_folder")
folder = (folder_ans.label if folder_ans else "") or ""
folder_conf = folder_ans.confidence if folder_ans else 0.0
# TYPE COMES FROM THE FOLDER. This is the whole point of the model.
derived = type_from_folder(folder)
bucket = (bucket_ans.label if bucket_ans else "") or ""
if derived:
note_type = derived
elif bucket:
note_type = bucket # folder unknown; trust JEV's bucket
else:
note_type = "area"
needs_new = bool(
new_ans and new_ans.confidence >= 0.5 and folder_conf < 0.60
)
confidences = [a.confidence for a in (bucket_ans, folder_ans) if a]
confidence = min(confidences) if confidences else 0.0
reasons = []
if bucket_ans:
reasons.append(f"JEV bucket: {bucket_ans.label} (conf {bucket_ans.confidence:.2f})")
if folder_ans:
reasons.append(f"JEV folder: {folder_ans.label} (conf {folder_ans.confidence:.2f})")
if derived:
reasons.append(f"type taken from folder: {derived}")
warnings: list[str] = []
# A mismatch between the chosen bucket and the chosen folder's bucket
# is a genuine signal that JEV was torn between two homes.
if bucket and derived and bucket != derived:
warnings.append(
f"JEV was torn: bucket '{bucket}' but folder implies '{derived}' "
"— review before applying"
)
if confidence < 0.5:
warnings.append("Low JEV confidence — recommend human confirmation")
if needs_new:
warnings.append("JEV suggests a NEW folder may be needed (not auto-created)")
if folder and folder not in set(self._candidate_folders(bucket_hint)):
warnings.append(f"JEV chose an unknown folder: {folder}")
alternatives = []
if bucket_ans and bucket_ans.probabilities:
alts = sorted(bucket_ans.probabilities.items(), key=lambda kv: -kv[1])
alternatives = [(k, round(v, 3)) for k, v in alts[1:3]]
# Tags JEV agreed to, merged with any the author already had.
proposed_tags: list[str] = []
for i, tag in enumerate(tags_offered):
a = result.answer(f"tag_{i}")
if a and a.confidence >= self._tag_threshold and tag not in proposed_tags:
proposed_tags.append(tag)
for t in (note.tags or []):
if t and t not in proposed_tags:
proposed_tags.append(t)
# One last pass: drop any tag a more specific tag already covers.
proposed_tags = self._dedupe_variants(proposed_tags)
if proposed_tags:
reasons.append("tags: " + ", ".join(proposed_tags))
return Proposal(
source_rel=note.rel_path,
note_type=note_type,
target_folder=folder,
toc_section=TYPE_TO_TOC.get(note_type, ""),
template=TYPE_TO_TEMPLATE.get(note_type, "Area.md"),
confidence=confidence,
method="jev",
reasons=reasons,
is_new_area=needs_new,
warnings=warnings,
alternatives=alternatives,
proposed_tags=proposed_tags,
)
def _failed(self, note: Note, msg: str) -> Proposal:
return Proposal(
source_rel=note.rel_path, note_type="", target_folder="",
toc_section="", template="", confidence=0.0, method="jev-error",
warnings=[msg],
)
class HybridClassifier:
"""Tier 1 first; escalate to Tier 2 when the rules are not confident."""
def __init__(self, vault: Vault, jev: JevClassifier | None = None, threshold: float = 0.45):
from .classify import Classifier
self.vault = vault
self.rules = Classifier(vault)
self.jev = jev
self.threshold = threshold
def classify(self, note: Note) -> Proposal:
from .classify import explicit_bucket_from_tags
primary = self.rules.classify(note)
if primary.method in ("blocked", "trusted"):
return primary
hint = explicit_bucket_from_tags(note)
needs_jev = (
self.jev is not None
and (primary.method in ("none", "jev-error") or primary.confidence < self.threshold)
)
if not needs_jev:
return primary
secondary = self.jev.classify(note, bucket_hint=hint)
if secondary.method == "jev-error":
primary.warnings.extend(secondary.warnings)
return primary
prefix = f"escalated from rules (confidence {primary.confidence:.2f})"
if hint:
prefix += f"; folder choices constrained to '{hint}' by your tag"
secondary.reasons.insert(0, prefix)
return secondary