Files
obsidian-sorter/sorter/classify.py
sam 33ec9b5bc5 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
2026-09-26 06:53:24 +10:00

290 lines
10 KiB
Python

"""
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