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:
289
sorter/classify.py
Normal file
289
sorter/classify.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Tier 1 deterministic classifier.
|
||||
|
||||
Simplified 2026-09-24. The rule is now trivial:
|
||||
|
||||
type mirrors folder
|
||||
|
||||
200 projects/ -> project
|
||||
300 areas/ -> area
|
||||
400 Personal Family/ -> family
|
||||
|
||||
So the classifier's only real job is to choose a DESTINATION FOLDER. The type
|
||||
then falls out of the folder automatically — nothing is inferred from content
|
||||
beyond a weak first guess.
|
||||
|
||||
Because picking the right folder is genuinely hard, the rules here are
|
||||
deliberately conservative: when they are not confident, the note escalates to
|
||||
Tier 2 (JEV).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import (
|
||||
BUCKETS,
|
||||
Note,
|
||||
Proposal,
|
||||
PLACEHOLDER_TYPE,
|
||||
VALID_TYPES,
|
||||
type_from_folder,
|
||||
)
|
||||
from .vault import Vault
|
||||
|
||||
# --------------------------------------------------------------- TOC + templates
|
||||
|
||||
# Which index note lists each type.
|
||||
TYPE_TO_TOC: dict[str, str] = {
|
||||
"project": "100 Table of Contents/Projects.md",
|
||||
"area": "100 Table of Contents/Areas.md",
|
||||
"family": "100 Table of Contents/Personal Family.md",
|
||||
"index": "100 Table of Contents/Table of Contents.md",
|
||||
}
|
||||
|
||||
# The template a type should use.
|
||||
TYPE_TO_TEMPLATE: dict[str, str] = {
|
||||
"project": "Project.md",
|
||||
"area": "Area.md",
|
||||
"family": "Family.md",
|
||||
"index": "Index.md",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- hints
|
||||
|
||||
# Keywords that weakly suggest a bucket. Used only as a first guess; JEV does
|
||||
# the real work. Order of checks: family, then project, else area.
|
||||
FAMILY_HINTS = [
|
||||
"recipe", "birthday", "holiday", "family", "movie", "film", "book",
|
||||
"game", "travel", "ski", "camp", "garden", "aquaponic", "art", "music",
|
||||
"photography", "investment", "business idea", "school", "basketball",
|
||||
]
|
||||
PROJECT_HINTS = [
|
||||
"phase", "milestone", "build plan", "roadmap", "work log", "checklist",
|
||||
"deliverable", "to do", "todo", "needs to be built", "not built",
|
||||
"original plan was", "next steps", "in progress",
|
||||
]
|
||||
|
||||
STOPWORDS = {
|
||||
"the", "and", "for", "with", "from", "this", "that", "into", "your",
|
||||
"notes", "note", "about", "overview", "setup", "guide", "using", "new",
|
||||
}
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return {
|
||||
w for w in re.findall(r"[a-z0-9]+", text.lower())
|
||||
if w not in STOPWORDS and len(w) > 2
|
||||
}
|
||||
|
||||
|
||||
def explicit_bucket_from_tags(note: "Note") -> str | None:
|
||||
"""
|
||||
If the author tagged the note with a bucket name, honour it.
|
||||
|
||||
Your own tag is the strongest signal available — a human already decided.
|
||||
"""
|
||||
tags = {t.lower() for t in (note.tags or [])}
|
||||
if "project" in tags:
|
||||
return "project"
|
||||
if tags & {"family", "personal"}:
|
||||
return "family"
|
||||
return None
|
||||
|
||||
|
||||
def explicit_bucket_from_tags(note: "Note") -> str | None:
|
||||
"""
|
||||
If the author tagged the note with a bucket name, honour it.
|
||||
|
||||
Your own tag is the strongest signal available — a human already decided.
|
||||
"""
|
||||
tags = {t.lower() for t in (note.tags or [])}
|
||||
if "project" in tags:
|
||||
return "project"
|
||||
if tags & {"family", "personal"}:
|
||||
return "family"
|
||||
return None
|
||||
|
||||
|
||||
# A tag can pin a note to a specific folder — but ONLY when the tag names a
|
||||
# clear CATEGORY (where the note belongs), never when it describes the note's
|
||||
# nature. 'recipe' is a category; 'business-idea' is a property, so it must not
|
||||
# pin anything — an electronics business idea still belongs in projects.
|
||||
TAG_FOLDER_HINTS: dict[str, str] = {
|
||||
"recipe": "400 Personal Family/450 Recipes",
|
||||
"holiday": "400 Personal Family/470 Holidays Travel",
|
||||
"travel": "400 Personal Family/470 Holidays Travel",
|
||||
"ski": "400 Personal Family/470 Holidays Travel",
|
||||
"film": "400 Personal Family/430 Movies Books Plays Games",
|
||||
}
|
||||
|
||||
|
||||
def explicit_folder_from_tags(note: "Note") -> str | None:
|
||||
"""Return a folder pinned by one of the note's tags, if any."""
|
||||
tags = {t.lower() for t in (note.tags or [])}
|
||||
for tag, folder in TAG_FOLDER_HINTS.items():
|
||||
if tag in tags:
|
||||
return folder
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Guess:
|
||||
bucket: str
|
||||
score: float
|
||||
reason: str
|
||||
|
||||
|
||||
class Classifier:
|
||||
"""Deterministic (Tier 1) folder classifier."""
|
||||
|
||||
def __init__(self, vault: Vault):
|
||||
self.vault = vault
|
||||
self._folders: list[str] | None = None
|
||||
|
||||
@property
|
||||
def folders(self) -> list[str]:
|
||||
if self._folders is None:
|
||||
self._folders = self.vault.folders()
|
||||
return self._folders
|
||||
|
||||
# ------------------------------------------------------------------ public
|
||||
|
||||
def classify(self, note: Note) -> Proposal:
|
||||
"""Produce a Proposal for a note. Never writes anything."""
|
||||
# --- guard: untitled notes are never moved -------------------------
|
||||
if note.stem.lower().startswith("untitled"):
|
||||
return Proposal(
|
||||
source_rel=note.rel_path, note_type="", target_folder="",
|
||||
toc_section="", template="", confidence=0.0, method="blocked",
|
||||
reasons=["Filename begins with 'Untitled'"],
|
||||
warnings=[
|
||||
"BLOCKED: untitled notes are never moved automatically. "
|
||||
"Rename it first if it is real content."
|
||||
],
|
||||
)
|
||||
|
||||
current = (note.note_type or "").strip().lower()
|
||||
expected = note.expected_type
|
||||
|
||||
# --- a tag can pin the folder outright -----------------------------
|
||||
pinned = explicit_folder_from_tags(note)
|
||||
if pinned and (note.folder != pinned or current != type_from_folder(pinned)):
|
||||
return Proposal(
|
||||
source_rel=note.rel_path,
|
||||
note_type=type_from_folder(pinned) or "family",
|
||||
target_folder=pinned,
|
||||
toc_section=TYPE_TO_TOC.get(type_from_folder(pinned) or "family", ""),
|
||||
template=TYPE_TO_TEMPLATE.get(type_from_folder(pinned) or "family", "Family.md"),
|
||||
confidence=0.95,
|
||||
method="deterministic",
|
||||
reasons=[f"your tag pinned this to {pinned}"],
|
||||
)
|
||||
|
||||
# --- already correctly filed? --------------------------------------
|
||||
if expected and current == expected:
|
||||
toc = TYPE_TO_TOC.get(current, "")
|
||||
return Proposal(
|
||||
source_rel=note.rel_path, note_type=current,
|
||||
target_folder=note.folder,
|
||||
toc_section=toc, template=TYPE_TO_TEMPLATE.get(current, "Area.md"),
|
||||
confidence=1.0, method="trusted",
|
||||
reasons=[f"type matches its folder ({expected})"],
|
||||
)
|
||||
|
||||
# --- weak first guess ----------------------------------------------
|
||||
guess = self._guess_bucket(note)
|
||||
if guess is None:
|
||||
return Proposal(
|
||||
source_rel=note.rel_path, note_type="", target_folder="",
|
||||
toc_section="", template="", confidence=0.0, method="none",
|
||||
reasons=["No deterministic match"],
|
||||
warnings=["No rule matched — requires Tier 2 (JEV)"],
|
||||
)
|
||||
|
||||
folder, matched = self._pick_folder(note, guess.bucket)
|
||||
toc = TYPE_TO_TOC.get(guess.bucket, "")
|
||||
|
||||
# A blind folder guess (no keyword evidence) must NOT look confident, or
|
||||
# the note never escalates to JEV and lands in an arbitrary folder.
|
||||
confidence = guess.score if matched else min(guess.score, 0.25)
|
||||
|
||||
warnings: list[str] = []
|
||||
if not matched:
|
||||
warnings.append("No keyword evidence for a folder — likely needs Tier 2 (JEV)")
|
||||
elif confidence < 0.6:
|
||||
warnings.append("Low confidence — likely needs Tier 2 (JEV)")
|
||||
|
||||
reasons = [guess.reason]
|
||||
if matched:
|
||||
reasons.append(f"folder matched by keyword: {folder}")
|
||||
|
||||
return Proposal(
|
||||
source_rel=note.rel_path,
|
||||
note_type=guess.bucket,
|
||||
target_folder=folder,
|
||||
toc_section=toc,
|
||||
template=TYPE_TO_TEMPLATE.get(guess.bucket, "Area.md"),
|
||||
confidence=confidence,
|
||||
method="deterministic",
|
||||
reasons=reasons,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- internals
|
||||
|
||||
def _guess_bucket(self, note: Note) -> Guess | None:
|
||||
# Your own tags win: if you tagged it 'project' or 'family', believe you.
|
||||
tagged = explicit_bucket_from_tags(note)
|
||||
if tagged:
|
||||
return Guess(tagged, 0.9, f"you tagged this note '{tagged}'")
|
||||
|
||||
text = " ".join(
|
||||
[(note.title or note.stem).lower(), " ".join(t.lower() for t in note.tags)]
|
||||
).lower()
|
||||
|
||||
fam = sum(1 for k in FAMILY_HINTS if k in text)
|
||||
proj = sum(1 for k in PROJECT_HINTS if k in text)
|
||||
|
||||
# An explicit `type: note` says nothing useful; rely on keywords.
|
||||
if current := (note.note_type or "").strip().lower():
|
||||
if current in BUCKETS and current != PLACEHOLDER_TYPE:
|
||||
return Guess(current, 0.5, f"existing type {current}")
|
||||
|
||||
if fam and fam >= proj:
|
||||
return Guess("family", min(0.7, 0.35 + 0.15 * fam),
|
||||
f"{fam} family keyword(s) matched")
|
||||
if proj:
|
||||
return Guess("project", min(0.7, 0.35 + 0.15 * proj),
|
||||
f"{proj} project keyword(s) matched")
|
||||
|
||||
# Default bucket is area — the catch-all for ongoing material.
|
||||
return Guess("area", 0.3, "defaulted to area (no strong signal)")
|
||||
|
||||
def _pick_folder(self, note: Note, bucket: str) -> tuple[str, bool]:
|
||||
"""
|
||||
Choose a folder inside the bucket.
|
||||
|
||||
Returns (folder, matched) where `matched` is False when there was no
|
||||
keyword evidence and the choice is effectively arbitrary.
|
||||
"""
|
||||
candidates = [
|
||||
f for f in self.folders
|
||||
if type_from_folder(f) == bucket
|
||||
]
|
||||
if not candidates:
|
||||
return "", False
|
||||
|
||||
target = _tokens((note.title or note.stem) + " " + " ".join(note.tags))
|
||||
best, best_score = None, 0
|
||||
for cand in candidates:
|
||||
score = len(target & _tokens(cand))
|
||||
if score > best_score or (score == best_score and best is None):
|
||||
best, best_score = cand, score
|
||||
|
||||
if best_score == 0:
|
||||
# No evidence — return a candidate but flag it as unmatched.
|
||||
return candidates[0], False
|
||||
return best or candidates[0], True
|
||||
Reference in New Issue
Block a user