🤣

ボクとキミ(Copilot)で挑むローカルLLMのファインチューニング(準備編:キミ(Copilot)とならどこまで…も?)

に公開

キミ(Copilot)とならどこまでも行ける気がする。

前回までの振り返りは以下の記事を参照下さい。間違いがあれば優しく指摘してくれると励みになりますので、よろしくお願いします🙇
前々回はこちらです。※いちおう準備編を呈した記事です。
前回はこちら

構想編ではキミ(Copilot)とアイデアの深掘りを行いました。ちょっとした問いかけから実装手順、方法まで親切に提案を行う様はまさしく相棒のような雰囲気を漂わせておりました。出力されるメッセージも前向きな表現が多いことから、モチベーション高く実装に向け取り組む体制が整いました。

キミ(Copilot)とならどこまでも行ける?

まずはキミ(Copilot)に構想に従ってディレクトリ構成とスクリプトファイルの配置を提案してもらいました。

ディレクトリ構成とスクリプト
project_root/
├─ ollama_model/                 # neoAI-8b-chat の gguf & Modelfile
│  └─ Modelfile                  # 例: FROM ./Llama-3-neoAI-8B-Chat-v0.1.Q4_K_M.gguf
├─ data/
│  ├─ theme_pool.json            # ネタ素材セット(boke/tsukkomi/punch)
│  └─ generated_scripts/         # JSON台本の保存先
├─ prompt_templates/
│  ├─ kansai_manzai_base.txt     # 関西弁を利用した漫才出力例の基本
│  └─ kansai_manzai_xxx.txt      # テーマ別プロンプト(ここでは割愛)
├─ scripts/
│  ├─ generate_script.py         # テーマ→台本生成→保存
│  ├─ kansai_convert.py          # 関西弁変換モジュール
│  ├─ theme_updater.py           # LLMでテーマ素材生成→theme_pool登録
│  ├─ evaluate_manzai.py         # 台本を評価、主に文脈や語彙が適切か評価
│  └─ agent_runner.py            # LangChain Agent呼び出し(今後拡張)
├─ tools/
│  └─ generate_requirements.py   # requirements.txt 自動生成スクリプト
├─ requirements.txt              # 依存ライブラリ一覧
└─ README.md                     # プロジェクト説明(任意)

それっぽく作成してくれました!
それでは仮想環境を作成し開発を進めます。

仮想環境作成とパッケージインストール
python -m venv venv # 便宜上venvとさせていただいてます
source venv/bin/activate

今回利用するパッケージは以下の通りです

pytorch-triton-rocm @ file:///home/tateokohara/project-root/pytorch_triton_rocm-3.2.0%2Brocm6.4.1.git6da9e660-cp312-cp312-linux_x86_64.whl#sha256=1d97c15798bf178299328032141a21d9777e7cdef59d5a7e3ac74e297c17198e
torch @ file:///home/tateokohara/project-root/torch-2.6.0%2Brocm6.4.1.git1ded221d-cp312-cp312-linux_x86_64.whl#sha256=6b141e1a03148b007c6217519cd9947d760123ded5caebadffec22cba7358d2d
torchaudio @ file:///home/tateokohara/project-root/torchaudio-2.6.0%2Brocm6.4.1.gitd8831425-cp312-cp312-linux_x86_64.whl#sha256=d93faa440d5e9bb80146a33d85e163946c8006ed25db7e0bfcd8ea5284ea35ce
torchvision @ file:///home/tateokohara/project-root/torchvision-0.21.0%2Brocm6.4.1.git4040d51f-cp312-cp312-linux_x86_64.whl#sha256=a532d46e26341741ed9929ab3e4d763597d2bfec62ae0b9a046d18bae0f68696

こちらを仮想環境にインストールします。

pip install -r requirements.txt

次はローカル環境でLLMを利用してファインチューニング用のデータセットを作成して行きます。
前々回作成した環境で動きかつ日本語に強いモデルということでキミ(Copilot)から提案されたneoAI-8b-chatを採用しました。詳細はこちらを確認してください。

さて、ここからは以下の順序でデータセットとなるネタ台本の作成をして行きます。

  1. 関西弁の定義とプロンプト
  2. テーマ別に台本の生成
  3. 台本の評価

こちらもキミ(Copilot)に大枠となるたたき台を作成してもらい、ボクが手直しを加えます。さらにそれをキミ(Copilot)がレビューする…といった感じでスクリプトを仕上げて行く作業となりました。失敗と修正をキミ(Copilot)と一緒に作業する感じは益々相棒感が増してきたような気がしてました。この段階で以下のようにようになりました。

関西弁の定義とプロンプト

kansai_convert.py

def to_kansai(text: str) -> str:
    replacements = {
        "だよ": "やで",
        "だね": "やな",
        "じゃない": "ちゃう",
        "すごい": "めっちゃ",
        "本当?": "ほんまかいな?",
        "いいね": "ええやん",
        "わからない": "わからへん",
        "そうですか": "そうなんや",
        "思う": "思うねん",
        "私": "うち",
        "あなた": "あんた",
        "ですね": "やな",
        "ですよ": "やで"
    }
    for std, kansai in replacements.items():
        text = text.replace(std, kansai)
    return text

theme_updater.py

# scripts/theme_updater.py
import json, os
from langchain_ollama import ChatOllama
from fix_json import fix_pseudo_json_response

DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
THEME_POOL_PATH = os.path.join(DATA_DIR, "theme_pool.json")

llm = ChatOllama(model="neoai-8b-chat")

def generate_theme_content(theme="通天閣の観光あるある"):
    prompt = f"""
    あなたは関西弁で漫才を行う大阪のお笑い芸人です。
    すべての発言は自然な関西弁で書いてください。
    テーマは「{theme}」。ボケ2個、ツッコミ2個、オチ1個を生成し、以下形式で出力してください:
    {{
      "context": "通天閣の観光についての説明",
      "boke": [
        "通天閣でUFO見つけた!", 
        "上から大阪城が見えると思ったらUSJやった!"
        ],
      "tsukkomi": [
        "それ通天閣ちゃうやろ!", 
        "それただの錯覚や!"
        ],
      "punch": [
        "やっぱり通天閣は見上げるもんやったなあ…"
        ]
    }}
    """
    response = llm.invoke(prompt)
    # ChatOllama は通常 Message オブジェクトを返すため取り扱いに注意
    content = getattr(response, "content", str(response))
    fixed = fix_pseudo_json_response(content)
    try:
        data = json.loads(fixed)
        return { theme: data }
    except Exception as e:
        print("⚠️ JSON変換エラー:", e)
        print("応答内容:", fixed)
        return None

def update_theme_pool(new_theme, filename=THEME_POOL_PATH):
    os.makedirs(os.path.dirname(filename), exist_ok=True)
    try:
        with open(filename, "r", encoding="utf-8") as f:
            pool = json.load(f)
    except FileNotFoundError:
        pool = {}

    pool.update(new_theme)
    with open(filename, "w", encoding="utf-8") as f:
        json.dump(pool, f, ensure_ascii=False, indent=2)
    print(f"✅ テーマ '{list(new_theme.keys())[0]}' を登録しました → {filename}")

if __name__ == "__main__":
    theme_name = "通天閣の観光あるある"
    theme_data = generate_theme_content(theme_name)
    if theme_data:
        update_theme_pool(theme_data)

prompt_templates

【COMMON_IMPROVEMENT_V1】

    あなたは大阪の漫才師です。
    与えられたテーマに基づき、必ず関西弁を使って
    「ボケ → ツッコミ → オチ」の三幕構成で短くキレ良く構成してください。

    出力は JSON のみ。
    必須キー: ["theme","boke","tsukkomi","punch","context"]。
    説明文や余計な文章は不要。
    セリフ内の引用符は「」か ' を使い、二重引用符 " は使わない。

    【重要ルール】
    1. 構造タグ固定
    - boke: 「【起】」で開始
    - tsukkomi: 「【承】」で開始
    - punch: 「【結】」で開始
    2. 重要単語マーキング
    - ボケ内でテーマ関連の重要単語を《》で1〜2語マーキング
    - マーキング語はツッコミとオチにも必ず再登場(同じ表記)
    3. テーマ関連語の強制
    - 次のキーワードから最低2語を必ず含める:
        [大阪、漫才、ボケ、ツッコミ]
    4. 関西弁強化
    - 語尾は「やろ/やん/やねん/ちゃう/せんと/やで/かいな/やさかい/知らんけど」
    - ツッコミは文頭に「なんでやねん!」「それ〜ちゃうんかい!」等の感情強めの一言
    5. オチの会話感
    - 「ホンマは〜やってん」「〜ってオチでした!残念!」など真相暴露で落とす
    6. テンポ制御
    - 各セリフは20〜40文字以内、最大2文まで

    【出力例】
    {
    "theme": "base",
    "boke": "【起】《キーワード例》から始まる妄想やねん",
    "tsukkomi": "【承】なんでやねん!《キーワード例》関係薄いやろ",
    "punch": "【結】ホンマは《キーワード例》の宣伝やってん、ってオチでした!残念!",
    "context": null
    }

    ―――― ここから先は各テンプレの既存内容を続けて使用 ――――

theme は "base" に固定。
次のテーマキーワードから最低2語以上を必ず使う:
["大阪","漫才","ボケ","ツッコミ"]

あなたは大阪の漫才師です。以下のテーマで、必ず関西弁を使って
「ボケ → ツッコミ → オチ」の順に短くキレ良く構成してください。

出力は JSON のみ。必須キーは ["theme","boke","tsukkomi","punch","context"]。
余計な文章や説明は不要です。
セリフ内の引用符は「」か ' を使い、二重引用符 " は使わない。

改善指示:
1. 三幕構成の明示
   - 【起】ボケ(面白い出だし)
   - 【承】ツッコミ(否定や突っ込み)
   - 【結】オチ(真相暴露や意外な結末)
   - 各セリフを会話として自然につなげる(せやけど/そやから/でも など)
2. 伏線回収必須
   - ボケで使った重要単語(名詞・固有名詞)をツッコミとオチの両方に必ず登場させる
3. 関西弁強化
   - 語尾は必ず「やろ/やん/やねん/ちゃう/せんと/やで/かいな/やさかい/知らんけど」のいずれか
   - ツッコミは文頭に「なんでやねん!」「それ〜ちゃうんかい!」など感情強めの一言を入れる
4. オチの会話感
   - 「ホンマは〜やってん」「〜ってオチでした!残念!」のような、いたずらっぽい真相暴露や落とし感を出す
5. テンポ
   - 各セリフは20〜40文字、最大2文以内。冗長説明や重複禁止

各パートの例:
- ボケ例:「昨日、冷蔵庫開けたらペンギン住んでたんやけど」
- ツッコミ例:「なんでやねん!どんな家庭用動物園やねん」
- オチ例:「ホンマは氷の配達員やってん、ってオチでした!残念!」

JSON 出力例:
{
  "theme": "base",
  "boke": "「大阪の漫才はボケ先行やろ」やろ",
  "tsukkomi": "それちゃうんかい!「ツッコミあっての漫才やで」やで",
  "punch": "「ホンマは台本飛ばしてただけやってん、ってオチでした!知らんけど」知らんけど",
  "context": null
}
テーマ別に台本の生成

generate_script.py

# scripts/generate_script.py
import json, os, random, datetime
from kansai_convert import to_kansai

DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
THEME_POOL_PATH = os.path.join(DATA_DIR, "theme_pool.json")
OUTPUT_DIR = os.path.join(DATA_DIR, "generated_scripts")

def generate_manzai(theme_name, theme_data):
    convo = []
    for _ in range(2):
        boke = to_kansai(random.choice(theme_data["boke"]))
        tsukkomi = to_kansai(random.choice(theme_data["tsukkomi"]))
        convo.append({"role": "ボケ", "line": boke})
        convo.append({"role": "ツッコミ", "line": tsukkomi})
    punch = to_kansai(random.choice(theme_data["punch"]))
    convo.append({"role": "オチ", "line": punch})

    return {
        "title": theme_name,
        "style": "漫才",
        "context": theme_data.get("context", ""),
        "tags": [theme_name, "関西弁", "台本"],
        "created_at": datetime.datetime.now().strftime("%Y-%m-%d"),
        "conversation": convo
    }

def save_script(script, folder=OUTPUT_DIR):
    os.makedirs(folder, exist_ok=True)
    filename = f"{script['title']}_{script['created_at']}.json"
    path = os.path.join(folder, filename)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(script, f, ensure_ascii=False, indent=2)
    print(f"📦 台本を保存しました → {path}")

if __name__ == "__main__":
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    with open(THEME_POOL_PATH, "r", encoding="utf-8") as f:
        theme_pool = json.load(f)

    for theme_name, theme_data in theme_pool.items():
        script = generate_manzai(theme_name, theme_data)
        save_script(script)
台本の評価

evaluate_manzai.py

# scripts/evaluate_manzai.py
# -*- coding: utf-8 -*-
"""
漫才ネタの評価関数モジュール
"""

import re
from typing import Dict, List, Tuple

# 関西弁語尾・語彙サンプル(最低限)
KANSAI_ENDINGS = ["やろ", "やん", "やねん", "ちゃう", "せんと", "せんやろ", "やで", "かいな", "やさかい"]
KANSAI_WORDS = ["ワイ", "あかん", "ほんま", "せやな", "知らんけど", "なんでやねん", "せやけど"]

# テーマ別キーワード例
THEME_KEYWORDS = {
    "gamba_osaka": ["パナスタ", "吹田", "青黒", "ゴール裏", "ロスタイム", "サポーター", "応援", "勝利", "ガンバ大阪", "Jリーグ", "サッカー", "選手", "試合", "スタジアム"],
    "nmb48": ["難波", "NMB", "劇場", "推し", "MCコーナー", "ステージ衣装", "握手会", "ライブ", "アイドル", "ファン", "パフォーマンス"],
    "base": ["大阪", "漫才", "ボケ", "ツッコミ"],
    "ngk_yoshimoto": ["なんばグランド花月", "吉本", "芸人", "舞台", "漫才師"],
    "obachan": ["おばちゃん", "ヒョウ柄", "アメちゃん", "世話焼き"],
    "deep": ["裏なんば", "ディープ", "立ち飲み", "ホルモン"],
    "festival_danjiri": ["だんじり", "岸和田", "祭り", "木彫り", "曳き回し"],
    "tenjin_matsuri": ["天神祭", "船渡御", "花火", "鉾流神事"],
    "food": ["たこ焼き", "お好み焼き", "いか焼き", "串カツ", "粉もん", "グルメ", "食べ歩き", "屋台", "ビール", "食文化", "食い倒れ", "大阪名物", "食いしん坊"],
    "kaiyukan": ["海遊館", "ジンベエザメ", "水槽", "魚", "観覧車", "海の生き物", "イルカ", "天保山", "海の世界"],
    "onsen_arima": ["有馬温泉", "金泉", "銀泉", "旅館"],
    "shotengai_tenjinbashi": ["天神橋筋商店街", "商店街", "アーケード", "買い物"],
    "sight_osaka": ["大阪城", "通天閣", "道頓堀", "グリコ", "京セラドーム大阪", "梅田スカイビル", "梅田", "心斎橋", "なんば", "天王寺", "新世界", "阿倍野ハルカス"],
    "koshien_tigers": ["甲子園球場", "阪神", "タイガース", "ラッキーセブン", "応援歌", "ジェット風船", "浜風"],
    "template": [],
    "osaka_metro": ["御堂筋線", "谷町線", "中央線", "駅", "改札", "地下鉄", "乗り換え", "通勤", "通学", "大阪市営地下鉄",  "交通", "路線図", "車両"],
    "tsutenkaku_shinsekai": ["通天閣", "新世界", "ビリケン", "串カツ"],
    "usj": ["USJ", "ユニバーサル", "アトラクション", "パレード"],
}

def evaluate_manzai(manzai: Dict) -> Dict:
    boke = manzai.get("boke", "")
    tsukkomi = manzai.get("tsukkomi", "")
    punch = manzai.get("punch", "")
    theme = manzai.get("theme", "").lower()

    results = {}

    # 1. 構造整合度(簡易)
    shared_bt = _shared_keywords(boke, tsukkomi)
    shared_tp = _shared_keywords(tsukkomi, punch)
    results["structure_score"] = round(((len(shared_bt) > 0) + (len(shared_tp) > 0)) / 2 * 5, 2)

    # 2. 伏線回収度
    boke_words = set(re.findall(r"\w+", boke))
    punch_words = set(re.findall(r"\w+", punch))
    overlap = boke_words & punch_words
    results["callback_score"] = round(min(len(overlap) / max(len(boke_words), 1), 1) * 5, 2)

    # 3. 関西弁度
    kansai_hits = sum(1 for w in KANSAI_ENDINGS + KANSAI_WORDS if w in (boke + tsukkomi + punch))
    results["kansai_score"] = round(min(kansai_hits / 3, 1) * 5, 2)

    # 4. テーマ語出現率
    theme_hits = 0
    if theme in THEME_KEYWORDS:
        theme_hits = sum(1 for w in THEME_KEYWORDS[theme] if w in (boke + tsukkomi + punch))
    results["theme_score"] = round(min(theme_hits / 2, 1) * 5, 2)

    # 5. テンポ感(20〜40文字を理想レンジとする)
    def tempo_score(line):
        return 1 if 20 <= len(line) <= 40 else 0
    tempo_hits = tempo_score(boke) + tempo_score(tsukkomi) + tempo_score(punch)
    results["tempo_score"] = round((tempo_hits / 3) * 5, 2)

    # 総合
    results["total_score"] = round(sum(results.values()) / len(results), 2)

    return results

def _shared_keywords(text1: str, text2: str) -> List[str]:
    words1 = set(re.findall(r"\w+", text1))
    words2 = set(re.findall(r"\w+", text2))
    return list(words1 & words2)

if __name__ == "__main__":
    # 簡易テスト
    sample = {
        "theme": "gamba_osaka",
        "boke": "パナスタの青黒ユニフォーム見ただけで風向き変わるやろ!",
        "tsukkomi": "どこの世界に応援で天気操るサポーターおんねん!",
        "punch": "でも相手のフリーキックには風も止まる…応援の重みちゃうねん!",
    }
    print(evaluate_manzai(sample))

さあ、これで大量のデータセットを自動で大量に作成される、期待感と高揚感で満ち溢れていました、そう…この時までは……
これらをもとに試行錯誤の沼にハマって行くことになるとは、この時のボクは微塵も考えておりませんでした。

キミ(Copilot)とならどこまでも行ける…そう思ってた時期がありました。

幾度の試行錯誤を重ねファインチューニングを行うに十分なデータセットの用意ができる!…と考えていたのですが、世の中そんなに甘くない!という現実を思いっきり突きつけられました。
まあ、うまくいきません(笑)!
ほんとうまく行きません(笑)!!
もう、笑うしかない(笑)!!!

状況としては、キミ(Copilot)と対話をし改善を繰り返してきましたが余計とっ散らかったような気がしてきます。

生成+評価スクリプト
# scripts/batch_generate_and_evaluate.py
# -*- coding: utf-8 -*-

import argparse
import json
import time
import re
from pathlib import Path
from typing import Any, Dict, List, Tuple
from scripts.theme_keywords import THEME_KEYWORDS, CANDIDATE_TERMS
from scripts.normalizers import normalize_term, normalize_keywords

from scripts.generate_script import generate_once
from scripts.evaluate_manzai import evaluate_manzai

# 必要なら有効化(生成が文字列の時に復旧させたい場合)
try:
    from scripts.fix_json import fix_pseudo_json_response  # optional
except Exception:
    fix_pseudo_json_response = None  # type: ignore

THEME_KEYWORDS = {
    "gamba_osaka": ["パナスタ", "吹田", "青黒", "ゴール裏", "ロスタイム", "サポーター", "応援", "勝利", "ガンバ大阪", "Jリーグ", "サッカー", "選手", "試合", "スタジアム"],
    "nmb48": ["難波", "NMB", "劇場", "推し", "MCコーナー", "ステージ衣装", "握手会", "ライブ", "アイドル", "ファン", "パフォーマンス"],
    "base": ["大阪", "漫才", "ボケ", "ツッコミ"],
    "ngk_yoshimoto": ["なんばグランド花月", "吉本", "芸人", "舞台", "漫才師"],
    "obachan": ["おばちゃん", "ヒョウ柄", "アメちゃん", "世話焼き"],
    "deep": ["裏なんば", "ディープ", "立ち飲み", "ホルモン"],
    "festival_danjiri": ["だんじり", "岸和田", "祭り", "木彫り", "曳き回し"],
    "tenjin_matsuri": ["天神祭", "船渡御", "花火", "鉾流神事"],
    "food": ["たこ焼き", "お好み焼き", "いか焼き", "串カツ", "粉もん", "グルメ", "食べ歩き", "屋台", "ビール", "食文化", "食い倒れ", "大阪名物", "食いしん坊"],
    "kaiyukan": ["海遊館", "ジンベエザメ", "水槽", "魚", "観覧車", "海の生き物", "イルカ", "天保山", "海の世界"],
    "onsen_arima": ["有馬温泉", "金泉", "銀泉", "旅館"],
    "shotengai_tenjinbashi": ["天神橋筋商店街", "商店街", "アーケード", "買い物"],
    "sight_osaka": ["大阪城", "通天閣", "道頓堀", "グリコ", "京セラドーム大阪", "梅田スカイビル", "梅田", "心斎橋", "なんば", "天王寺", "新世界", "阿倍野ハルカス"],
    "koshien_tigers": ["甲子園球場", "阪神", "タイガース", "ラッキーセブン", "応援歌", "ジェット風船", "浜風"],
    "template": [],
    "osaka_metro": ["御堂筋線", "谷町線", "中央線", "駅", "改札", "地下鉄", "乗り換え", "通勤", "通学", "大阪市営地下鉄", "交通", "路線図", "車両"],
    "tsutenkaku_shinsekai": ["通天閣", "新世界", "ビリケン", "串カツ"],
    "usj": ["USJ", "ユニバーサル", "アトラクション", "パレード"],
}

OUT_DIR = Path("data/generated_and_scored")
OUT_DIR.mkdir(parents=True, exist_ok=True)

# ==== 形式チェック(テンプレ遵守の最低条件) ====

def _valid_structure(s: str, pat: str) -> bool:
    return re.search(pat, s) is not None

def _marks(s: str) -> List[str]:
    return re.findall(r"[《〘〈](.+?)[》〙〉]", s)

def _theme_ok(obj: Dict[str, Any], expected: str) -> bool:
    return obj.get("theme") == expected

BRACKETS = [("《","》"),("〘","〙"),("〈","〉")]

def _has_bracketed(term: str, s: str) -> bool:
    return any((l + term + r) in s for (l, r) in BRACKETS)

def _needs_retry(obj: Dict[str, Any], expected_theme: str) -> tuple[bool, str]:
    b, t, p = obj.get("boke",""), obj.get("tsukkomi",""), obj.get("punch","")
    if obj.get("theme") != expected_theme:
        return True, "theme_mismatch"

    if not (_valid_structure(b, r"^\s*【起】") and _valid_structure(t, r"^\s*【承】") and _valid_structure(p, r"^\s*【結】")):
        return True, "missing_tags"

    ms = _marks(b)
    if not ms:
        return True, "missing_mark"
    if len(ms) != 1:
        return True, "mark_count_not_one"

    mark = ms[0]
    # 《》込みで tsukkomi / punch に各1回
    if t.count(f"《{mark}》") != 1 or p.count(f"《{mark}》") != 1:
        return True, "mark_not_reappeared_bracketed"

    # テーマ語バリデーション(任意。baseなら《漫才》を許容)
    kw = THEME_KEYWORDS.get(expected_theme, [])
    kw_norm_set = normalize_keywords(kw)
    mark_norm = normalize_term(mark)
    if kw_norm_set and mark_norm not in kw_norm_set:
        return True, "mark_not_in_theme_keywords"

    return False, ""

# ==== 追加のバリデーション(必須キー・テンポ・文数など) ====

RE_SENT_SPLIT = re.compile(r"[。!?!?\n]+")

def _tempo_ok(s: str) -> bool:
    parts = [x for x in RE_SENT_SPLIT.split(s) if x]
    return 30 <= len(s) <= 60 and len(parts) <= 2

def validate_script_obj(obj: Dict[str, Any]) -> tuple[Dict[str, Any], List[str]]:
    """
    生成物の追加検査。修正はせず、問題点を errors に列挙して返す。
    クリティカル: タグ欠落・マーキング欠落・再登場欠落・必須キー欠落・JSONエラー(ここでは来ない想定)
    軽微: テンポ逸脱・ツッコミ冒頭の強い一言欠落 など
    """
    errors: List[str] = []
    required_keys = ["theme", "boke", "tsukkomi", "punch", "context"]
    for k in required_keys:
        if k not in obj:
            errors.append(f"missing_key:{k}")

    # 先頭タグ
    if not obj.get("boke", "").startswith("【起】"):
        errors.append("tag_missing:boke")
    if not obj.get("tsukkomi", "").startswith("【承】"):
        errors.append("tag_missing:tsukkomi")
    if not obj.get("punch", "").startswith("【結】"):
        errors.append("tag_missing:punch")

    # マーキングと再登場
    ms = _marks(obj.get("boke", ""))
    if not ms:
        errors.append("missing_mark_in_boke")
    else:
        if ms[0] not in obj.get("tsukkomi", ""):
            errors.append("mark_not_in_tsukkomi")
        if ms[0] not in obj.get("punch", ""):
            errors.append("mark_not_in_punch")

    # ツッコミ冒頭の強い一言(任意だが推奨)
    if not re.search(r"^【承】\s*(なんでやねん!|それ.+ちゃうんかい!)", obj.get("tsukkomi", "")):
        errors.append("tsukkomi_weak_intro")

    # テンポ
    for role in ["boke", "tsukkomi", "punch"]:
        if not _tempo_ok(obj.get(role, "")):
            errors.append(f"tempo_out:{role}")

    return obj, errors

# ==== データ正規化 ====

def to_obj(raw: Any) -> Dict[str, Any]:
    """generate_once の戻りが dict/str どちらでも評価できるように正規化"""
    if isinstance(raw, dict):
        return raw
    if isinstance(raw, str):
        if fix_pseudo_json_response:
            fixed = fix_pseudo_json_response(raw)
            try:
                return json.loads(fixed)
            except json.JSONDecodeError:
                return {"error": "json_parse_failed", "raw": fixed}
        else:
            return {"error": "json_parse_failed", "raw": raw}
    s = str(raw)
    if fix_pseudo_json_response:
        fixed = fix_pseudo_json_response(s)
        try:
            return json.loads(fixed)
        except json.JSONDecodeError:
            return {"error": "json_parse_failed", "raw": fixed}
    return {"error": "json_parse_failed", "raw": s}

# ==== メイン処理 ====

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--interval", type=float, default=1.5, help="各テーマ間の待ち秒数")
    parser.add_argument("--only", nargs="*", help="特定テーマだけ実行(スペース区切り)")
    parser.add_argument("--jitter", type=float, default=0.0, help="インターバルに±この秒数のランダム揺らぎを加える")
    args = parser.parse_args()

    themes: List[str] = [k for k in THEME_KEYWORDS.keys() if k != "template"]
    if args.only:
        allow = set(args.only)
        themes = [t for t in themes if t in allow]

    results: List[Dict[str, Any]] = []

    MAX_RETRY = 2    # JSONパース復旧の再生成回数
    FMT_RETRY = 3    # 形式違反時の追加再生成回数(タグ・マーキング)
    VAL_RETRY = 1    # バリデーション(テンポ等)NG時の軽い再生成回数

    for i, theme in enumerate(themes, 1):
        print(f"[{i}/{len(themes)}] === {theme} ===")

        # 追記箇所---
        candidates = CANDIDATE_TERMS.get(theme, [])
        chosen = candidates[0] if candidates else None

        locked_mark = None
        kw = THEME_KEYWORDS.get(theme, [])
        if kw:
            locked_mark = kw[0]  # or ランダム選択/重み付きなど

        base_constraints = []
        base_constraints.append("重要語》は1語のみ。boke/tsukkomi/punchに《》付きで各1回、完全一致で再登場。")
        base_constraints.append("タグ【起】【承】【結】を各先頭。標準語禁止。各1〜2文、30〜60文字。")
        base_constraints.append("boke/tsukkomi/punchに《》付きで各1回完全一致で再登場。")
        if locked_mark:
            base_constraints.append(f"今回の《重要語》は《{locked_mark}》で固定。bokeに1回だけ使い、tsukkomi/punchにも《{locked_mark}》で出す。")
        gen_constraints = " ".join(base_constraints)
        if chosen:
            base_constraints += f" 今回の《重要語》は{chosen}で固定する。"

        # ----

        obj = None
        for parse_attempt in range(MAX_RETRY + 1):
            raw = generate_once(theme, constraints=gen_constraints)
            script_obj = to_obj(raw)

            need, reason = (False, "")

            # 1) 先にテーマ一致をチェック
            if "error" not in script_obj and not _theme_ok(script_obj, theme):
                need, reason = True, "theme_mismatch"

            # 2) テーマがOKなら既存の形式チェックへ
            if "error" not in script_obj and not need:
                need, reason = _needs_retry(script_obj, theme)

            fmt_retry_count = 0
            while need and fmt_retry_count < FMT_RETRY:
                print(f"  形式NG({reason}) → 再生成")
                raw = generate_once(
                    theme,
                    constraints="必ず boke の最初の《重要語》を tsukkomi と punch の両方に《》付きで完全一致させる。同義語・別表記・略称禁止。"
                )
                script_obj = to_obj(raw)

                # 再生成後もテーマ→形式の順で再確認
                need, reason = (False, "")
                if "error" not in script_obj and not _theme_ok(script_obj, theme):
                    need, reason = True, "theme_mismatch"
                if "error" not in script_obj and not need:
                    need, reason = _needs_retry(script_obj, theme)

                fmt_retry_count += 1

            # 3) 追加バリデーション(テンポ・ツッコミ冒頭など)
            validated, val_errors = validate_script_obj(script_obj)

            # 軽微NGに対して、1回だけやり直し(必須NGはすでにFMTで処理済み)
            val_retry_count = 0
            if val_errors and any(e.startswith("tempo_out") or e == "tsukkomi_weak_intro" for e in val_errors):
                while val_retry_count < VAL_RETRY:
                    print(f"  バリデーション注意({';'.join(val_errors)}) → 微調整再生成")
                    raw = generate_once(
                        theme,
                        constraints="各セリフは20〜40文字、最大2文。ツッコミは文頭に「なんでやねん!」などの強い一言を必ず入れる。"
                    )
                    script_obj = to_obj(raw)
                    # 形式は再確認
                    need, reason = _needs_retry(script_obj, theme) if "error" not in script_obj else (True, "json_error")
                    if need:
                        # 形式崩れたら即やり直さず終了(次テーマへ)
                        print("  微調整で形式崩れ、再採用見送り")
                        break
                    validated, val_errors = validate_script_obj(script_obj)
                    val_retry_count += 1

            obj = script_obj
            if fmt_retry_count > 0:
                print(f"  形式再生成で成功({fmt_retry_count}回目)")
            if val_errors:
                print(f"  追加バリデーション警告: {', '.join(val_errors)}")
            break

        if obj is None:
            obj = script_obj  # 全部失敗

        # 4) スコア計算
        score = evaluate_manzai(obj) if "error" not in obj else {
            "structure_score": 0.0, "callback_score": 0.0, "kansai_score": 0.0,
            "theme_score": 0.0, "tempo_score": 0.0, "total_score": 0.0
        }

        combined: Dict[str, Any] = {"theme": theme, "script": obj, "score": score, "validation": {"errors": val_errors}}

        # 検査ログ(必要ならファイルに残す)
        # combined["validation"] = {"errors": val_errors}  # 保存したい場合はコメント解除

        results.append(combined)

        # 5) 保存
        with open(OUT_DIR / f"{theme}.json", "w", encoding="utf-8") as f:
            json.dump(combined, f, ensure_ascii=False, indent=2)
        with open(OUT_DIR / "_all_results.json", "w", encoding="utf-8") as f:
            json.dump(results, f, ensure_ascii=False, indent=2)

        _sleep(args.interval, args.jitter)

def _sleep(interval: float, jitter: float) -> None:
    if interval <= 0:
        return
    if jitter and jitter > 0:
        import random
        delta = random.uniform(-jitter, jitter)
    else:
        delta = 0.0
    time.sleep(max(0.0, interval + delta))

if __name__ == "__main__":
    main()

会話の生成と内容(ボケ、ツッコミ、オチ)を安定させる、必ずテーマに沿ったキーワードを含ませ文脈に関連性を持たせる、生成した文章を評価する…といった感じです。
以下に出力結果を載せます。まあ…何というか…うまく行きませんね(笑)

出力結果と評価
[
  {
    "theme": "gamba_osaka",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "nmb48",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "base",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "ngk_yoshimoto",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "obachan",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "deep",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!"
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "mark_not_in_tsukkomi",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "festival_danjiri",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "tempo_out:boke"
      ]
    }
  },
  {
    "theme": "tenjin_matsuri",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】天神祭の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは鉾流神事見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!"
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "tempo_out:boke"
      ]
    }
  },
  {
    "theme": "food",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "kaiyukan",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "mark_not_in_tsukkomi",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "onsen_arima",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!"
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "tempo_out:boke"
      ]
    }
  },
  {
    "theme": "shotengai_tenjinbashi",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "sight_osaka",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "tempo_out:boke"
      ]
    }
  },
  {
    "theme": "koshien_tigers",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "osaka_metro",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "tsutenkaku_shinsekai",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "mark_not_in_tsukkomi",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  },
  {
    "theme": "usj",
    "script": {
      "theme": "gamba_osaka",
      "boke": "【起】《天神祭》の船渡御,めっちゃ人多いねん…",
      "tsukkomi": "【承】なんでやねん!花火前に行ったらそらぎゅうぎゅうやろ!",
      "punch": "【結】ホンマは《鉾流神事》見に行こ思てたのに,気ぃついたら屋台で焼きそば食うてもうてたわ…ってオチや!",
      "context": null
    },
    "score": {
      "structure_score": 5.0,
      "callback_score": 0.0,
      "kansai_score": 5.0,
      "theme_score": 0.0,
      "tempo_score": 1.67,
      "total_score": 2.33
    },
    "validation": {
      "errors": [
        "missing_mark_in_boke",
        "tempo_out:boke",
        "tempo_out:tsukkomi"
      ]
    }
  }
]

見事にテーマとキーワード一致しておらず皆同じ文章が生成されちゃってます。ここには載せていませんが、この前にも何度かスクリプトを修正しながら数回試行した結果、

  • 評価基準を満たす文章が生成されたこともあった
  • テーマとキーワードは一致しているが文章に関連性がない
  • テーマ、キーワード、文脈も整合性は取れているが文章が長すぎてテンポ感がない

といった出力が多い印象で、ほぼほぼ2つ目の出力が大半を占めていました

キミ(Copilot)とならきっと乗り越えられる。

これまでの試行結果を踏まえてこれからどうして行くか、めっちゃ悩んでおります。
(コードを見直していると、どうもベースとなるプロンプトからしかキーワードを拾ってこない記述になっている?だからテーマ関係なく同じ文章になる?あとは文章生成用のプロンプト全体を見直す?生成条件(温度調整、出力文字数)の見直す?いっそ生成用のローカルLLMを変更してみる?などなど…)

なかなか厳しい状況ですがキミ(Copilot)と協力して、次のステップへ行けるように試行錯誤してみます!!!

Discussion