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
419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""
|
|
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
|