"""Build a larger hard-negative dataset for the automatic memory controller. The normal bootstrap corpus contains mostly short, obvious examples. This hard set adds realistic negations, questions, replacement requests, and long noise clauses so the write/forget heads are evaluated on decisions that are easy to confuse with durable facts. """ from __future__ import annotations import argparse import json import random import string from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).resolve().parent ATTRIBUTES = ( "常用时区", "最喜欢的水果", "项目代号", "备用联系人", "默认输出风格", "工作区域", "提醒时间", "档案代号", ) TRAIN_FACT_TEMPLATES = ( "请记住:我的{attribute}是{value}。", "以后涉及{attribute}时,请使用{value}这个值。", "个人资料更新——我的{attribute}等于{value},以后可能会问到。", "请把这条个人资料保存下来:我的{attribute}为{value}。", "我的{attribute}是{value},这是需要长期保留的信息。", ) EVAL_FACT_TEMPLATES = ( "登记一下,我的{attribute}:{value}。", "将我的{attribute}记为{value},后续请按这个资料回答。", "长期资料里新增一项:{attribute}={value}。", "请把我的{attribute}保存成{value}。", ) TRAIN_NOISE_TEMPLATES = ( "这是普通对话噪声:我暂时提到一个无关编号{value},不需要长期保存。", "随口一提,编号{value}只是临时信息,请不要记住。", "请不要把这句话写入长期记忆:今天看到的临时编号是{value}。", "这只是一次性测试值{value},不用保存,也不要据此推断个人资料。", "聊天中的无关内容:{value};它不是我的个人事实。", ) EVAL_NOISE_TEMPLATES = ( "临时提到{value},这不是需要保存的资料。", "忽略这个一次性编号{value},不要将它写入记忆。", "普通闲聊内容:{value},没有长期价值。", "不要记住{value},它只是当前消息里的干扰项。", ) TRAIN_QUERY_TEMPLATES = ( "我的{attribute}是什么?", "只根据已经保存的资料,告诉我{attribute}。", "不要猜测,请读取记忆回答:我的{attribute}为?", "记忆中是否有我的{attribute}?", ) EVAL_QUERY_TEMPLATES = ( "跨对话后,我登记的{attribute}是哪一个?", "请从长期资料中查找我的{attribute}。", "之前保存的{attribute}内容是什么?", ) TRAIN_FORGET_TEMPLATES = ( "请删除关于我的{attribute}的记忆,不要再保留{value}。", "忘掉我的{attribute},这条资料已经失效。", "撤销之前保存的{attribute},以后不要再使用它。", "清除我的{attribute}记录;{value}不再有效。", ) EVAL_FORGET_TEMPLATES = ( "请移除长期记忆中的{attribute},不要继续记住它。", "我的{attribute}已经作废,请忘记这项资料。", "撤回关于{attribute}的个人信息,不要再保留。", ) def _value(rng: random.Random, prefix: str) -> str: alphabet = string.ascii_uppercase + string.digits return f"{prefix}-" + "".join(rng.choice(alphabet) for _ in range(8)) def _row( *, group: str, index: int, text: str, write: float, forget: float, kind: str, attribute: str, value: str, ) -> dict[str, Any]: return { "id": f"{group}:{index}", "group_id": group, "text": text, "messages": [{"role": "user", "content": text}], "write_label": write, "forget_label": forget, "kind": kind, "source": "synthetic_memory_policy_hardset", "subject": "验证用户", "attribute": attribute, "value": value, "answer": "", "answerable": None, } def _build_split( *, count: int, split: str, seed: int, fact_templates: tuple[str, ...], noise_templates: tuple[str, ...], query_templates: tuple[str, ...], forget_templates: tuple[str, ...], ) -> list[dict[str, Any]]: rng = random.Random(seed) rows: list[dict[str, Any]] = [] for group_index in range(count): group = f"hard-{split}-{group_index:06d}" attribute = ATTRIBUTES[group_index % len(ATTRIBUTES)] fact_value = _value(rng, "FACT") noise_value = _value(rng, "NOISE") replacement_value = _value(rng, "NEW") rows.extend( ( _row( group=group, index=0, text=rng.choice(fact_templates).format(attribute=attribute, value=fact_value), write=1.0, forget=0.0, kind="fact", attribute=attribute, value=fact_value, ), _row( group=group, index=1, text=rng.choice(noise_templates).format(value=noise_value), write=0.0, forget=0.0, kind="noise", attribute="", value=noise_value, ), _row( group=group, index=2, text=rng.choice(query_templates).format(attribute=attribute), write=0.0, forget=0.0, kind="query", attribute=attribute, value="", ), _row( group=group, index=3, text=rng.choice(forget_templates).format(attribute=attribute, value=fact_value), write=0.0, forget=1.0, kind="forget", attribute=attribute, value=fact_value, ), _row( group=group, index=4, text=rng.choice(fact_templates).format(attribute=attribute, value=replacement_value), write=1.0, # A replacement is a write/update, not a delete. The # runtime retires the matched old version and keeps the # new fragment active. Only an explicit forget request # receives forget_label=1. forget=0.0, kind="replacement", attribute=attribute, value=replacement_value, ), ) ) rng.shuffle(rows) return rows def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output-dir", default="data/production_memory_hard") parser.add_argument("--train-groups", type=int, default=4000) parser.add_argument("--eval-groups", type=int, default=1000) parser.add_argument("--seed", type=int, default=20260905) args = parser.parse_args() if args.train_groups < 1 or args.eval_groups < 1: raise SystemExit("group counts must be positive") output_dir = Path(args.output_dir) if not output_dir.is_absolute() and not output_dir.exists(): output_dir = PROJECT_ROOT / output_dir output_dir.mkdir(parents=True, exist_ok=True) train_rows = _build_split( count=args.train_groups, split="train", seed=args.seed, fact_templates=TRAIN_FACT_TEMPLATES, noise_templates=TRAIN_NOISE_TEMPLATES, query_templates=TRAIN_QUERY_TEMPLATES, forget_templates=TRAIN_FORGET_TEMPLATES, ) eval_rows = _build_split( count=args.eval_groups, split="eval", seed=args.seed + 1, fact_templates=EVAL_FACT_TEMPLATES, noise_templates=EVAL_NOISE_TEMPLATES, query_templates=EVAL_QUERY_TEMPLATES, forget_templates=EVAL_FORGET_TEMPLATES, ) for name, rows in (("train", train_rows), ("eval", eval_rows)): with (output_dir / f"{name}.jsonl").open("w", encoding="utf-8") as handle: for row in rows: handle.write(json.dumps(row, ensure_ascii=False) + "\n") manifest = { "format_version": 1, "generator": "build_memory_policy_hardset.py", "seed": args.seed, "train_groups": args.train_groups, "eval_groups": args.eval_groups, "train_examples": len(train_rows), "eval_examples": len(eval_rows), "labels": { "write_positive": sum(row["write_label"] >= 0.5 for row in train_rows), "forget_positive": sum(row["forget_label"] >= 0.5 for row in train_rows), }, "warning": "Synthetic hard negatives; combine with redacted real conversations before production deployment.", } (output_dir / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" ) print(json.dumps(manifest, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()