b4e923c2e7
Build and Push Docker Image / build (push) Successful in 14s
One schema-bump release (recall-index schema 3, entity-index schema 2; old recall indexes rebuild automatically, no vault migration): - Local-first recall index: the live BM25 index lives in the machine state dir (keyed by endpoint hash); capture's upkeep is a zero-network atomic file write, no advisory lock — replacing the O(vault) GET/PUT-whole-index round trip per write. The vault copy is a snapshot (sweep + session-end) used to seed fresh machines / catch up after another client swept (ECHO_RECALL_SYNC_HOURS, default 24). The read path never PUTs the vault. - Incremental sweep: entity + recall meta carry 16-char content hashes; `sweep --fast` fetches only new/gone/hash-missing notes plus a rotating weekday shard (--all-shards forces everything); deletions drop from both indexes; index-only so no --apply gate. `load` auto-runs it past ECHO_FAST_SWEEP_DAYS (7); doctor reports index freshness. Obsidian-side edits now reach the indexes without manual maintenance. - Stemming + alias expansion: echo_stem.py (conservative Porter-lite, unit- tested families + over-stemming guards) applied at index AND query time; a query fuzzy-matching an entity folds its title/alias vocabulary into the BM25 query at half weight — expansion can only boost docs containing the terms. capture hashes the note's FINAL content (auto-link reordered first). Eval gold set 8 -> 14 queries (6 paraphrases): recall@5/MRR 1.00/1.00 vs keyword baseline 0.75/0.79. +3 unit tests, +10 e2e; test harnesses now isolate ECHO_STATE_DIR. All seven suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""test_patch_semantics.py — H4: prove the hi-fi mock models REAL PATCH semantics.
|
|
|
|
These are exactly the behaviors the shipped naive mock cannot reproduce (it appends
|
|
everything at EOF), so without this the section-replace / section-append / missing-
|
|
heading / frontmatter-replace paths are untested. Drives the real echo.py against
|
|
mock_olrapi_hifi.py.
|
|
|
|
Run: python test_patch_semantics.py [--port 8820]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ECHO = HERE.parent / "echo-memory.plugin.src" / "skills" / "echo-memory" / "scripts" / "echo.py"
|
|
KEY = "test-key-not-a-real-secret"
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond, detail=""):
|
|
print(f"{'ok ' if cond else 'FAIL'} {name}" + (f" -- {detail}" if not cond else ""))
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
def http(method, url, body=None, headers=None):
|
|
data = body.encode() if isinstance(body, str) else body
|
|
req = urllib.request.Request(url, data=data, method=method,
|
|
headers={"Authorization": f"Bearer {KEY}", **(headers or {})})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return r.status, r.read().decode("utf-8", "replace")
|
|
except Exception as e: # noqa: BLE001
|
|
return getattr(e, "code", 0), ""
|
|
|
|
|
|
def tmp(content):
|
|
f = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
|
|
f.write(content)
|
|
f.close()
|
|
return f.name
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--port", type=int, default=8820)
|
|
a = ap.parse_args()
|
|
base = f"http://127.0.0.1:{a.port}"
|
|
srv = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi_hifi.py"), "--port", str(a.port)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
|
|
def echo(*args):
|
|
import tempfile
|
|
env = dict(os.environ, ECHO_BASE=base, ECHO_KEY=KEY, ECHO_VERIFY="0",
|
|
ECHO_STATE_DIR=globals().setdefault("_STATE_DIR", tempfile.mkdtemp()))
|
|
return subprocess.run([sys.executable, str(ECHO), *args], capture_output=True, text=True, env=env)
|
|
|
|
def ground(path):
|
|
_, body = http("GET", f"{base}/__debug__?path={path}")
|
|
return None if body == "<<MISSING>>" else body
|
|
|
|
try:
|
|
for _ in range(50):
|
|
try:
|
|
urllib.request.urlopen(f"{base}/__debug__reset", data=b"", timeout=1)
|
|
break
|
|
except Exception:
|
|
time.sleep(0.1)
|
|
|
|
doc = "doc.md"
|
|
seed = ("---\ntype: note\nupdated: 2026-01-01\n---\n\n"
|
|
"# Doc\n\n## Scope\nold scope\n\n## Other\nkeep me\n")
|
|
|
|
# replace: Scope body becomes new; Other untouched.
|
|
http("PUT", f"{base}/vault/{doc}", seed)
|
|
echo("patch", doc, "replace", "heading", "Doc::Scope", tmp("new scope"))
|
|
g = ground(doc) or ""
|
|
check("heading replace swaps section body", "new scope" in g and "old scope" not in g, g)
|
|
check("heading replace leaves sibling section intact", "## Other\nkeep me" in g, g)
|
|
|
|
# append: lands INSIDE the section (before '## Other'), not at EOF.
|
|
echo("patch", doc, "append", "heading", "Doc::Scope", tmp("appended line"))
|
|
g = ground(doc) or ""
|
|
check("heading append inserts within the section (not EOF)",
|
|
"appended line" in g and g.index("appended line") < g.index("## Other"), g)
|
|
|
|
# prepend: lands at the TOP of the section body.
|
|
echo("patch", doc, "prepend", "heading", "Doc::Scope", tmp("first line"))
|
|
g = ground(doc) or ""
|
|
check("heading prepend inserts at section top",
|
|
"first line" in g and g.index("first line") < g.index("new scope"), g)
|
|
|
|
# missing heading -> 400 -> echo.py exits non-zero (the silent-loss guard).
|
|
r = echo("patch", doc, "append", "heading", "Doc::Nope", tmp("lost?"))
|
|
check("missing heading fails loud (non-zero exit)", r.returncode != 0, r.stderr)
|
|
check("missing heading does not write", "lost?" not in (ground(doc) or ""))
|
|
|
|
# frontmatter replace on an existing field updates it.
|
|
echo("fm", doc, "updated", "2026-06-22")
|
|
g = ground(doc) or ""
|
|
check("frontmatter replace updates existing field", "updated: 2026-06-22" in g, g)
|
|
|
|
# fm on a MISSING field: the server still 400s the raw PATCH (matches real API),
|
|
# but cmd_fm (v1.5) falls back to a surgical create — the key is inserted and
|
|
# every other line is preserved. (The old contract failed loud on exactly the
|
|
# notes that needed repair.)
|
|
before = ground(doc) or ""
|
|
r = echo("fm", doc, "nonexistent_field", "x")
|
|
check("fm creates a missing field (create-or-replace)", r.returncode == 0,
|
|
r.stderr or r.stdout)
|
|
g = ground(doc) or ""
|
|
check("fm-created key holds the value", "nonexistent_field: x" in g, g)
|
|
check("fm create preserves every existing line",
|
|
all(line in g for line in before.splitlines() if line.strip()), g)
|
|
|
|
# raw `patch replace frontmatter` (no fallback) still fails loud on a missing key.
|
|
r = echo("patch", doc, "replace", "frontmatter", "still_missing", tmp('"x"'))
|
|
check("raw frontmatter PATCH of a missing field fails loud", r.returncode != 0, r.stderr)
|
|
|
|
print(f"\n{len(failures)} failure(s)" if failures else "\nall PATCH-semantics tests passed")
|
|
return 1 if failures else 0
|
|
finally:
|
|
srv.terminate()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|