forked from jason/echo
ver 1.2
This commit is contained in:
@@ -26,6 +26,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo # noqa: E402
|
||||
import echo_quality # noqa: E402 — distinctive-token guards for fuzzy candidate matching
|
||||
|
||||
INDEX_PATH = "_agent/index/entities.json"
|
||||
|
||||
@@ -61,6 +62,48 @@ def slugify(text: str) -> str:
|
||||
return text[:40] or "untitled"
|
||||
|
||||
|
||||
def derive_aliases(title: str) -> list[str]:
|
||||
"""Safe, lossless name variants of a title — case/punctuation normalizations that are
|
||||
unambiguously the SAME name (hyphen<->space, lowercased). Deliberately NOT acronyms or
|
||||
token subsets: those collide across entities and would make resolve() match too much.
|
||||
Lets `echo-memory` and `echo memory` both resolve to a title written either way."""
|
||||
t = str(title or "").strip()
|
||||
if not t:
|
||||
return []
|
||||
low = t.lower()
|
||||
variants = {low, slugify(t), slugify(t).replace("-", " "),
|
||||
low.replace("-", " "), low.replace(" ", "-")}
|
||||
return sorted(v for v in variants if v)
|
||||
|
||||
|
||||
_WORD = re.compile(r"[a-z0-9]+")
|
||||
|
||||
|
||||
def _sig_tokens(s) -> set[str]:
|
||||
"""Distinctive tokens of a string: long enough and not a common word (reuses the
|
||||
auto-link guards), so 'data'/'api'/'the' can't drive a fuzzy match on their own."""
|
||||
return {t for t in _WORD.findall(str(s or "").lower())
|
||||
if len(t) >= echo_quality.MIN_NAME_LEN and t not in echo_quality._COMMON}
|
||||
|
||||
|
||||
def aliases_in_frontmatter(text: str) -> list[str]:
|
||||
"""Read an `aliases:` list (flow `[a, b]` or block `- a`) from a note's YAML frontmatter.
|
||||
Frontmatter is the durable, Obsidian-native home for aliases; sweep folds these into the
|
||||
index so they survive an index rebuild."""
|
||||
if not str(text).startswith("---"):
|
||||
return []
|
||||
end = text.find("\n---", 3)
|
||||
fm = text[:end] if end != -1 else text
|
||||
flow = re.search(r"(?m)^aliases:\s*\[(.*?)\]", fm)
|
||||
if flow:
|
||||
return [a.strip().strip('"').strip("'") for a in flow.group(1).split(",") if a.strip()]
|
||||
block = re.search(r"(?m)^aliases:\s*\n((?:[ \t]*-[ \t]*.+\n?)+)", fm)
|
||||
if block:
|
||||
return [ln.strip().lstrip("-").strip().strip('"').strip("'")
|
||||
for ln in block.group(1).splitlines() if ln.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def derive_path(kind: str, slug: str, date: str | None = None, domain: str = "business") -> str:
|
||||
folder = KIND_FOLDER.get(kind)
|
||||
if folder is None:
|
||||
@@ -128,7 +171,9 @@ def _norm(s) -> str:
|
||||
|
||||
|
||||
def resolve(index: dict, mention: str):
|
||||
"""Return (slug, entry) for a mention matched by slug, title, or alias — else (None, None)."""
|
||||
"""Return (slug, entry) for a mention EXACTLY matched by slug, title, or alias — else
|
||||
(None, None). Exact-only by design: this drives capture's create-vs-update decision, so a
|
||||
loose match here would silently merge distinct entities. Fuzzy lookup is fuzzy_candidates()."""
|
||||
key = _norm(mention)
|
||||
ents = index.get("entities", {})
|
||||
if key in ents:
|
||||
@@ -143,6 +188,29 @@ def resolve(index: dict, mention: str):
|
||||
return None, None
|
||||
|
||||
|
||||
def fuzzy_candidates(index: dict, mention: str, limit: int = 5):
|
||||
"""Advisory near-matches for when resolve() finds no exact hit. Ranks entities sharing
|
||||
>=1 *distinctive* token (>= MIN_NAME_LEN, not a common word) with the mention, so a
|
||||
shortened or expanded name — 'echo memory' for the project 'echo' — surfaces the existing
|
||||
note instead of vanishing. Returns [(slug, entry, score)] sorted best-first. NEVER used to
|
||||
auto-merge (that's resolve's job); only to SURFACE 'did you mean' candidates."""
|
||||
mtoks = _sig_tokens(mention)
|
||||
if not mtoks:
|
||||
return []
|
||||
scored = []
|
||||
for slug, e in index.get("entities", {}).items():
|
||||
etoks = set()
|
||||
for name in (slug, e.get("title", ""), *e.get("aliases", [])):
|
||||
etoks |= _sig_tokens(name)
|
||||
overlap = mtoks & etoks
|
||||
if not overlap or not etoks:
|
||||
continue
|
||||
score = round(len(overlap) / min(len(mtoks), len(etoks)), 3)
|
||||
scored.append((score, len(overlap), slug, e))
|
||||
scored.sort(key=lambda x: (-x[0], -x[1], x[2]))
|
||||
return [(slug, e, score) for score, _, slug, e in scored[:limit]]
|
||||
|
||||
|
||||
def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = None,
|
||||
aliases=None) -> dict:
|
||||
ents = index.setdefault("entities", {})
|
||||
@@ -150,7 +218,11 @@ def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = Non
|
||||
e["path"] = path
|
||||
e["kind"] = kind
|
||||
e["title"] = title or e.get("title") or slug
|
||||
merged = set(e.get("aliases", [])) | set(aliases or [])
|
||||
# Keep any prior + explicit aliases, and ALWAYS fold in the safe title variants so a
|
||||
# hyphen/space/case form of the name resolves even if no one passed --aliases. Drop any
|
||||
# alias that just equals the slug (resolve already checks the slug).
|
||||
merged = set(e.get("aliases", [])) | set(aliases or []) | set(derive_aliases(e["title"]))
|
||||
merged.discard(slug)
|
||||
e["aliases"] = sorted(a for a in merged if a)
|
||||
e["last_seen"] = echo.today()
|
||||
ents[slug] = e
|
||||
|
||||
Reference in New Issue
Block a user