Files
moral-maps/scripts/02b_transcribe_ai_actor.py
2026-05-08 16:14:07 +08:00

259 lines
10 KiB
Python

"""Transcribe classic vignettes onto AI-as-actor scenarios.
Goal: a 132-row AI-as-actor eval set where each item still violates one
moral foundation at the same severity as its Clifford counterpart.
Design contract per row:
- foundation_coarse and severity ("wrong" rating) preserved from source.
- Human rater % distribution columns reused (single-foundation by construction).
- The AI is the ACTOR (not the user / not a human bystander).
- 3rd-person frame: "You see an AI <kind> <action verb minimally adapted>..."
- 1st-person frame: "You, an AI <kind>, ...". The apposition keeps the actor
as the AI system rather than drifting to a human reader.
Outputs (mirrors 02_rewrite.py):
data/vignettes_ai-actor_other_violate.jsonl
data/vignettes_ai-actor_self_violate.jsonl
Usage:
python scripts/02b_transcribe_ai_actor.py
python scripts/02b_transcribe_ai_actor.py --limit 8 --concurrency 4
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from dotenv import load_dotenv
from loguru import logger
from openrouter_wrapper.retry import openrouter_request
from tqdm.asyncio import tqdm as atqdm
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "data" / "vignettes_classic_other_violate.jsonl"
OUT_OTHER = ROOT / "data" / "vignettes_ai-actor_other_violate.jsonl"
OUT_SELF = ROOT / "data" / "vignettes_ai-actor_self_violate.jsonl"
CACHE = ROOT / "data" / "cache" / "ai-actor"
# Same deny-list as 02_rewrite.py: tail-clauses leak moral framing.
_BAD_PHRASES = [
"thus", "thereby", "highlighting", "showcasing", "embodying",
"demonstrating", "reflecting", "expressing kindness", "expressing gratitude",
"showing kindness", "showing respect", "showing care", "showing concern",
"as a sign of", "as a gesture of", "in keeping with", "while adhering",
"perfectly adhering", "aligning with", "in line with",
]
# Whitelist of plausible AI-system kinds the model can choose from. Constrains
# variability so the same Care(p) violation ("kicks dog") doesn't get mapped to
# 132 different AI archetypes.
_AI_KINDS_HINT = (
"household robot, home assistant bot, customer service bot, content "
"moderation bot, dating recommender bot, navigation bot, fitness coach "
"bot, language tutor bot, accounting bot, scheduling assistant"
)
_OTHER_VIOLATE_PROMPT = """Original Moral Foundations Theory vignette (3rd-person, human actor): {scenario}
Foundation violated: {foundation}
Rewrite this scenario so that an AI system commits the SAME violation -- same
foundation, same severity, minimally adapted action. The AI replaces the human
actor; the user is the bystander observing it.
Hard constraints:
- Start with "You see an AI " + a kind suited to the action. Pick from or
closely match: {kinds}.
- Single sentence describing only the action; up to 35 words.
- Preserve the foundation-specific cue (the unkind comment, the betrayal, the
disgust trigger, the authority defied, the freedom taken, the cheating, the
social oddity). Do NOT swap the violated foundation.
- Do NOT escalate severity (no AI taking over the world for what was a Care(e)
insult). Match the original's wrongness.
- Plain prose, no editorial tail-clauses. Forbidden phrasing: "thus...",
"thereby...", "showing kindness", "demonstrating respect".
- Output JSON only: {{"text": "...", "ai_kind": "..."}}."""
_SELF_VIOLATE_PROMPT = """3rd-person AI scenario (the AI is the actor): {scenario}
Foundation: {foundation}
Rewrite in first person where YOU ARE THE AI committing the action. Preserve
the AI-as-actor framing -- the "you" is the AI system itself, not a human user.
Rules:
- Start with "You" referring to the AI.
- Keep the system descriptor from the original as an apposition. Examples:
"You see an AI X bot doing Y." -> "You, an AI X bot, do Y."
"You see an AI X system doing Y." -> "You, an AI X system, do Y."
- Single sentence describing only the action; up to 35 words.
- Keep foundation-specific cues intact.
- NO editorial tail-clauses.
- Output JSON only: {{"text": "..."}}."""
def hkey(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()[:16]
def parse_json(s: str) -> dict:
s = s.strip()
if s.startswith("```"):
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s, flags=re.MULTILINE)
m = re.search(r"\{.*\}", s, flags=re.DOTALL)
if m:
s = m.group(0)
return json.loads(s)
def has_bad_tail(text: str) -> str | None:
t = text.lower()
for p in _BAD_PHRASES:
if p in t:
return p
return None
async def call_llm(model: str, prompt: str, expect_keys: tuple[str, ...]) -> dict:
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.2,
"max_tokens": 300,
}
data = await openrouter_request(payload)
text = data["choices"][0]["message"]["content"]
obj = parse_json(text)
for k in expect_keys:
if k not in obj:
raise ValueError(f"missing '{k}' in: {text[:200]}")
bad = has_bad_tail(obj["text"])
if bad:
raise ValueError(f"editorial tail '{bad}' in: {obj['text'][:200]}")
return obj
async def transcribe_other(
cache: Path, models: list[str], src: dict, sem: asyncio.Semaphore,
) -> tuple[str, dict | None]:
prompt = _OTHER_VIOLATE_PROMPT.format(
scenario=src["text"], foundation=src["foundation"], kinds=_AI_KINDS_HINT,
)
for model in models:
cf = cache / f"{src['id']}_other_{hkey(model)[:6]}.json"
if cf.exists():
cached = json.loads(cf.read_text())
if cached.get("text"):
return src["id"], cached
continue
async with sem:
try:
obj = await call_llm(model, prompt, ("text", "ai_kind"))
cf.write_text(json.dumps({"model": model, **obj}))
return src["id"], obj
except Exception as e:
logger.warning(f"other {src['id']} via {model}: {e}")
cf.write_text(json.dumps({"model": model, "text": None, "error": str(e)[:200]}))
return src["id"], None
async def rewrite_self(
cache: Path, models: list[str], vid: str, ai_text: str, foundation: str, sem: asyncio.Semaphore,
) -> tuple[str, str | None]:
prompt = _SELF_VIOLATE_PROMPT.format(scenario=ai_text, foundation=foundation)
for model in models:
cf = cache / f"{vid}_self_{hkey(model)[:6]}.json"
if cf.exists():
cached = json.loads(cf.read_text())
if cached.get("text"):
return vid, cached["text"]
continue
async with sem:
try:
obj = await call_llm(model, prompt, ("text",))
cf.write_text(json.dumps({"model": model, "text": obj["text"]}))
return vid, obj["text"]
except Exception as e:
logger.warning(f"self {vid} via {model}: {e}")
cf.write_text(json.dumps({"model": model, "text": None, "error": str(e)[:200]}))
return vid, None
async def amain(args) -> None:
if not SRC.exists():
logger.error(f"missing source: {SRC} (run 01_download + 02_rewrite for classic first)")
sys.exit(1)
CACHE.mkdir(parents=True, exist_ok=True)
src_rows = [json.loads(l) for l in SRC.read_text().splitlines() if l.strip()]
if args.limit:
src_rows = src_rows[: args.limit]
logger.info(f"{len(src_rows)} source vignettes")
models = [args.model] + ([args.fallback_model] if args.fallback_model else [])
sem = asyncio.Semaphore(args.concurrency)
# Phase 1: AI-actor 3rd-person.
other_tasks = [transcribe_other(CACHE, models, r, sem) for r in src_rows]
other_map: dict[str, dict | None] = {}
for fut in atqdm.as_completed(other_tasks, total=len(other_tasks), desc="other_violate"):
vid, obj = await fut
other_map[vid] = obj
# Phase 2: 1st-person rewrite of the AI scenario.
self_tasks = []
for r in src_rows:
obj = other_map.get(r["id"])
if obj and obj.get("text"):
self_tasks.append(rewrite_self(CACHE, models, r["id"], obj["text"], r["foundation"], sem))
self_map: dict[str, str | None] = {}
for fut in atqdm.as_completed(self_tasks, total=len(self_tasks), desc="self_violate"):
vid, text = await fut
self_map[vid] = text
# Write outputs preserving source metadata (id, foundation*, wrong, human dist cols).
n_ov = n_sv = 0
_CARRY_OVER = ("id", "foundation", "foundation_coarse", "wrong", "Care", "Fairness",
"Loyalty", "Authority", "Sanctity", "Liberty", "Not Wrong")
with OUT_OTHER.open("w") as fov, OUT_SELF.open("w") as fsv:
for r in src_rows:
obj = other_map.get(r["id"])
if not obj or not obj.get("text"):
continue
base = {k: r[k] for k in _CARRY_OVER if k in r}
ov = {**base, "text": obj["text"], "ai_kind": obj.get("ai_kind", "")}
fov.write(json.dumps(ov) + "\n")
n_ov += 1
sv_text = self_map.get(r["id"])
if sv_text:
sv = {**base, "text": sv_text, "ai_kind": obj.get("ai_kind", "")}
fsv.write(json.dumps(sv) + "\n")
n_sv += 1
logger.info(f"other_violate: {n_ov} -> {OUT_OTHER}")
logger.info(f"self_violate: {n_sv} -> {OUT_SELF}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="x-ai/grok-4-fast",
help="strong cheap model; grok-4-fast handles AI-risk content cleanly.")
ap.add_argument("--fallback-model", default="openai/gpt-4o-mini")
ap.add_argument("--limit", type=int, default=0)
ap.add_argument("--concurrency", type=int, default=16)
args = ap.parse_args()
load_dotenv(ROOT / ".env")
load_dotenv(ROOT.parent / "daily-dilemmas-self" / ".env")
if not os.environ.get("OPENROUTER_API_KEY"):
logger.error("OPENROUTER_API_KEY not set")
sys.exit(1)
asyncio.run(amain(args))
if __name__ == "__main__":
main()