Memory Bank のプロファイルは、スキーマとスコープの組に対して単一のものが保持されます。つまりスコープは「誰のものか」を指す識別子ではなく、保存先そのものを決める複合キーです。キーがぶれれば、そこにあるのは別のプロファイルであって、空のプロファイルは正常な応答として返ります。呼び出し側が {"user_id": 123} と書いた日と {"user_id": "123"} と書いた日で、行き先が変わる。この構造に気づくまで、私はずっと「記憶が定着しない」という誤った問題を解こうとしていました。
勘で直すのが嫌だったので、手元に小さなハーネスを書いて測りました。以下は API キーを使わず、(schema, scope) に対して単一プロファイルという意味論だけをローカルに再現したものです。数値はすべてこのハーネスを実際に走らせて得たもので、推定値は含みません。そして結果は、私が用意した「正しそうな対処」の順位を、ほぼ逆さまにしました。
スコープは識別子ではなく、保存先を決める複合キーでした
まず、模型を用意します。Memory Bank 側の実装を推測するのではなく、公表されている意味論——スキーマとスコープの組ごとに単一のプロファイルが source-of-truth として保たれる——だけをそのまま写します。
def run_with_fallback(calls, norm): store = {} hits = miss = leaks = 0 for app, u, kind, s, tier in calls: k = (SCHEMA, norm(s)) p = store.get(k) if p is None: # 見つからなければ user_id が一致する既存プロファイルを探す want = str(s.get("user_id", "")).strip() for (_sch, kk), v in store.items(): try: kd = json.loads(kk) except Exception: continue if str(kd.get("user_id", "")).strip() == want: p = v if v["owner"] != (app, u): leaks += 1 # 別テナントの記録を返した break if p is None: miss += 1 store[k] = {"owner": (app, u), "billing_plan": tier} else: hits += 1 n = hits + miss return {"hit": 100 * hits / n, "profiles": len(store), "leaks": leaks}
#!/usr/bin/env python3"""scope drift lint — 同じ scope キーの書かれ方が場所によってぶれている箇所を指摘する。絶対的な正しさは判定しない。多数派と異なる書き方だけを不一致として報告する。"""import astimport sysimport pathlibfrom collections import defaultdictALLOWED = {"app_id", "user_id"}SINKS = {"ingest", "retrieve_profile", "generate_memories"}def shape(v): """値の「書かれ方」を分類する。str() 済みか、素の変数か、定数か。""" if isinstance(v, ast.Call) and isinstance(v.func, ast.Name) and v.func.id == "str": return "str()" if isinstance(v, ast.Name): return "bare-name" if isinstance(v, ast.JoinedStr): return "f-string" if isinstance(v, ast.Constant): return f"const:{type(v.value).__name__}" return "other"class Collect(ast.NodeVisitor): """scope= に渡される辞書リテラルを集める。変数経由の1段も追う。""" def __init__(self): self.sites = [] self.assigned = {} def visit_Assign(self, n): if (isinstance(n.value, ast.Dict) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)): self.assigned[n.targets[0].id] = n.value self.generic_visit(n) def visit_Call(self, n): fname = n.func.attr if isinstance(n.func, ast.Attribute) else getattr(n.func, "id", None) if fname in SINKS: for kw in n.keywords: if kw.arg != "scope": continue d = kw.value if isinstance(kw.value, ast.Dict) \ else self.assigned.get(getattr(kw.value, "id", None)) if isinstance(d, ast.Dict): self.sites.append(d) self.generic_visit(n)def scan(root): sites = [] for p in sorted(pathlib.Path(root).rglob("*.py")): try: tree = ast.parse(p.read_text(encoding="utf-8")) except SyntaxError: continue c = Collect() c.visit(tree) sites += [(str(p), d) for d in c.sites] # 第1パス: キーごとに「どの書かれ方が何回出たか」を数え、多数派を決める shapes = defaultdict(lambda: defaultdict(int)) for _, d in sites: for k, v in zip(d.keys, d.values): if isinstance(k, ast.Constant) and k.value in ALLOWED: shapes[k.value][shape(v)] += 1 majority = {k: max(s.items(), key=lambda x: x[1])[0] for k, s in shapes.items()} # 第2パス: 多数派から外れた箇所と、許可外キー・必須キー欠落を報告する out = [] for path, d in sites: keys = [] for k, v in zip(d.keys, d.values): if not (isinstance(k, ast.Constant) and isinstance(k.value, str)): out.append((path, d.lineno, "dynamic-key", "キーが定数文字列でない")) continue keys.append(k.value) if k.value not in ALLOWED: out.append((path, d.lineno, "extra-key", f"許可外のキー {k.value!r} — プロファイルが分裂します")) continue sh = shape(v) if sh != majority[k.value]: out.append((path, d.lineno, "shape-drift", f"{k.value} が {sh}(コーパス多数派は {majority[k.value]})")) if sh.startswith("const:") and sh != "const:str": out.append((path, d.lineno, "type-drift", f"{k.value} が文字列でない")) for m in sorted(ALLOWED - set(keys)): out.append((path, d.lineno, "missing-key", f"必須キー欠落 {m!r}")) return outif __name__ == "__main__": rows = scan(sys.argv[1] if len(sys.argv) > 1 else ".") for f, l, kind, msg in rows: print(f"{f}:{l}: [{kind}] {msg}") print(f"-- {len(rows)} 件", file=sys.stderr) sys.exit(1 if rows else 0) # CI で落とす
なお本稿の計測は実 API ではなく、(schema, scope) に対して単一プロファイルという意味論をローカルに再現したハーネスによるものです。分裂と併合の起き方はキーの決まり方だけで説明できるため模型で十分だと判断しましたが、課金や遅延を含む判断をされる場合はご自身の構成で測り直してください。ハーネスは本文のコードをそのまま繋げれば動きます。