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:
418
sorter/tier2.py
Normal file
418
sorter/tier2.py
Normal 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
|
||||
Reference in New Issue
Block a user