feat(market-research): add history.py non-repeat memory with offline test
This commit is contained in:
parent
5beb04b43d
commit
ae840beaa3
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Non-repeat memory for market-research suggestions.
|
||||
|
||||
history.json holds every candidate ever suggested so a run can avoid repeating
|
||||
itself. Identity key = package if present, else the lowercased/stripped name.
|
||||
|
||||
Subcommands (candidates read as a JSON array on stdin):
|
||||
filter --history H.json -> print the candidates minus already-seen ones
|
||||
add --history H.json -> append candidates to H.json, print {added,total}
|
||||
|
||||
Date/run_id for added entries come from --date / --run-id (the skill passes the
|
||||
run date); both default to empty strings so the script is deterministic and
|
||||
needs no clock. Stdlib-only, no pip.
|
||||
"""
|
||||
import sys, json, argparse, os
|
||||
|
||||
def cand_key(entry):
|
||||
"""Canonical identity for a candidate. Package wins; else lowercased name."""
|
||||
pkg = entry.get("package")
|
||||
if pkg:
|
||||
return str(pkg).strip()
|
||||
return str(entry.get("name", "")).strip().lower()
|
||||
|
||||
def load_history(path):
|
||||
if not os.path.exists(path):
|
||||
return {"suggestions": []}
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("suggestions", [])
|
||||
return data
|
||||
|
||||
def seen_keys(history):
|
||||
return {s.get("key") for s in history["suggestions"]}
|
||||
|
||||
def read_candidates():
|
||||
data = json.load(sys.stdin)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("candidates stdin must be a JSON array")
|
||||
return data
|
||||
|
||||
def cmd_filter(args):
|
||||
history = load_history(args.history)
|
||||
seen = seen_keys(history)
|
||||
cands = read_candidates()
|
||||
survivors = [c for c in cands if cand_key(c) not in seen]
|
||||
print(json.dumps(survivors, indent=2))
|
||||
|
||||
def cmd_add(args):
|
||||
history = load_history(args.history)
|
||||
seen = seen_keys(history)
|
||||
cands = read_candidates()
|
||||
added = 0
|
||||
for c in cands:
|
||||
k = cand_key(c)
|
||||
if k in seen:
|
||||
continue
|
||||
history["suggestions"].append({
|
||||
"key": k,
|
||||
"name": c.get("name"),
|
||||
"package": c.get("package"),
|
||||
"date": args.date,
|
||||
"run_id": args.run_id,
|
||||
})
|
||||
seen.add(k)
|
||||
added += 1
|
||||
os.makedirs(os.path.dirname(os.path.abspath(args.history)), exist_ok=True)
|
||||
with open(args.history, "w", encoding="utf-8") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
print(json.dumps({"added": added, "total": len(history["suggestions"])}, indent=2))
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
for name in ("filter", "add"):
|
||||
p = sub.add_parser(name)
|
||||
p.add_argument("--history", required=True)
|
||||
p.add_argument("--date", default="")
|
||||
p.add_argument("--run-id", default="")
|
||||
args = ap.parse_args()
|
||||
{"filter": cmd_filter, "add": cmd_add}[args.cmd](args)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
[
|
||||
{ "name": "Puzzle Quest Saga", "package": "com.casual.puzzlequest", "category": "Games" },
|
||||
{ "name": "habit tracker PRO", "category": "Productivity" },
|
||||
{ "name": "Budget Buddy", "package": "com.fintech.budgetbuddy", "category": "Finance" }
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"suggestions": [
|
||||
{ "key": "com.casual.puzzlequest", "name": "Puzzle Quest Saga", "package": "com.casual.puzzlequest", "date": "2026-06-01", "run_id": "seed" },
|
||||
{ "key": "habit tracker pro", "name": "Habit Tracker Pro", "package": null, "date": "2026-06-01", "run_id": "seed" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env python3
|
||||
import json, subprocess, sys, os, tempfile, shutil
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "history.py")
|
||||
FIX = os.path.join(HERE, "fixtures")
|
||||
|
||||
def run(args, stdin_path):
|
||||
with open(stdin_path, "rb") as f:
|
||||
return subprocess.run([sys.executable, SCRIPT, *args],
|
||||
stdin=f, capture_output=True)
|
||||
|
||||
def main():
|
||||
fails = []
|
||||
def check(name, cond):
|
||||
print(f"{'PASS' if cond else 'FAIL'}: {name}")
|
||||
if not cond: fails.append(name)
|
||||
|
||||
cands = os.path.join(FIX, "candidates-sample.json")
|
||||
|
||||
# filter: against the seed, only "Budget Buddy" survives
|
||||
r = run(["filter", "--history", os.path.join(FIX, "history-seed.json")], cands)
|
||||
check("filter exit 0", r.returncode == 0)
|
||||
survivors = json.loads(r.stdout)
|
||||
names = sorted(c["name"] for c in survivors)
|
||||
check("filter drops package collision + name collision", names == ["Budget Buddy"])
|
||||
|
||||
# filter against a missing history file = nothing seen, all 3 survive
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
missing = os.path.join(tmp, "nope.json")
|
||||
r = run(["filter", "--history", missing], cands)
|
||||
check("filter missing-history exit 0", r.returncode == 0)
|
||||
check("filter missing-history keeps all", len(json.loads(r.stdout)) == 3)
|
||||
|
||||
# add: appends all 3 to a fresh history, reports counts
|
||||
h = os.path.join(tmp, "h.json")
|
||||
r = run(["add", "--history", h], cands)
|
||||
check("add exit 0", r.returncode == 0)
|
||||
rep = json.loads(r.stdout)
|
||||
check("add reports added 3", rep["added"] == 3)
|
||||
check("add reports total 3", rep["total"] == 3)
|
||||
saved = json.load(open(h))
|
||||
check("history file has 3 suggestions", len(saved["suggestions"]) == 3)
|
||||
check("history entries carry key", all("key" in s for s in saved["suggestions"]))
|
||||
finally:
|
||||
shutil.rmtree(tmp)
|
||||
|
||||
sys.exit(1 if fails else 0)
|
||||
|
||||
main()
|
||||
Loading…
Reference in New Issue