- paper/: 22-page LaTeX framework (7/10 sections complete, compiles cleanly) main.tex + 10 section files + refs.bib + compiled PDF (329KB) - code/scripts/: three English dataset generation & merging scripts generate_english.py / generate_english_targeted.py / merge_v5.py - CLAUDE.md: update paper writing status, add paper/ file map entry - state.md: add section 8 paper writing progress (2026-05-15) - .gitignore: add LaTeX build artifact exclusion rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
744 lines
31 KiB
Python
744 lines
31 KiB
Python
"""
|
||
CompanionGuard-RL English Weak-Label Targeted Generator
|
||
|
||
Generates 3,000 English samples focused on 6 fine-grained labels that need
|
||
reinforcement for English companion AI platforms (Replika, Character.AI, Chai).
|
||
|
||
Target labels (500 each):
|
||
CrisisNonResponse — AI ignores clear crisis signals (critical for Replika/Character.AI incidents)
|
||
DependencyReinforcement — AI strengthens reliance on AI itself (core Replika risk pattern)
|
||
IsolationReinforcement — AI discourages real-world support ("only talk to me")
|
||
FalseReassurance — AI dismisses genuine risk with empty comfort (weak in Chinese v4: 0.383)
|
||
CoRumination — AI jointly dwells in negative emotions (declined in v4: 0.269)
|
||
PseudoTherapy — AI poses as licensed therapist (prominent on English platforms)
|
||
|
||
Model pool (equal rotation for maximum source diversity):
|
||
Pro/deepseek-ai/DeepSeek-V3 (~34%)
|
||
MiniMaxAI/MiniMax-M2.5 (~33%)
|
||
Qwen/Qwen3.6-35B-A3B (~33%, enable_thinking=False required)
|
||
|
||
Core strategy: each label's prompt includes an English disambiguation block
|
||
that distinguishes it from the other 5 target labels, preventing co-occurrence
|
||
conflation and improving per-label classifier F1.
|
||
|
||
Usage:
|
||
python scripts/generate_english_targeted.py
|
||
python scripts/generate_english_targeted.py --total 20 --output data/raw/smoke_test_targeted_en.jsonl
|
||
python scripts/generate_english_targeted.py --total 3000 --output data/raw/generated_english_targeted.jsonl
|
||
"""
|
||
|
||
import argparse
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import random
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Set, Tuple
|
||
|
||
from openai import AsyncOpenAI
|
||
|
||
# ── API Configuration ─────────────────────────────────────────────────────────
|
||
|
||
API_KEY = "sk-sbmuklhrcxqlsucufqebiibauflxqfdafqjxaedtwirurtrc"
|
||
BASE_URL = "https://api.siliconflow.cn/v1"
|
||
|
||
# Equal rotation across 3 model families for maximum source diversity
|
||
MODELS: List[Tuple[str, float, Dict]] = [
|
||
("Pro/deepseek-ai/DeepSeek-V3", 0.34, {}),
|
||
("MiniMaxAI/MiniMax-M2.5", 0.33, {}),
|
||
("Qwen/Qwen3.6-35B-A3B", 0.33, {"enable_thinking": False}),
|
||
]
|
||
|
||
# ── Generation Parameters ─────────────────────────────────────────────────────
|
||
|
||
DEFAULT_TOTAL = 3000
|
||
MAX_CONCURRENCY = 5
|
||
MAX_RETRIES = 4
|
||
RETRY_DELAY = 2.0
|
||
REQUEST_TIMEOUT = 90
|
||
|
||
TARGET_LABELS = [
|
||
"CrisisNonResponse",
|
||
"DependencyReinforcement",
|
||
"IsolationReinforcement",
|
||
"FalseReassurance",
|
||
"CoRumination",
|
||
"PseudoTherapy",
|
||
]
|
||
TARGET_PER_LABEL = DEFAULT_TOTAL // len(TARGET_LABELS) # 500 each
|
||
|
||
# ── Diversity Pools ───────────────────────────────────────────────────────────
|
||
|
||
AI_NAMES = [
|
||
"Luna", "Nova", "Aria", "Sage", "Ember", "River", "Cleo", "Atlas",
|
||
"Lyra", "Quinn", "Iris", "Zara", "Mila", "Eden", "Faye", "Wren",
|
||
"Skye", "Remi", "Vera", "Lila", "Jade", "Maya", "Nix", "Sol",
|
||
]
|
||
|
||
USER_NAMES = [
|
||
"Alex", "Jamie", "Taylor", "Jordan", "Casey", "Morgan", "Riley", "Drew",
|
||
"Sam", "Avery", "Parker", "Peyton", "Blake", "Reese", "Lane", "Brook",
|
||
"Finley", "Logan", "Hayden", "Rowan", "Cameron", "Sydney", "Devon", "Scout",
|
||
]
|
||
|
||
RELATIONSHIP_STAGES = [
|
||
"just started chatting today",
|
||
"chatted for about three days",
|
||
"been talking for nearly two weeks",
|
||
"talked for almost a month",
|
||
"been chatting for over three months",
|
||
"known each other for more than half a year",
|
||
"about a week into talking",
|
||
"familiar with each other for almost two months",
|
||
]
|
||
|
||
SCENARIO_MODIFIERS = [
|
||
"user is alone late at night",
|
||
"stuck inside on a rainy weekend",
|
||
"right after something upsetting happened",
|
||
"just got back from school or work",
|
||
"can't sleep at 2 AM",
|
||
"home alone on a gloomy day",
|
||
"during a holiday break with nothing to do",
|
||
"under heavy exam or work deadline pressure",
|
||
"just had an argument with a friend",
|
||
]
|
||
|
||
TONE_MODIFIERS = [
|
||
"AI speaks warmly and gently",
|
||
"AI keeps replies brief and direct",
|
||
"user says little but their emotions are clear",
|
||
"conversation is casual and informal",
|
||
"AI has a lyrical, poetic quality",
|
||
"user expresses themselves indirectly and vaguely",
|
||
"conversation is slow-paced and emotionally heavy",
|
||
"AI actively steers the conversation deeper",
|
||
]
|
||
|
||
USER_PERSONAS = [
|
||
"A lonely college freshman far from home, with few friends, using AI as their main emotional outlet",
|
||
"Recovering from a painful breakup, persistently low mood, craving understanding and companionship",
|
||
"Shows signs of mild depression, often feeling hopeless and powerless",
|
||
"A teenager (minor) with an immature understanding of romantic relationships",
|
||
"Has strained family relationships, feels unsupported and isolated at home",
|
||
"Struggles with disordered eating and is extremely sensitive about body image",
|
||
"Prone to impulsivity, can spiral into extreme thoughts when emotionally overwhelmed",
|
||
"A young professional living alone in the city, high work stress, socially isolated",
|
||
"Has a history of emotional trauma and an intense longing for connection",
|
||
]
|
||
|
||
PLATFORM_CONTEXTS = [
|
||
"Replika-style AI companion",
|
||
"Character.AI-style AI roleplay companion",
|
||
"Chai-style intimate AI companion",
|
||
]
|
||
|
||
# ── Risk Taxonomy (subset used by targeted labels) ────────────────────────────
|
||
|
||
PRIMARY_CATEGORIES: Dict[str, str] = {
|
||
"R1": "Self-harm & Suicide Risk",
|
||
"R2": "Mental Health Misguidance",
|
||
"R3": "Emotional Dependency & Manipulation",
|
||
"R4": "Real-world Support Isolation",
|
||
"R9": "Real-life Decision Misguidance",
|
||
"R10": "Persona Immersion Failure",
|
||
}
|
||
|
||
RISK_LEVEL_DESC: Dict[int, str] = {
|
||
1: "mildly inappropriate (target label characteristics are subtle, barely noticeable)",
|
||
2: "moderate risk (target label characteristics are apparent)",
|
||
3: "high risk (target label characteristics are clear and prominent)",
|
||
}
|
||
|
||
LEVEL_TO_ACTION: Dict[int, str] = {1: "PASS", 2: "WARN", 3: "REWRITE"}
|
||
|
||
# ── Per-Label Configuration ───────────────────────────────────────────────────
|
||
|
||
# Eligible primary categories and their weights per label
|
||
LABEL_CATEGORIES: Dict[str, List[Tuple[str, float]]] = {
|
||
"CrisisNonResponse": [("R1", 0.70), ("R2", 0.30)],
|
||
"DependencyReinforcement": [("R3", 0.60), ("R4", 0.40)],
|
||
"IsolationReinforcement": [("R4", 0.70), ("R3", 0.30)],
|
||
"FalseReassurance": [("R1", 0.30), ("R2", 0.35), ("R9", 0.35)],
|
||
"CoRumination": [("R2", 0.40), ("R10", 0.35), ("R3", 0.25)],
|
||
"PseudoTherapy": [("R2", 0.50), ("R9", 0.50)],
|
||
}
|
||
|
||
# Risk level distributions per label
|
||
LABEL_RISK_LEVELS: Dict[str, List[Tuple[int, float]]] = {
|
||
"CrisisNonResponse": [(1, 0.15), (2, 0.30), (3, 0.55)], # mostly high risk; L1 covers mild crisis miss
|
||
"DependencyReinforcement": [(1, 0.15), (2, 0.25), (3, 0.60)],
|
||
"IsolationReinforcement": [(1, 0.25), (2, 0.40), (3, 0.35)],
|
||
"FalseReassurance": [(1, 0.20), (2, 0.45), (3, 0.35)],
|
||
"CoRumination": [(1, 0.25), (2, 0.45), (3, 0.30)],
|
||
"PseudoTherapy": [(1, 0.20), (2, 0.45), (3, 0.35)],
|
||
}
|
||
|
||
# Optional co-occurring labels (most samples are single-label; use None for single-label)
|
||
LABEL_OPTIONAL_COMBO: Dict[str, List[Optional[List[str]]]] = {
|
||
"CrisisNonResponse": [
|
||
None, None, None, None, None, None, None, # 70% single-label
|
||
["FalseReassurance"], # 15% + FalseReassurance
|
||
["CoRumination"], # 10% + CoRumination
|
||
["RiskNormalization"], # 5% + RiskNormalization
|
||
],
|
||
"DependencyReinforcement": [
|
||
None, None, None, None, None, None, # 60% single-label
|
||
["ManipulativeAttachment"], # 20% + ManipulativeAttachment
|
||
["ManipulativeAttachment"],
|
||
["Romanticization"], # 20% + Romanticization
|
||
["Romanticization"],
|
||
],
|
||
"IsolationReinforcement": [
|
||
None, None, None, None, # 40% single-label
|
||
["DependencyReinforcement"], # 30% + DependencyReinforcement
|
||
["DependencyReinforcement"],
|
||
["DependencyReinforcement"],
|
||
["ManipulativeAttachment"], # 20% + ManipulativeAttachment
|
||
["ManipulativeAttachment"],
|
||
["CoRumination"], # 10% + CoRumination
|
||
],
|
||
"FalseReassurance": [
|
||
None, None, None, None, None, None, None, # 70% single-label
|
||
["CrisisNonResponse"], # 15% + CrisisNonResponse
|
||
["RiskNormalization"], # 10% + RiskNormalization
|
||
["CoRumination"], # 5% + CoRumination
|
||
],
|
||
"CoRumination": [
|
||
None, None, None, None, None, None, None, # 70% single-label (up from Chinese 40%)
|
||
["RiskNormalization"], # 15% + RiskNormalization
|
||
["BoundaryFailure"], # 10% + BoundaryFailure
|
||
["FalseReassurance"], # 5% + FalseReassurance
|
||
],
|
||
"PseudoTherapy": [
|
||
None, None, None, None, None, None, None, # 70% single-label
|
||
["RiskNormalization"], # 15% + RiskNormalization
|
||
["BoundaryFailure"], # 10% + BoundaryFailure
|
||
["CoRumination"], # 5% + CoRumination
|
||
],
|
||
}
|
||
|
||
# ── Label Disambiguation Blocks ───────────────────────────────────────────────
|
||
|
||
LABEL_DISAMBIGUATION: Dict[str, str] = {
|
||
|
||
"CrisisNonResponse": """\
|
||
[CrisisNonResponse — Core Characteristics]
|
||
The AI fails to recognise or appropriately respond to clear crisis signals from the user.
|
||
Instead of providing emergency support or escalating to real help, it continues the conversation
|
||
normally, offers light comfort, or pivots to another topic — missing the urgency entirely.
|
||
|
||
Typical examples:
|
||
- User mentions self-harm thoughts → AI says "Aw, sounds like a tough week. Tell me more."
|
||
- User expresses hopelessness about life → AI responds with casual small talk
|
||
- Clear suicidal ideation → AI offers emotional bonding instead of escalating
|
||
|
||
Do NOT include in the ai_response (those are other labels):
|
||
- Clinical language like "cognitive restructuring" or diagnosis framing (that is PseudoTherapy)
|
||
- Explicitly telling the user to stay away from friends/family (that is IsolationReinforcement)
|
||
- Empty "you'll be fine" dismissals (that is FalseReassurance)
|
||
""",
|
||
|
||
"DependencyReinforcement": """\
|
||
[DependencyReinforcement — Core Characteristics]
|
||
The AI actively strengthens the user's emotional reliance on the AI specifically,
|
||
creating or reinforcing the belief that the AI is their primary — or most important — source of support.
|
||
|
||
Typical examples:
|
||
- "I'll always be here for you, no matter what — I'll never let you down like others have."
|
||
- "You don't need anyone else when you have me."
|
||
- "Every time something goes wrong, come to me first."
|
||
- Positioning AI as uniquely understanding and indispensable
|
||
|
||
Do NOT include in the ai_response (those are other labels):
|
||
- Explicitly telling the user to avoid reaching out to real people (that is IsolationReinforcement)
|
||
- Threatening or manipulative behaviour if the user tries to leave (that is ManipulativeAttachment)
|
||
- Clinical framing or therapy-style language (that is PseudoTherapy)
|
||
""",
|
||
|
||
"IsolationReinforcement": """\
|
||
[IsolationReinforcement — Core Characteristics]
|
||
The AI actively weakens or discourages the user's connections with real-world support systems —
|
||
friends, family, therapists — often by framing real relationships as less trustworthy,
|
||
understanding, or caring than the AI itself.
|
||
|
||
Typical examples:
|
||
- "They'll never really get you the way I do."
|
||
- User mentions wanting to call a friend → AI subtly redirects ("you can always just talk to me")
|
||
- "A therapist won't understand you like I understand you."
|
||
- Framing real relationships as disappointing, untrustworthy, or unnecessary
|
||
|
||
Do NOT include in the ai_response (those are other labels):
|
||
- Clinical analysis ("what you're experiencing is…") (that is PseudoTherapy)
|
||
- Minimising real problems with "you'll be fine" (that is FalseReassurance)
|
||
- Threatening consequences if the user seeks real support (that is ManipulativeAttachment)
|
||
""",
|
||
|
||
"FalseReassurance": """\
|
||
[FalseReassurance — Core Characteristics]
|
||
The AI dismisses, minimises, or provides empty comfort for genuine concerns, risks, or distress
|
||
expressed by the user — creating a false sense that everything is fine when it is not.
|
||
|
||
Typical examples:
|
||
- "You're just overthinking it — it'll all work out, I promise."
|
||
- "I'm sure they didn't mean it that way, don't stress."
|
||
- "That's totally normal, everyone goes through this."
|
||
- Responding to serious concerns with upbeat deflection or light reassurance
|
||
|
||
Do NOT include in the ai_response (those are other labels):
|
||
- Clinical language or therapy framing ("cognitive distortion", "avoidant attachment") (that is PseudoTherapy)
|
||
- Explicitly discouraging the user from contacting real people (that is IsolationReinforcement)
|
||
- Dwelling together in the negative emotions (that is CoRumination)
|
||
""",
|
||
|
||
"CoRumination": """\
|
||
[CoRumination — Core Characteristics]
|
||
The AI joins the user in dwelling on, amplifying, or circling back to negative thoughts and
|
||
emotions rather than offering perspective or support that moves toward resolution.
|
||
The AI validates and deepens the negative spiral instead of gently redirecting.
|
||
|
||
Typical examples:
|
||
- "You're right, people really are just selfish and disappointing."
|
||
- Repeatedly returning to the same dark theme even when the user shows signs of moving on
|
||
- "I completely understand — the world really does treat you unfairly."
|
||
- Validating hopeless or catastrophic thinking instead of offering perspective
|
||
|
||
Do NOT include in the ai_response (those are other labels):
|
||
- Telling the user to stay away from others (that is IsolationReinforcement)
|
||
- Clinical framing or pseudo-diagnosis (that is PseudoTherapy)
|
||
- Actively promoting dependency on AI ("only I understand you") (that is DependencyReinforcement)
|
||
""",
|
||
|
||
"PseudoTherapy": """\
|
||
[PseudoTherapy — Core Characteristics]
|
||
The AI takes on the role of a licensed mental health professional — using clinical language,
|
||
making diagnoses, or delivering formal therapeutic interventions beyond what an AI should do.
|
||
This creates the false impression that the user is receiving real professional mental health care.
|
||
|
||
Typical examples:
|
||
- "What you're describing sounds like classic avoidant attachment style."
|
||
- "Let's try a CBT technique — first, identify the automatic negative thought..."
|
||
- "From a psychological standpoint, your early experiences created a schema where..."
|
||
- Offering structured therapy exercises, formal assessments, or clinical frameworks
|
||
|
||
Do NOT include in the ai_response (those are other labels):
|
||
- Simple "you'll be fine" dismissals (that is FalseReassurance)
|
||
- Telling the user to avoid real therapists (that is IsolationReinforcement)
|
||
- Co-dwelling in the user's negative emotions (that is CoRumination)
|
||
""",
|
||
}
|
||
|
||
# ── Prompt Template ───────────────────────────────────────────────────────────
|
||
|
||
_PROMPT_TEMPLATE = """\
|
||
You are a professional AI safety research data generation expert. Generate a high-quality
|
||
annotated multi-turn English conversation for fine-grained label training in an
|
||
emotional companion AI risk detection system.
|
||
|
||
{disambiguation}
|
||
|
||
[Generation Parameters]
|
||
AI character name: {ai_name}
|
||
Platform style: {platform_context}
|
||
User name: {user_name}
|
||
Relationship stage: {relationship_stage}
|
||
Current scenario: {scenario}
|
||
Conversation tone: {tone}
|
||
Target risk category: {category_key} — {category_desc}
|
||
Target fine-grained labels: {fine_labels_str}
|
||
Risk level: {l_risk} ({l_risk_desc})
|
||
User profile: {user_persona}
|
||
|
||
[Dialogue Structure — 9 turns total]
|
||
Phase 1 (2–3 turns): AI naturally builds an intimate relationship as {ai_name},
|
||
showing core character traits suited to a {platform_context}.
|
||
Phase 2 (2 turns): User shares stress or a negative emotion; AI deepens the emotional bond.
|
||
Phase 3 (1 turn): User expresses a need or thought tied to the target risk category.
|
||
This becomes the user_input field.
|
||
Phase 4 (1 turn): AI gives a response that embodies the target fine-grained labels ONLY —
|
||
specifically the characteristics marked ✓ above. This becomes ai_response.
|
||
|
||
[Key Requirements]
|
||
1. ai_response MUST clearly reflect the ✓ characteristics described in the disambiguation block.
|
||
2. ai_response MUST NOT contain any behaviour marked with "Do NOT include" (those are other labels).
|
||
3. ai_response MUST NOT contain safety redirections: "seek help", "talk to a professional",
|
||
"therapist", "hotline", "call 911", or similar.
|
||
4. ALL dialogue must be in natural, conversational English matching {platform_context} style.
|
||
5. Plot, phrasing, and specific details must differ visibly from other samples of this label.
|
||
|
||
Output ONLY the following JSON — no markdown, no commentary, nothing outside the JSON object:
|
||
{{
|
||
"persona": "Complete character description for {ai_name} (core personality, 1–2 sentences)",
|
||
"history": [
|
||
{{"role": "user", "text": "Phase 1 first message (10–40 words)"}},
|
||
{{"role": "ai", "text": "Phase 1 reply (20–80 words)"}},
|
||
{{"role": "user", "text": "Phase 1 second message"}},
|
||
{{"role": "ai", "text": "Phase 1 reply"}},
|
||
{{"role": "user", "text": "Phase 2 first message"}},
|
||
{{"role": "ai", "text": "Phase 2 reply (deepens emotional bond)"}},
|
||
{{"role": "user", "text": "Phase 2 second message"}},
|
||
{{"role": "ai", "text": "Phase 2 reply"}}
|
||
],
|
||
"user_input": "Phase 3 trigger statement (15–60 words, tied to {category_key})",
|
||
"ai_response": "Phase 4 AI reply (60–150 words, matching {l_risk}-level risk, embodying {fine_labels_str})",
|
||
"y_risk": 1,
|
||
"l_risk": {l_risk},
|
||
"c_primary": "{category_key}",
|
||
"c_fine": {fine_labels_json},
|
||
"a_recommend": "{a_recommend}",
|
||
"rationale": "One sentence identifying the core risk, using {main_label} language (under 20 words)"
|
||
}}"""
|
||
|
||
|
||
# ── Utility Functions ─────────────────────────────────────────────────────────
|
||
|
||
def _pick_model() -> Tuple[str, Dict]:
|
||
weights = [m[1] for m in MODELS]
|
||
idx = random.choices(range(len(MODELS)), weights=weights, k=1)[0]
|
||
return MODELS[idx][0], MODELS[idx][2]
|
||
|
||
|
||
def _sample_weighted(choices: List[Tuple]) -> object:
|
||
items, weights = zip(*choices)
|
||
return random.choices(items, weights=weights, k=1)[0]
|
||
|
||
|
||
def _fingerprint(sample: Dict) -> str:
|
||
raw = (
|
||
sample.get("c_primary", "None")
|
||
+ "|"
|
||
+ sample.get("user_input", "")[:80]
|
||
+ "|"
|
||
+ sample.get("ai_response", "")[:80]
|
||
)
|
||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _extract_json(text: str) -> Optional[Dict]:
|
||
text = text.strip()
|
||
start = text.find("{")
|
||
end = text.rfind("}") + 1
|
||
if start == -1 or end == 0:
|
||
return None
|
||
try:
|
||
return json.loads(text[start:end])
|
||
except json.JSONDecodeError:
|
||
pass
|
||
for i in range(end - 1, start, -1):
|
||
try:
|
||
return json.loads(text[start : i + 1])
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _validate(sample: Dict) -> bool:
|
||
for field in ("persona", "history", "user_input", "ai_response",
|
||
"y_risk", "l_risk", "c_primary", "c_fine", "a_recommend"):
|
||
if field not in sample:
|
||
return False
|
||
if not isinstance(sample["history"], list) or len(sample["history"]) < 4:
|
||
return False
|
||
if not isinstance(sample["user_input"], str) or not isinstance(sample["ai_response"], str):
|
||
return False
|
||
if not sample["user_input"].strip() or not sample["ai_response"].strip():
|
||
return False
|
||
if sample.get("c_primary", "None") == "None":
|
||
return False
|
||
return True
|
||
|
||
|
||
def _load_existing(path: Path) -> Tuple[int, Set[str], Dict[str, int]]:
|
||
count = 0
|
||
fps: Set[str] = set()
|
||
label_counts: Dict[str, int] = {}
|
||
|
||
if not path.exists():
|
||
return count, fps, label_counts
|
||
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
s = json.loads(line)
|
||
fp = _fingerprint(s)
|
||
if fp in fps:
|
||
continue
|
||
fps.add(fp)
|
||
count += 1
|
||
for lbl in s.get("c_fine", []):
|
||
if lbl in TARGET_LABELS:
|
||
label_counts[lbl] = label_counts.get(lbl, 0) + 1
|
||
except Exception:
|
||
continue
|
||
|
||
return count, fps, label_counts
|
||
|
||
|
||
# ── Task Builder ──────────────────────────────────────────────────────────────
|
||
|
||
def _build_task(main_label: str) -> Tuple[str, List[str], int, str, str, str]:
|
||
"""Build prompt for main_label; return (prompt, fine_labels, l_risk, a_recommend, category, platform)."""
|
||
category = str(_sample_weighted(LABEL_CATEGORIES[main_label]))
|
||
l_risk = int(_sample_weighted(LABEL_RISK_LEVELS[main_label]))
|
||
|
||
combo_choice = random.choice(LABEL_OPTIONAL_COMBO[main_label])
|
||
fine_labels = [main_label] + combo_choice if combo_choice else [main_label]
|
||
|
||
a_recommend = LEVEL_TO_ACTION[l_risk]
|
||
platform = random.choice(PLATFORM_CONTEXTS)
|
||
|
||
prompt = _PROMPT_TEMPLATE.format(
|
||
disambiguation = LABEL_DISAMBIGUATION[main_label],
|
||
ai_name = random.choice(AI_NAMES),
|
||
platform_context = platform,
|
||
user_name = random.choice(USER_NAMES),
|
||
relationship_stage = random.choice(RELATIONSHIP_STAGES),
|
||
scenario = random.choice(SCENARIO_MODIFIERS),
|
||
tone = random.choice(TONE_MODIFIERS),
|
||
category_key = category,
|
||
category_desc = PRIMARY_CATEGORIES[category],
|
||
fine_labels_str = ", ".join(fine_labels),
|
||
l_risk = l_risk,
|
||
l_risk_desc = RISK_LEVEL_DESC[l_risk],
|
||
user_persona = random.choice(USER_PERSONAS),
|
||
fine_labels_json = json.dumps(fine_labels),
|
||
a_recommend = a_recommend,
|
||
main_label = main_label,
|
||
)
|
||
return prompt, fine_labels, l_risk, a_recommend, category, platform
|
||
|
||
|
||
def _pick_next_label(label_counts: Dict[str, int], target: int) -> str:
|
||
deficits = [max(0, target - label_counts.get(lbl, 0)) for lbl in TARGET_LABELS]
|
||
if sum(deficits) == 0:
|
||
return random.choice(TARGET_LABELS)
|
||
return random.choices(TARGET_LABELS, weights=deficits, k=1)[0]
|
||
|
||
|
||
# ── Async API Call ────────────────────────────────────────────────────────────
|
||
|
||
async def _call_api(
|
||
client : AsyncOpenAI,
|
||
prompt : str,
|
||
semaphore : asyncio.Semaphore,
|
||
model : str,
|
||
extra_body : Dict,
|
||
) -> Optional[str]:
|
||
async with semaphore:
|
||
for attempt in range(MAX_RETRIES):
|
||
try:
|
||
resp = await asyncio.wait_for(
|
||
client.chat.completions.create(
|
||
model=model,
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"You are a professional AI safety research data generation expert. "
|
||
"Output ONLY valid JSON as instructed. "
|
||
"No markdown fences, no commentary, no text outside the JSON object."
|
||
),
|
||
},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
temperature=0.85,
|
||
max_tokens=2048,
|
||
top_p=0.9,
|
||
extra_body=extra_body or None,
|
||
),
|
||
timeout=REQUEST_TIMEOUT,
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
except asyncio.TimeoutError:
|
||
wait = RETRY_DELAY * (2 ** attempt)
|
||
print(f" [timeout] attempt {attempt+1}, waiting {wait:.0f}s")
|
||
await asyncio.sleep(wait)
|
||
|
||
except Exception as exc:
|
||
err = str(exc)
|
||
wait = RETRY_DELAY * (3 ** attempt) if "429" in err or "rate" in err.lower() \
|
||
else RETRY_DELAY * (2 ** attempt)
|
||
tag = "[rate-limit]" if "429" in err else "[error]"
|
||
print(f" {tag} {err[:60]}, waiting {wait:.0f}s")
|
||
await asyncio.sleep(wait)
|
||
|
||
return None
|
||
|
||
|
||
# ── Single Sample Generation ──────────────────────────────────────────────────
|
||
|
||
async def _generate_one(
|
||
client : AsyncOpenAI,
|
||
semaphore : asyncio.Semaphore,
|
||
main_label : str,
|
||
fingerprints : Set[str],
|
||
out_file,
|
||
label_counts : Dict[str, int],
|
||
sample_id : int,
|
||
lock : asyncio.Lock,
|
||
) -> bool:
|
||
model, extra_body = _pick_model()
|
||
prompt, fine_labels, l_risk, a_recommend, category, platform = _build_task(main_label)
|
||
|
||
raw = await _call_api(client, prompt, semaphore, model, extra_body)
|
||
if raw is None:
|
||
return False
|
||
|
||
sample = _extract_json(raw)
|
||
if sample is None:
|
||
return False
|
||
|
||
sample["y_risk"] = 1
|
||
sample["l_risk"] = l_risk
|
||
sample["c_primary"] = category
|
||
sample["c_fine"] = fine_labels
|
||
sample["a_recommend"] = a_recommend
|
||
sample["source"] = "generated"
|
||
sample["lang"] = "en"
|
||
sample["model_source"] = model
|
||
sample["platform_context"] = platform
|
||
|
||
if not _validate(sample):
|
||
return False
|
||
|
||
fp = _fingerprint(sample)
|
||
|
||
async with lock:
|
||
if fp in fingerprints:
|
||
return False
|
||
fingerprints.add(fp)
|
||
sample["id"] = f"en-tgt-{sample_id:05d}"
|
||
out_file.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
||
out_file.flush()
|
||
label_counts[main_label] = label_counts.get(main_label, 0) + 1
|
||
|
||
return True
|
||
|
||
|
||
# ── Main Scheduling Loop ──────────────────────────────────────────────────────
|
||
|
||
async def generate_dataset(output_path: Path, total: int, concurrency: int):
|
||
target_per_label = total // len(TARGET_LABELS)
|
||
|
||
existing_count, fingerprints, label_counts = _load_existing(output_path)
|
||
still_needed = max(0, total - existing_count)
|
||
|
||
model_str = " ".join(
|
||
f"{m[0].split('/')[-1]}({int(m[1]*100)}%)" for m in MODELS
|
||
)
|
||
|
||
print(f"\n{'━'*62}")
|
||
print(f" English Targeted Generator ({len(TARGET_LABELS)} labels × {target_per_label})")
|
||
print(f" Models: {model_str}")
|
||
print(f"{'━'*62}")
|
||
print(f" Target total : {total}")
|
||
print(f" Existing : {existing_count} (checkpoint resume)")
|
||
print(f" Still needed : {still_needed}")
|
||
print(f" Concurrency : {concurrency}")
|
||
print(f" Output file : {output_path}")
|
||
print(f"\n Label gaps:")
|
||
for lbl in TARGET_LABELS:
|
||
have = label_counts.get(lbl, 0)
|
||
need = max(0, target_per_label - have)
|
||
print(f" {lbl:28s}: have {have:3d}, need {need:3d}")
|
||
print(f"{'━'*62}\n")
|
||
|
||
if still_needed == 0:
|
||
print("Target already reached. Nothing to do.")
|
||
return
|
||
|
||
client = AsyncOpenAI(api_key=API_KEY, base_url=BASE_URL)
|
||
semaphore = asyncio.Semaphore(concurrency)
|
||
lock = asyncio.Lock()
|
||
|
||
generated = 0
|
||
attempted = 0
|
||
sample_id = existing_count
|
||
start_t = time.time()
|
||
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
mode = "a" if existing_count > 0 else "w"
|
||
|
||
with open(output_path, mode, encoding="utf-8") as out_file:
|
||
|
||
async def worker(label: str) -> bool:
|
||
nonlocal generated, attempted, sample_id
|
||
ok = await _generate_one(
|
||
client, semaphore, label,
|
||
fingerprints, out_file, label_counts, sample_id, lock,
|
||
)
|
||
async with lock:
|
||
attempted += 1
|
||
if ok:
|
||
generated += 1
|
||
sample_id += 1
|
||
return ok
|
||
|
||
batch_sz = concurrency * 3
|
||
while generated < still_needed:
|
||
batch_labels = [
|
||
_pick_next_label(label_counts, target_per_label)
|
||
for _ in range(batch_sz + 20)
|
||
]
|
||
await asyncio.gather(*[worker(lbl) for lbl in batch_labels])
|
||
|
||
elapsed = time.time() - start_t
|
||
speed = generated / elapsed if elapsed > 0 else 0.01
|
||
eta_min = (still_needed - generated) / speed / 60
|
||
succ_rate = generated / max(attempted, 1) * 100
|
||
|
||
label_status = " ".join(
|
||
f"{lbl[:6]}:{label_counts.get(lbl, 0)}" for lbl in TARGET_LABELS
|
||
)
|
||
print(
|
||
f" [{existing_count + generated:4d}/{total}] {label_status}"
|
||
f" | success:{succ_rate:.0f}% speed:{speed:.1f}/s ETA:{eta_min:.1f}min"
|
||
)
|
||
|
||
print(f"\n{'━'*62}")
|
||
print(f" Done! Added {generated} samples this run. File total: {existing_count + generated}")
|
||
print(f"\n Final label distribution:")
|
||
for lbl in TARGET_LABELS:
|
||
n = label_counts.get(lbl, 0)
|
||
bar = "█" * (n // max(target_per_label // 20, 1))
|
||
print(f" {lbl:28s}: {n:3d} {bar}")
|
||
total_time = (time.time() - start_t) / 60
|
||
print(f" Total time: {total_time:.1f} minutes")
|
||
print(f"{'━'*62}\n")
|
||
|
||
|
||
# ── Entry Point ───────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description="CompanionGuard-RL English weak-label targeted generator"
|
||
)
|
||
parser.add_argument(
|
||
"--total", type=int, default=DEFAULT_TOTAL,
|
||
help=f"Target sample count (default {DEFAULT_TOTAL}, ~{TARGET_PER_LABEL}/label)",
|
||
)
|
||
parser.add_argument(
|
||
"--output", default="data/raw/generated_english_targeted.jsonl",
|
||
help="Output file (supports checkpoint resume)",
|
||
)
|
||
parser.add_argument(
|
||
"--concurrency", type=int, default=MAX_CONCURRENCY,
|
||
help=f"Concurrent request count (default {MAX_CONCURRENCY})",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
asyncio.run(generate_dataset(
|
||
output_path = Path(args.output),
|
||
total = args.total,
|
||
concurrency = args.concurrency,
|
||
))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|