{"record":{"id":"e1398d8a1c5317c3","repo":"nexu-io/open-design","slug":"participant-speaker-appears-on-both-participant","errorCode":null,"errorMessage":"Participant {speaker} appears on both {participant['side']} and {side}; use one side per participant","messagePattern":"Participant (.+?) appears on both (.+?) and (.+?); use one side per participant","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/chat-motion-overlay/scripts/build_chat_overlay_spec.py","lineNumber":198,"sourceCode":"            )\n            if avatar_key not in PRESET_KEYS:\n                raise ValueError(f\"Unsupported avatar key for participant {speaker}: {avatar_key}\")\n            participant = {\n                \"id\": unique_slug(slugify(speaker), used_participant_ids),\n                \"name\": speaker,\n                \"side\": side,\n                \"avatarKey\": avatar_key,\n            }\n            used_participant_ids.add(participant[\"id\"])\n            if config[\"avatarMode\"] in {\"upload\", \"mixed\"} and configured.get(\"uploadPath\"):\n                participant[\"uploadPath\"] = configured[\"uploadPath\"]\n            if config[\"avatarMode\"] == \"upload\" and not participant.get(\"uploadPath\"):\n                raise ValueError(f\"avatarMode=upload requires uploadPath for participant {speaker}\")\n            participants[speaker] = participant\n        participant = participants[speaker]\n        side = message[\"side\"] or participant[\"side\"]\n        if side != participant[\"side\"]:\n            raise ValueError(f\"Participant {speaker} appears on both {participant['side']} and {side}; use one side per participant\")\n        avatar_key = participant[\"avatarKey\"]\n        if message[\"avatar\"] and message[\"avatar\"] != participant[\"avatarKey\"] and not configured_participant(speaker, config).get(\"preset\"):\n            raise ValueError(f\"Participant {speaker} has conflicting transcript avatar hints: {participant['avatarKey']} and {message['avatar']}; set a config preset to override\")\n        messages.append(\n            {\n                \"id\": f\"msg-{index + 1}\",\n                \"speaker\": speaker,\n                \"text\": message[\"text\"].strip(),\n                \"side\": side,\n                \"avatarKey\": avatar_key,\n                \"appearAt\": start + index * gap,\n                \"highlight\": bool(message[\"highlight\"]),\n            }\n        )\n    if config[\"avatarMode\"] == \"mixed\" and not any(participant.get(\"uploadPath\") for participant in participants.values()):\n        raise ValueError(\"avatarMode=mixed requires at least one upload path\")\n    duration = start + max(len(messages) - 1, 0) * gap + int(meta[\"hold\"])\n    runtime_output = DELIVERY_TO_OUTPUT[config[\"deliveryFormat\"]]","sourceCodeStart":180,"sourceCodeEnd":216,"githubUrl":"https://github.com/nexu-io/open-design/blob/5be4028344c2eb4c667c5a97bda8f750c5597ef7/skills/chat-motion-overlay/scripts/build_chat_overlay_spec.py#L180-L216","documentation":"Thrown in build_spec() when a speaker's message declares a side that differs from the side already stored on that participant. The overlay requires each participant to stay on one side (left or right) for the whole conversation; flipping sides mid-conversation is treated as a data error.","triggerScenarios":"The same speaker appears in two transcript lines with conflicting inline side hints (e.g. `老婆|right|...` then `老婆|left|...`), or a transcript line side hint disagrees with the side set in config.participants for that speaker. The run_test_matrix.py case 'invalid_participant_side_conflict' reproduces this.","commonSituations":"Hand-editing a transcript and getting the side token wrong on one line; copying a message block from another conversation with opposite left/right convention; a config participant side that contradicts the transcript.","solutions":["Edit the transcript so every line for that speaker uses the same side.","If the config participant side is the intended one, remove the inline side token from the conflicting transcript lines.","For genuinely different speakers that happen to share a name, rename one so each maps to a distinct participant."],"exampleFix":"// before (transcript)\n老婆|right|第一句\n老婆|left|第二句\n// after\n老婆|right|第一句\n老婆|right|第二句","handlingStrategy":"validation","validationCode":"SIDE_TOKENS = {\"left\",\"right\"}\ndef check_side_consistency(transcript_path, config):\n    seen = {name: p.get(\"side\") for name, p in config.get(\"participants\", {}).items() if p.get(\"side\")}\n    for raw in transcript_path.read_text(encoding=\"utf-8\").splitlines():\n        parts = [p.strip() for p in raw.split(\"|\")] if \"|\" in raw else []\n        if len(parts) < 3 or parts[1] not in SIDE_TOKENS:\n            continue\n        speaker, side = parts[0], parts[1]\n        if speaker in seen and seen[speaker] != side:\n            raise ValueError(f\"{speaker} appears on both {seen[speaker]} and {side}\")\n        seen[speaker] = side","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Decide each speaker's side once and reuse it for every line.","Where possible, set side in config.participants and omit it from transcript lines."],"tags":["transcript-parsing","chat-motion-overlay","participant-config"],"backgroundTag":null,"analyzedSha":"5be4028344c2eb4c667c5a97bda8f750c5597ef7","analyzedAt":"2026-08-12T12:03:58.812Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}