ver 1.0
This commit is contained in:
@@ -14,7 +14,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
@@ -54,79 +53,11 @@ def link(a_path: str, b_path: str) -> int:
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- recall -----
|
||||
def _resolve_link_to_path(target: str, nmap: dict) -> str | None:
|
||||
target = target.strip()
|
||||
if "/" in target:
|
||||
return target if target.endswith(".md") else target + ".md"
|
||||
return nmap.get(idx_mod.slugify(target))
|
||||
|
||||
|
||||
def _brief(path: str) -> None:
|
||||
text = links.get_text(path)
|
||||
if text is None:
|
||||
return
|
||||
print(f"\n### {path}")
|
||||
fm_type = re.search(r"(?m)^type:\s*(.+)$", text)
|
||||
if fm_type:
|
||||
print(f"_type: {fm_type.group(1).strip()}_")
|
||||
# Prefer a Status paragraph; else the first handful of non-empty body lines.
|
||||
body = text.split("\n---", 2)[-1] if text.startswith("---") else text
|
||||
status = re.search(r"(?ms)^##\s+Status\s*$(.*?)(?=^##\s|\Z)", body)
|
||||
if status and status.group(1).strip():
|
||||
print(status.group(1).strip()[:400])
|
||||
return
|
||||
shown = 0
|
||||
for line in body.splitlines():
|
||||
if not line.strip() or line.startswith("# "):
|
||||
continue
|
||||
print(line[:200])
|
||||
shown += 1
|
||||
if shown >= 8:
|
||||
break
|
||||
|
||||
|
||||
def recall(query, limit: int = 6) -> int:
|
||||
q = " ".join(query) if isinstance(query, list) else query
|
||||
index = idx_mod.load()
|
||||
nmap = idx_mod.name_map(index)
|
||||
|
||||
status, body = echo.request("POST", f"{echo.BASE}/search/simple/?query={urllib.parse.quote_plus(q)}")
|
||||
echo.check(status, body, "recall search")
|
||||
try:
|
||||
results = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
results = []
|
||||
hits = []
|
||||
for r in results[:limit]:
|
||||
fn = r.get("filename") or r.get("path")
|
||||
if fn and fn not in hits:
|
||||
hits.append(fn)
|
||||
rslug, e = idx_mod.resolve(index, q)
|
||||
if e and e.get("path") and e["path"] not in hits:
|
||||
hits.insert(0, e["path"])
|
||||
|
||||
seen = set(hits)
|
||||
neighbors = [] # (via, path)
|
||||
for p in hits:
|
||||
text = links.get_text(p)
|
||||
if text is None:
|
||||
continue
|
||||
targets = set(links.all_wikilinks(text)) | set(links.source_notes(text))
|
||||
for t in targets:
|
||||
tp = _resolve_link_to_path(t, nmap)
|
||||
if tp and tp not in seen:
|
||||
seen.add(tp)
|
||||
neighbors.append((p, tp))
|
||||
|
||||
print(f"== recall: {q} ==")
|
||||
print(f"\n# Primary hits ({len(hits)})")
|
||||
for p in hits:
|
||||
_brief(p)
|
||||
print(f"\n# Linked context ({len(neighbors)})")
|
||||
for via, p in neighbors:
|
||||
print(f"\n(via {via})")
|
||||
_brief(p)
|
||||
return 0
|
||||
def recall(query, limit: int = 8) -> int:
|
||||
"""Hybrid lexical (BM25) + graph recall — implemented in echo_recall. Kept here as
|
||||
the stable public entrypoint (echo.py and /echo-recall call echo_ops.recall)."""
|
||||
import echo_recall # lazy: only pulls the BM25 modules when recall is actually run
|
||||
return echo_recall.recall(query, limit=limit)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- agent log -------
|
||||
@@ -197,7 +128,30 @@ def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> N
|
||||
|
||||
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
|
||||
aliases=None, sources=None, date: str | None = None, domain: str = "business",
|
||||
inbox: bool = False, no_log: bool = False) -> int:
|
||||
inbox: bool = False, no_log: bool = False, as_json: bool = False,
|
||||
dry_run: bool = False) -> int:
|
||||
import contextlib
|
||||
import io
|
||||
import echo_output
|
||||
import echo_quality
|
||||
|
||||
real_stdout = sys.stdout
|
||||
|
||||
def quiet():
|
||||
# M4: in --json mode, swallow the helper "ok:" chatter so stdout is clean JSON.
|
||||
return contextlib.redirect_stdout(io.StringIO()) if as_json else contextlib.nullcontext()
|
||||
|
||||
def done(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False) -> int:
|
||||
if as_json:
|
||||
act = f"dry-run:{action}" if dry else action
|
||||
env = echo_output.envelope(act, {"kind": kind, "path": path, "title": title,
|
||||
"links_added": links}, ok=ok)
|
||||
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
|
||||
elif dry:
|
||||
print(f"would {action} {kind or '-'} -> {path}")
|
||||
# non-dry human output is the helper "ok:" lines + the summary printed by the caller.
|
||||
return 0 if ok else 1
|
||||
|
||||
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
|
||||
today_s = echo.today()
|
||||
aliases = [a.strip() for a in (aliases or []) if a.strip()]
|
||||
@@ -205,44 +159,74 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
||||
|
||||
# Unknown home -> defer to the inbox (a single idempotent capture line).
|
||||
if inbox or not kind:
|
||||
if dry_run:
|
||||
return done("inbox", "inbox/captures/inbox.md", dry=True)
|
||||
line = f"- {today_s}: {title}"
|
||||
if body_text.strip():
|
||||
line += f" — {body_text.strip().splitlines()[0]}"
|
||||
return echo.cmd_append("inbox/captures/inbox.md", line)
|
||||
with quiet():
|
||||
rc = echo.cmd_append("inbox/captures/inbox.md", line)
|
||||
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
|
||||
|
||||
slug = idx_mod.slugify(title)
|
||||
index = idx_mod.load()
|
||||
_, existing = idx_mod.resolve(index, title)
|
||||
existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)
|
||||
|
||||
if existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200:
|
||||
path = existing["path"]
|
||||
kind = existing.get("kind", kind)
|
||||
_append_to_existing(path, kind, today_s, body_text)
|
||||
action = "updated"
|
||||
if dry_run:
|
||||
if existing_reachable:
|
||||
return done("update", existing["path"], dry=True)
|
||||
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
||||
return done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain), dry=True)
|
||||
|
||||
with quiet():
|
||||
if existing_reachable:
|
||||
path = existing["path"]
|
||||
kind = existing.get("kind", kind)
|
||||
_append_to_existing(path, kind, today_s, body_text)
|
||||
action = "updated"
|
||||
else:
|
||||
if not status_v and kind == "project":
|
||||
status_v = "active"
|
||||
# M2: don't let a 40-char-truncated slug silently collide with a different
|
||||
# entity already in the index — disambiguate to slug-2, slug-3, ...
|
||||
slug = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
||||
path = idx_mod.derive_path(kind, slug, date=date, domain=domain)
|
||||
note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title, body_text, sources)
|
||||
echo.cmd_put(path, echo.temp_file(note.encode()))
|
||||
action = "created"
|
||||
|
||||
# H3: the entity-index write is the race the review flagged — a bare load->save lets
|
||||
# two concurrent captures clobber each other's entry. Route it through a lock-guarded,
|
||||
# fresh-re-read transaction. Returns the fresh index (used by auto-link below).
|
||||
import echo_concurrency
|
||||
index = echo_concurrency.atomic_index_update(
|
||||
lambda idx: idx_mod.upsert(idx, slug, path, kind, title, aliases))
|
||||
|
||||
# auto-link: any other known entity CONFIDENTLY named in the body (M2: is_confident_link
|
||||
# rejects short/common tokens so a 3-char alias or a word like "API" can't link everywhere).
|
||||
linked = 0
|
||||
for s2, e2 in index.get("entities", {}).items():
|
||||
if e2.get("path") in (None, path):
|
||||
continue
|
||||
names = [n for n in [e2.get("title", ""), s2, *e2.get("aliases", [])] if n]
|
||||
if any(echo_quality.is_confident_link(n, body_text) for n in names):
|
||||
ca, cb = links.link_bidirectional(path, e2["path"])
|
||||
linked += int(ca or cb)
|
||||
|
||||
# Keep the BM25 recall index current for this note (best-effort; lock-guarded inside
|
||||
# update_note, so it can't clobber a concurrent writer either).
|
||||
try:
|
||||
import echo_recall
|
||||
echo_recall.update_note(path, links.get_text(path) or "")
|
||||
except Exception as exc: # never let recall-index upkeep fail a capture
|
||||
print(f"echo_ops: recall-index update skipped ({exc})", file=sys.stderr)
|
||||
|
||||
if not no_log:
|
||||
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
|
||||
|
||||
if as_json:
|
||||
done(action, path, links=linked)
|
||||
else:
|
||||
if not status_v and kind == "project":
|
||||
status_v = "active"
|
||||
path = idx_mod.derive_path(kind, slug, date=date, domain=domain)
|
||||
note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title, body_text, sources)
|
||||
echo.cmd_put(path, echo.temp_file(note.encode()))
|
||||
action = "created"
|
||||
|
||||
idx_mod.upsert(index, slug, path, kind, title, aliases)
|
||||
idx_mod.save(index)
|
||||
|
||||
# auto-link: any other known entity whose name/alias appears in the body, matched
|
||||
# on word boundaries (and >=3 chars) so a short alias can't match inside a word.
|
||||
linked = 0
|
||||
for s2, e2 in index.get("entities", {}).items():
|
||||
if e2.get("path") in (None, path):
|
||||
continue
|
||||
names = [n for n in [e2.get("title", ""), s2, *e2.get("aliases", [])] if len(n) >= 3]
|
||||
if any(re.search(rf"\b{re.escape(n)}\b", body_text, re.IGNORECASE) for n in names):
|
||||
ca, cb = links.link_bidirectional(path, e2["path"])
|
||||
linked += int(ca or cb)
|
||||
|
||||
if not no_log:
|
||||
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
|
||||
|
||||
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
|
||||
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user