check-agent-originality.sh 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. #!/usr/bin/env bash
  2. #
  3. # check-agent-originality.sh — Flag agent files that substantially duplicate
  4. # an existing agent (or another agent in the same change set).
  5. #
  6. # Why: a new agent should be genuinely new. Find-replace "re-skins" of an
  7. # existing agent (e.g. swapping a country/platform name) are easy to miss in
  8. # review because they're mergeable and well-formed — but they bloat the
  9. # library with duplicates. This compares each candidate against the whole
  10. # existing roster using entity-neutralized 8-word shingle overlap, so a
  11. # swapped proper noun can't hide the copy.
  12. #
  13. # Usage:
  14. # ./scripts/check-agent-originality.sh [file ...]
  15. # With files: checks those agent .md files (used by CI on changed files).
  16. # With no args: checks every agent in the repo against every other (audit).
  17. #
  18. # Exit status:
  19. # 0 all candidates below the FAIL threshold
  20. # 1 at least one candidate at/above FAIL threshold (likely duplicate)
  21. #
  22. # Tunables (env):
  23. # ORIGINALITY_FAIL default 40 — at/above this %, treated as a duplicate (exit 1)
  24. # ORIGINALITY_WARN default 20 — at/above this %, surfaced as a warning (no fail)
  25. #
  26. # Calibration: across the existing agent library the worst same-pair
  27. # similarity is ~1.5% (median 0%). Anything in the double digits is a strong
  28. # anomaly; the defaults leave a wide safety margin against false positives.
  29. set -euo pipefail
  30. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  31. REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
  32. command -v python3 >/dev/null 2>&1 || {
  33. echo "ERROR: python3 is required for the originality check." >&2
  34. exit 2
  35. }
  36. ORIGINALITY_FAIL="${ORIGINALITY_FAIL:-40}" \
  37. ORIGINALITY_WARN="${ORIGINALITY_WARN:-20}" \
  38. REPO_ROOT="$REPO_ROOT" \
  39. python3 - "$@" <<'PYEOF'
  40. import os, re, sys, glob, json
  41. REPO_ROOT = os.environ["REPO_ROOT"]
  42. FAIL = float(os.environ["ORIGINALITY_FAIL"])
  43. WARN = float(os.environ["ORIGINALITY_WARN"])
  44. # Division set — divisions.json (repo root) is the single source of truth, and
  45. # scripts/check-divisions.sh (CI) enforces it against the directories on disk.
  46. # Read it directly rather than hardcoding the list here so this check can never
  47. # drift out of sync with the catalog the way a copied literal silently would.
  48. with open(os.path.join(REPO_ROOT, "divisions.json")) as _fh:
  49. AGENT_DIRS = sorted(json.load(_fh)["divisions"].keys())
  50. # Proper nouns we neutralize so a find-replace re-skin (swap the country/platform
  51. # and little else) still scores as a near-duplicate. Extend as new markets appear.
  52. ENTITY = re.compile(
  53. r'\b(vietnam|vietnamese|china|chinese|douyin|tiktok|korea|korean|japan|japanese|'
  54. r'india|indian|indonesia|indonesian|thailand|thai|philippines|filipino|brazil|'
  55. r'brazilian|mexico|mexican|wechat|weixin|weibo|xiaohongshu|rednote|kuaishou|'
  56. r'bilibili|zhihu|baidu|shopee|lazada|zalo|tokopedia|taobao|tmall|pinduoduo|'
  57. r'instagram|facebook|youtube|reels|shorts|linkedin|twitter|threads|snapchat)\b')
  58. def strip_frontmatter(t):
  59. if t.startswith('---'):
  60. parts = t.split('---', 2)
  61. if len(parts) >= 3:
  62. return parts[2]
  63. return t
  64. def tokens(text):
  65. text = ENTITY.sub(' ', strip_frontmatter(text).lower())
  66. text = re.sub(r'[^a-z0-9 ]', ' ', text)
  67. return text.split()
  68. def shingles(words, k=8):
  69. return set(' '.join(words[i:i+k]) for i in range(max(0, len(words) - k + 1)))
  70. def jaccard(a, b):
  71. return len(a & b) / len(a | b) if a and b else 0.0
  72. def is_agent(path):
  73. try:
  74. with open(path) as fh:
  75. return fh.readline().strip() == '---'
  76. except OSError:
  77. return False
  78. def rel(p):
  79. try:
  80. return os.path.relpath(p, REPO_ROOT)
  81. except ValueError:
  82. return p
  83. # --- Build the existing-library corpus -------------------------------------
  84. corpus = {}
  85. for d in AGENT_DIRS:
  86. for f in glob.glob(os.path.join(REPO_ROOT, d, '**', '*.md'), recursive=True):
  87. if is_agent(f):
  88. corpus[os.path.abspath(f)] = shingles(tokens(open(f).read()))
  89. # --- Determine candidates ---------------------------------------------------
  90. args = sys.argv[1:]
  91. if args:
  92. candidates = []
  93. for a in args:
  94. p = a if os.path.isabs(a) else os.path.join(os.getcwd(), a)
  95. p = os.path.abspath(p)
  96. if not os.path.isfile(p):
  97. print(f" skip (not found): {a}")
  98. continue
  99. if not is_agent(p):
  100. print(f" skip (no frontmatter, not an agent): {rel(p)}")
  101. continue
  102. candidates.append(p)
  103. else:
  104. candidates = list(corpus.keys()) # audit mode: everything vs everything
  105. if not candidates:
  106. print("No agent files to check.")
  107. sys.exit(0)
  108. cand_sh = {p: corpus.get(p) or shingles(tokens(open(p).read())) for p in candidates}
  109. cand_set = set(candidates)
  110. worst = 0.0
  111. fails, warns = [], []
  112. for p in candidates:
  113. sh = cand_sh[p]
  114. best_name, best_score = "", 0.0
  115. # vs existing library (exclude the candidate itself by path)
  116. for cf, csh in corpus.items():
  117. if cf == p:
  118. continue
  119. s = jaccard(sh, csh)
  120. if s > best_score:
  121. best_name, best_score = rel(cf), s
  122. # vs other candidates in this same change set
  123. for op in candidates:
  124. if op == p:
  125. continue
  126. s = jaccard(sh, cand_sh[op])
  127. if s > best_score:
  128. best_name, best_score = rel(op) + " (same change set)", s
  129. pct = best_score * 100
  130. worst = max(worst, pct)
  131. tag = "OK "
  132. if pct >= FAIL:
  133. tag = "FAIL "; fails.append((rel(p), best_name, pct))
  134. elif pct >= WARN:
  135. tag = "WARN "; warns.append((rel(p), best_name, pct))
  136. print(f" [{tag}] {pct:5.1f}% {rel(p)}")
  137. if best_name:
  138. print(f" closest: {best_name}")
  139. print()
  140. print(f"Thresholds: WARN >= {WARN:.0f}%, FAIL >= {FAIL:.0f}% "
  141. f"(existing-library baseline max ~1.5%)")
  142. if fails:
  143. print()
  144. print(f"FAILED: {len(fails)} agent(s) substantially duplicate existing content:")
  145. for name, match, pct in fails:
  146. print(f" - {name} ~{pct:.0f}% like {match}")
  147. print()
  148. print("A new agent should be genuinely new. If this is intended market/platform")
  149. print("localization, make the body distinct (different platforms, tactics, examples)")
  150. print("rather than a find-replace of an existing agent.")
  151. sys.exit(1)
  152. if warns:
  153. print(f"\n{len(warns)} warning(s) — review for overlap, but not blocking.")
  154. print("\nPASSED")
  155. sys.exit(0)
  156. PYEOF