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