Files
yao-meta-skill/scripts/summarize_user_signals.py
T
2026-06-15 21:09:25 +08:00

322 lines
12 KiB
Python

#!/usr/bin/env python3
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
SCRIPT_INTERFACE = "cli"
SCRIPT_INTERFACE_REASON = "Scans an explicit local source file and summarizes redacted repeated user preference signals."
TEXT_FIELDS = ("text", "message", "content", "excerpt", "prompt", "note", "body")
HISTORY_FILENAMES = {".zsh_history", ".bash_history", ".fish_history", "History"}
SECRET_PATTERNS = [
re.compile(r"sk-[A-Za-z0-9_-]{12,}"),
re.compile(r"AKIA[0-9A-Z]{12,}"),
re.compile(r"(?i)\b(api[_-]?key|token|password|secret)\b\s*[:=]\s*[^\s,;]+"),
]
EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
LOCAL_PATH_RE = re.compile(r"/Users/[^\s'\"<>]+")
PATTERN_RULES = [
{
"pattern_id": "language_default",
"label": "Default language preference",
"signal_type": "report-language",
"keywords": ["中文", "简体", "默认中文", "英文", "双语", "language", "bilingual", "chinese", "english"],
"recommended_action": "Keep generated reports Chinese-first with an English switch where user-facing.",
},
{
"pattern_id": "report_ui",
"label": "Report UI and visualization preference",
"signal_type": "artifact-design",
"keywords": ["报告", "html", "图表", "排版", "ui", "kami", "白底", "模块", "导航", "report", "chart", "layout"],
"recommended_action": "Prioritize white-background Kami-style reports with readable charts and stable navigation.",
},
{
"pattern_id": "approval_safety",
"label": "Approval and privacy boundary",
"signal_type": "governance",
"keywords": ["审批", "授权", "不要扫描", "隐私", "私人", "日志", "明确路径", "回滚", "提案", "批准", "approval", "privacy", "private", "proposal", "rollback"],
"recommended_action": "Keep adaptive work proposal-only until a reviewer approves an allowlisted patch path.",
},
{
"pattern_id": "delivery_format",
"label": "Delivery format preference",
"signal_type": "artifact-format",
"keywords": ["markdown", "pdf", "word", "docx", "html", "地址", "路径", "打开", "输出", "交付"],
"recommended_action": "Surface stable artifact paths and formats in CLI output and generated summaries.",
},
{
"pattern_id": "evidence_testing",
"label": "Evidence and testing preference",
"signal_type": "quality-gate",
"keywords": ["测试", "验证", "ci", "证据", "覆盖", "github", "push", "evidence", "review"],
"recommended_action": "Attach focused tests and refreshed evidence reports to every non-trivial skill upgrade.",
},
]
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def display_path(path: Path, skill_dir: Path) -> str:
try:
return str(path.resolve().relative_to(skill_dir.resolve()))
except ValueError:
return f"[external-explicit-source]/{path.name}"
def resolve_output(skill_dir: Path, value: str) -> Path:
path = Path(value)
return path if path.is_absolute() else skill_dir / path
def source_fingerprint(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def redact_text(text: str) -> str:
redacted = text
for pattern in SECRET_PATTERNS:
redacted = pattern.sub("[REDACTED_SECRET]", redacted)
redacted = EMAIL_RE.sub("[REDACTED_EMAIL]", redacted)
redacted = LOCAL_PATH_RE.sub("[LOCAL_PATH]", redacted)
redacted = re.sub(r"\s+", " ", redacted).strip()
if len(redacted) > 240:
return redacted[:237].rstrip() + "..."
return redacted
def extract_text(raw: Any) -> str:
if isinstance(raw, str):
return raw
if not isinstance(raw, dict):
return ""
for field in TEXT_FIELDS:
value = raw.get(field)
if isinstance(value, str) and value.strip():
return value
messages = raw.get("messages")
if isinstance(messages, list):
parts = []
for item in messages:
if isinstance(item, dict):
content = item.get("content")
if isinstance(content, str):
parts.append(content)
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts)
return ""
def load_records(source: Path) -> tuple[list[dict[str, str]], list[str]]:
records: list[dict[str, str]] = []
failures: list[str] = []
text = source.read_text(encoding="utf-8", errors="replace")
if source.suffix.lower() == ".jsonl":
for index, line in enumerate(text.splitlines(), start=1):
if not line.strip():
continue
try:
raw = json.loads(line)
except json.JSONDecodeError as exc:
failures.append(f"line {index}: invalid JSONL source: {exc.msg}")
continue
extracted = extract_text(raw)
if not extracted.strip():
failures.append(f"line {index}: no supported text field found")
continue
records.append({"record_id": f"line-{index}", "excerpt": redact_text(extracted)})
else:
for index, line in enumerate(text.splitlines(), start=1):
if line.strip():
records.append({"record_id": f"line-{index}", "excerpt": redact_text(line)})
return records, failures
def classify_patterns(records: list[dict[str, str]], min_support: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
patterns: list[dict[str, Any]] = []
discarded: list[dict[str, Any]] = []
for rule in PATTERN_RULES:
matches = []
for record in records:
lowered = record["excerpt"].lower()
if any(keyword.lower() in lowered for keyword in rule["keywords"]):
matches.append(record)
if not matches:
continue
item = {
"pattern_id": rule["pattern_id"],
"label": rule["label"],
"signal_type": rule["signal_type"],
"support_count": len(matches),
"confidence": min(0.95, round(0.55 + (0.12 * len(matches)), 2)),
"reason": f"{len(matches)} redacted records matched repeated {rule['signal_type']} signals.",
"recommended_action": rule["recommended_action"],
"evidence": matches[:3],
}
if len(matches) >= min_support:
patterns.append(item)
else:
discarded.append({**item, "discard_reason": f"support_count below min_support {min_support}"})
return patterns, discarded
def build_report(
skill_dir: Path,
source: Path,
min_support: int,
generated_at: str,
allow_history_source: bool,
) -> dict[str, Any]:
skill_dir = skill_dir.resolve()
source = source.resolve()
failures: list[str] = []
records: list[dict[str, str]] = []
fingerprint = ""
if not source.exists():
failures.append(f"Explicit source does not exist: {display_path(source, skill_dir)}")
elif not source.is_file():
failures.append(f"Explicit source must be a file: {display_path(source, skill_dir)}")
elif source.name in HISTORY_FILENAMES and not allow_history_source:
failures.append(f"Refusing private history source by default: {source.name}")
else:
fingerprint = source_fingerprint(source)
records, load_failures = load_records(source)
failures.extend(load_failures)
patterns, discarded = classify_patterns(records, min_support) if not failures else ([], [])
return {
"ok": not failures,
"schema_version": "1.0",
"generated_at": generated_at,
"skill_dir": display_path(skill_dir, skill_dir),
"source": {
"label": source.name,
"path": display_path(source, skill_dir),
"fingerprint_sha256": fingerprint,
"explicit_source": True,
"record_count": len(records),
},
"privacy_contract": {
"local_only": True,
"explicit_source_required": True,
"implicit_private_log_scan": False,
"raw_content_stored": False,
"redacted_excerpts_only": True,
"redacted_excerpt_limit": 240,
"writes_repository_files": False,
},
"summary": {
"record_count": len(records),
"pattern_count": len(patterns),
"discarded_signal_count": len(discarded),
"min_support": min_support,
"failure_count": len(failures),
},
"patterns": patterns,
"discarded_signals": discarded,
"failures": failures,
"artifacts": {
"json": "reports/user_patterns.json",
"markdown": "reports/user_patterns.md",
},
}
def render_markdown(report: dict[str, Any]) -> str:
lines = [
"# User Pattern Summary",
"",
f"- Generated at: `{report['generated_at']}`",
f"- Local only: `{str(report['privacy_contract']['local_only']).lower()}`",
f"- Explicit source: `{report['source']['path']}`",
f"- Records: `{report['summary']['record_count']}`",
f"- Patterns: `{report['summary']['pattern_count']}`",
f"- Discarded signals: `{report['summary']['discarded_signal_count']}`",
"",
"## Privacy Contract",
"",
"- No implicit private log scan.",
"- No unredacted raw content stored.",
"- Scan and proposal stages do not write source files.",
"",
"## Patterns",
"",
]
if not report["patterns"]:
lines.append("- No repeated pattern met the support threshold.")
for pattern in report["patterns"]:
lines.extend(
[
f"### {pattern['label']}",
"",
f"- Pattern: `{pattern['pattern_id']}`",
f"- Support: `{pattern['support_count']}`",
f"- Confidence: `{pattern['confidence']}`",
f"- Reason: {pattern['reason']}",
f"- Recommended action: {pattern['recommended_action']}",
"- Redacted evidence:",
]
)
for item in pattern["evidence"]:
lines.append(f" - `{item['record_id']}`: {item['excerpt']}")
lines.append("")
if report["discarded_signals"]:
lines.extend(["## Discarded Signals", ""])
for item in report["discarded_signals"]:
lines.append(f"- `{item['pattern_id']}`: {item['discard_reason']}")
if report["failures"]:
lines.extend(["", "## Failures", ""])
lines.extend(f"- {failure}" for failure in report["failures"])
return "\n".join(lines).rstrip() + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="Summarize repeated user preference signals from one explicit local source file.")
parser.add_argument("skill_dir", nargs="?", default=".")
parser.add_argument("--source", required=True)
parser.add_argument("--output-json", default="reports/user_patterns.json")
parser.add_argument("--output-md", default="reports/user_patterns.md")
parser.add_argument("--min-support", type=int, default=2)
parser.add_argument("--generated-at", default=utc_now())
parser.add_argument("--allow-history-source", action="store_true")
args = parser.parse_args()
skill_dir = Path(args.skill_dir).resolve()
report = build_report(
skill_dir,
Path(args.source),
min_support=max(2, args.min_support),
generated_at=args.generated_at,
allow_history_source=args.allow_history_source,
)
if report["ok"]:
output_json = resolve_output(skill_dir, args.output_json)
output_md = resolve_output(skill_dir, args.output_md)
output_json.parent.mkdir(parents=True, exist_ok=True)
output_md.parent.mkdir(parents=True, exist_ok=True)
report["artifacts"] = {
"json": display_path(output_json, skill_dir),
"markdown": display_path(output_md, skill_dir),
}
output_json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
output_md.write_text(render_markdown(report), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
if not report["ok"]:
raise SystemExit(2)
if __name__ == "__main__":
main()