以下の内容はhttps://kamatimaru.hatenablog.com/entry/2025/01/25/173113より取得しました。


生成AI(OpenAI)にある物語作品を換骨奪胎した別の物語作品を創ってもらう試み

概要

ある既存の物語作品をベースとして、与えた設定を加味して別の物語作品のあらすじを創る(=換骨奪胎)ということを生成AI(OpenAI)にやらせてみた。今回は「銀河鉄道の夜」を題材としている。

www.aozora.gr.jp

背景

前提として、物語の抽象的な構造を分析する学問分野というものが伝統的に存在する。プロップやスターウォーズも影響を受けているというジョゼフ・キャンベルが有名である。 ※ 私はこれらの学者の作品を読んだことはないが知識としては知っている。

ja.wikipedia.org

www.hayakawa-online.co.jp

また、「物語には抽象的な構造が存在する」ということを応用した物語創作の方法論的なものも以下のように存在する。

www.seikaisha.co.jp

www.kadokawa.co.jp

実際に私自身、このような方法論を使って物語を創れないか試みたことがある。

しかし、むしろ物語創作において難しいのは、抽象的な物語構造から具体的な世界設定や登場人物を肉付けするところであり、そこに才能が必要なのだということに気づいて断念した。

例えば、『ワンピース』や『鬼滅の刃』のような作品も抽象的な物語構造としては「旅をして仲間を作って敵を倒す」というシンプルなものかもしれないし、似たような物語構造の作品はたくさんある。

しかし、『ワンピース』や『鬼滅の刃』は物語世界の設定や主人公側と敵側のキャラクター造形が細部まで作り込まれていたりするから面白いのであって、そこが作家の力量が問われるところである。

要するに「物語」には以下の特性があるといえると思う。

  • 具体から抽象のベクトル(=既存の物語作品を分析して抽象的な構造を抽出する): 比較的簡単
  • 抽象から具体のベクトル(=抽象的な物語構造から具体的な物語を創る): 難しい

上記の前提を踏まえて、既存の有名な物語作品を換骨奪胎して、物語構造は似ているが物語世界の設定や登場人物などが異なる別の作品を創るタスクを生成AI(ChatGPT)にやらせてみた。

前提

使用したライブラリとそのバージョンは以下。

requirements.txt

openai==1.59.3
langchain==0.3
langchain-openai==0.2.14
python-dotenv==1.0.1
pydantic==2.10.4

python-dotenvを使用しているので、.envファイルにOpenAIのAPIキーの設定が必要。

.env

OPENAI_API_KEY={OpenAIのAPIキー}

方針

以下のようなワークフローを創る。

graph TD;
    STEP1[STEP1:元にしたい物語作品の作品名を受け取る]-->STEP2[STEP2:【生成AI】その作品の抽象的な物語構造を抽出する];
    STEP2-->STEP3[STEP3:作りたい物語の条件を入力する];
    STEP3-->STEP4:[STEP4:【生成AI】STEP2,3をインプットに新しい物語のあらすじを創作する];

STEP1:元にしたい物語作品の作品名を受け取る

これは生成AIとか何も関係なくて、Pythonの場合は以下のコードを書くだけで対話モードで文字列を入力して変数に格納できる。

input_work = input("創作のインプットにしたい作品名を入力してください: ")
print("入力:", input_work)
$ python main.py
創作のインプットにしたい作品名を入力してください: 銀河鉄道の夜
入力:  銀河鉄道の夜

STEP2:【生成AI】その作品の抽象的な物語構造を抽出する

ここから生成AI(OpenAI)を使う。この程度ならLangChainはオーバースペックかもしれないが、触ってみたかったので練習も兼ねて使ってみる。

今回の例であれば、「銀河鉄道の夜」の抽象的な物語構造を生成AIに抽出させたい。

「抽象的な物語構造」という言葉だけだと期待する抽象度やフォーマットが伝わりづらいので、「Few-Shotプロンプティング」というプロンプトエンジニアリングの技法を使って回答の精度を高める。

https://www.promptingguide.ai/jp/techniques/fewshot

まずは「Few-Shotプロンプティング」として与える例を定義する。今回は「桃太郎」と「シンデレラ」から抽象的な物語構造を抽出した例を与える。

examples = [
    {
        "work": "桃太郎",
        "plot": """
1.社会や共同体が危機に直面している。
2.特別な力や資質を持つ主人公が登場し、問題解決の鍵を握る。
3.主人公が課題を認識し、目標に向かって行動することを決意する。
4.主人公が目的達成に必要なリソースや助力を集める。
5.主人公が敵対者や障害に直面し、それを乗り越える。
6.主人公が課題を達成し、成果や報酬を得る。
7.主人公が成果を持ち帰り、社会に還元することで秩序が回復する。
        """,
    },
    {
        "work": "シンデレラ",
        "plot": """
1.主人公が困難や不公正な状況に置かれている。
2.主人公が新たなチャンスや希望を見つけるが、現状に阻まれる。
3.主人公が課題を克服するための助力者やリソースを得る。
4.主人公が試練を受け入れ、一時的な成功を収めるが、問題が完全には解決しない。
5.主人公の成功が危機にさらされるが、それを解決するための手がかりが提示される。
6.主人公が真の価値や能力を認識され、課題が完全に解決される。
7.主人公が自己実現を果たし、物語世界の秩序が回復または再構築される。
        """,
    },
]

※ 上記の「桃太郎」と「シンデレラ」から抽象的な物語構造を抽出するのも別経路で生成AIにやってもらった。

以下のようにプロンプトの生成部分を実装する。LangChainにFewShotPromptTemplateというAPIが存在するのでこれを利用する。

api.python.langchain.com

# ...省略
from langchain.prompts.prompt import PromptTemplate
from pydantic import BaseModel, Field
from langchain_core.output_parsers import JsonOutputParser
from langchain.prompts.few_shot import FewShotPromptTemplate

# ...省略
example_prompt = PromptTemplate(
    input_variables=["work", "plot"], template="# 「{work}」の場合\n{plot}"
)

class PlotResult(BaseModel):
    episodes: list[str] = Field(description="プロットを構成するエピソードのリスト")

parser = JsonOutputParser(pydantic_object=PlotResult)

get_input_work_plot_prompt_template = FewShotPromptTemplate(
    examples=examples,
    example_prompt=example_prompt,
    prefix="以下の例のように「{input_work}」の物語構造を抽象化してください。",
 suffix="{format_instructions}",
    input_variables=["input_work"],
    partial_variables={"format_instructions": parser.get_format_instructions()},
)

get_input_work_plot_prompt = get_input_work_plot_prompt_template.format(
    input_work=input_work
)
print(get_input_work_plot_prompt)

補足: この辺のコードはレスポンスをJSON形式で受け取るために必要。

# ...省略
from pydantic import BaseModel, Field
from langchain_core.output_parsers import JsonOutputParser
# ...省略

class PlotResult(BaseModel):
    episodes: list[str] = Field(description="プロットを構成するエピソードのリスト")

parser = JsonOutputParser(pydantic_object=PlotResult)

get_input_work_plot_prompt_template = FewShotPromptTemplate(
    # ...省略
    suffix="{format_instructions}",
    # ...省略
    partial_variables={"format_instructions": parser.get_format_instructions()},
)

以下のプロンプトが作成される。

以下の例のように「銀河鉄道の夜」の物語構造を抽象化してください。

# 「桃太郎」の場合

1.社会や共同体が危機に直面している。
2.特別な力や資質を持つ主人公が登場し、問題解決の鍵を握る。
3.主人公が課題を認識し、目標に向かって行動することを決意する。
4.主人公が目的達成に必要なリソースや助力を集める。
5.主人公が敵対者や障害に直面し、それを乗り越える。
6.主人公が課題を達成し、成果や報酬を得る。
7.主人公が成果を持ち帰り、社会に還元することで秩序が回復する。


# 「シンデレラ」の場合

1.主人公が困難や不公正な状況に置かれている。
2.主人公が新たなチャンスや希望を見つけるが、現状に阻まれる。
3.主人公が課題を克服するための助力者やリソースを得る。
4.主人公が試練を受け入れ、一時的な成功を収めるが、問題が完全には解決しない。
5.主人公の成功が危機にさらされるが、それを解決するための手がかりが提示される。
6.主人公が真の価値や能力を認識され、課題が完全に解決される。
7.主人公が自己実現を果たし、物語世界の秩序が回復または再構築される。


The output should be formatted as a JSON instance that conforms to the JSON schema below.

As an example, for the schema {"properties": {"foo": {"title": "Foo", "description": "a list of strings", "type": "array", "items": {"type": "string"}}}, "required": ["foo"]}
the object {"foo": ["bar", "baz"]} is a well-formatted instance of the schema. The object {"properties": {"foo": ["bar", "baz"]}} is not well-formatted.

Here is the output schema:
```
{"properties": {"episodes": {"description": "プロットを構成するエピソードのリスト", "items": {"type": "string"}, "title": "Episodes", "type": "array"}}, "required": ["episodes"]}
```

上記のプロンプトをOpenAIのAPIに投げる。

# ...省略
from dotenv import load_dotenv

model = ChatOpenAI(model="gpt-4o")
output = model.invoke(get_input_work_plot_prompt)
result = parser.invoke(output)
print(result)

試行回によっては「ジョバンニ」や「カムパネルラ」という固有名が結果に含まれてしまうなど、抽象度にブレは出るが、何度か試したら以下のような期待しているのに近い結果が得られた。

{
   "episodes":[
      "主人公が日常生活で孤独や疎外感を抱えている。",
      "主人公が不思議な体験を通じて異世界や新たな視点に導かれる。",
      "主人公が旅を通じて他者との交流や自己発見を経験する。",
      "主人公が宇宙的・哲学的なテーマや問いに触れる。",
      "主人公が旅の終わりに近づき、現実世界に戻る準備をする。",
      "主人公が新たな洞察や成長を得て日常生活に戻る。",
      "主人公が得た知見や経験を心に留め、物語世界の新たな秩序や理解が生まれる。"
   ]
}

STEP3:作りたい物語の条件を入力する

このフェーズも生成AIは関係がない。以下のようなコードを書く。

plot_conditions = []
while True:
    plot_condition = input(
        "プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): "
    )
    if plot_condition.lower() == "exit":
        break
    else:
        plot_conditions.append(plot_condition)

plot_conditions_md = ""
for i, condition in enumerate(plot_conditions, start=1):
    plot_conditions_md += f"{i}. {condition}\n"

print(plot_conditions_md)

以下のように対話モードでexitと入力するまで盛り込みたい作品の設定を入力することができる。

$ python main.py
プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): 時代は2100年
プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): SF作品
プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): 主人公は10代後半の男女5人
プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): 群像劇
プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): exit

1. 時代は21002. SF作品
3. 主人公は10代後半の男女54. 群像劇

STEP4:【生成AI】STEP2,3をインプットに新しい物語のあらすじを創作する

以下をインプットに生成AIに物語のあらすじを創ってもらうプロンプトを作成する。

  • STEP2の「銀河鉄道の夜」の物語の構造分析の結果
  • STEP3で入力した物語に盛り込みたい設定
# ...省略
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.prompts.chat import SystemMessagePromptTemplate
from langchain_core.messages import HumanMessage

# ...省略
get_your_plot_prompt_template = ChatPromptTemplate.from_messages(
    [
        SystemMessagePromptTemplate.from_template("""
以下のプロットで物語を生成してください。ただし、与えた設定を含めてください。

# プロット
{plot}
"""),
        HumanMessage(content=plot_conditions_md),
    ]
)

input_plot = ""
for i, event in enumerate(result["episodes"], start=1):
    input_plot += f"{i}. {event}\n"

get_your_plot_prompt = get_your_plot_prompt_template.format(plot=input_plot)
print(get_your_plot_prompt)

以下のようなプロンプトが作成される。

System:
以下のプロットで物語を生成してください。ただし、与えた設定を含めてください。

# プロット
1. 主人公が日常生活において孤独感や疎外感を抱えている。
2. 主人公が特別な出来事や状況に遭遇し、非日常の世界に引き込まれる。
3. 主人公が旅の仲間やガイドと出会い、新たな視点や知識を得る。
4. 主人公が様々な場所や状況を通じて自己理解を深め、内面的な成長を遂げる。
5. 主人公が人生の意味や幸福について考察し、悟りを得る。
6. 主人公が旅を終え、日常生活に戻る。
7. 主人公が旅を通じて得た経験や知識を糧に、より深い人間関係や自己理解を築く。


Human: 1. 時代は2100年
2. SF作品
3. 主人公は10代後半の男女5人
4. 群像劇

このプロンプトをOpenAIのAPIに投げて、最終結果を取得する。

# ...省略
output = model.invoke(get_your_plot_prompt)
print(output)

結果

以下のような物語のあらすじが得られた。

2100年、地球は高度なテクノロジーが日常生活に溶け込んだ未来社会となっていた。情報は瞬時にアクセスでき、人工知能が生活のあらゆる面をサポートしているが、人々は何かを失ったような感覚と共に生きていた。そんな中、10代後半の男女5人、リナ、ケイ、ユウタ、アイリ、そしてマコトは、それぞれ異なる孤独や不安を抱えて日々を過ごしていた。

リナは完璧を求めるあまり、友人との距離を感じていた。ケイは他人と比べることに疲れ、自己嫌悪を抱えていた。ユウタは将来に対する漠然とした不安に苛まれ、アイリは家庭の問題から逃れたいと切望していた。マコトは自分の居場所を見つけられずにいた。

ある日、5人はそれぞれの通う学校で、謎の電子端末を手に入れる。端末に触れた瞬間、不思議な力が働き、彼らは異世界へと導かれる。そこは未来と過去が交錯する不思議な世界で、彼らは自分たちが新たな冒険の主人公であることを知る。

異世界での経験を通じて、5人は次第に自分の内面と向き合うことになる。リナは完璧であることよりも、自分らしさを大切にすることを学び、ケイは他人ではなく、自分自身と向き合う勇気を得る。ユウタは仲間たちと力を合わせることで、未来は自分たちの手で切り拓くことができると気づく。アイリは、逃げるのではなく、自分の力で現実を変えることの重要性を知り、マコトは仲間と共にいることで自分の居場所を見つけることができた。

旅の途中で出会った異世界の住人たちは、5人にさまざまな視点や教えをもたらす。彼らは他者の視点を通じて、自分自身の考えや生き方に変化をもたらしていく。互いに支え合いながら、5人は成長し、心の中にあった問題を少しずつ克服していく。

異世界での冒険が終わりを迎えると、5人は再び現実世界に戻る。彼らは異世界での経験を通じて多くのことを学び取り、それぞれが抱えていた孤独や不安を克服する術を得た。そして、心の平和を見出した5人は、これからの人生に向けて一歩踏み出す。

それぞれの物語が交錯し、群像劇として描かれる彼らの成長は、未来社会に生きる全ての人々にとっての希望となるだろう。

やりたいことはできたが、あまり深みのない物語(個人的な感想)ができてしまった。WEBのChatGPTから同じようなことを試したときにはもう少し面白そうな物語のあらすじを作ってくれた。

WEBのChatGPTは過去のやり取りを記憶しているのに対して、OpenAIのAPIは明示的に過去のやり取りを渡さない限りは、過去のやり取りを記憶しないという仕様の違いがあるので、STEP2の時に「元のインプットが銀河鉄道の夜だった」という情報が失われて、その分単調になってしまったのではないかと考えられる。

あと、他の生成AIは分からないが、少なくともOpenAIは前向きな物語を作りがちな傾向を感じるので、必ずしもハッピーエンドじゃないけど受け手に余韻を与えるような作品を創りたい場合はプロンプトに工夫が必要な気がした。

1プロンプトバージョン

STEP2の時に「元のインプットが銀河鉄道の夜だった」という情報が失われて、その分単調になってしまった

という仮説を検証するために、物語構造を抽象化してもらうというワンクッションを経ずに、1プロンプトで完結するパターンも試してみた。

1プロンプトで完結する場合も、入力した物語の抽象的な構造を踏まえるという方針は変えずにやりたいので、CoT(Chain-of-Thought)というプロンプトエンジニアリングの技法を使って、「ただし、以下のプロセスで作成してください。〜」という指示を入れる。

https://www.promptingguide.ai/jp/techniques/cot

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

client = OpenAI()

input_work = input("創作のインプットにしたい作品名を入力してください: ")

plot_conditions = []
while True:
    plot_condition = input(
        "プロットを生成した作品の設定を好きなだけ入力してください (終了するには 'exit' と入力): "
    )
    if plot_condition.lower() == "exit":
        break
    else:
        plot_conditions.append(plot_condition)

plot_conditions_md = ""
for i, condition in enumerate(plot_conditions, start=1):
    plot_conditions_md += f"{i}. {condition}\n"

completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "あなたは小説家です。"},
        {
            "role": "user",
            "content": f"""
作品のプロットを作成してください。ただし、以下のプロセスで作成してください。
         
# プロセス
1. 例を参考に「{input_work}」の抽象的な物語構造を分析する。
2. 1.で分析した抽象的な物語構造を踏まえる
3. 以下の設定を盛り込む

# 抽象的な物語構造の分析例
「桃太郎」の場合

1. 社会や共同体が危機に直面している。
2. 特別な力や資質を持つ主人公が登場し、問題解決の鍵を握る。
3. 主人公が課題を認識し、目標に向かって行動することを決意する。
4. 主人公が目的達成に必要なリソースや助力を集める。
5. 主人公が敵対者や障害に直面し、それを乗り越える。
6. 主人公が課題を達成し、成果や報酬を得る。
7. 主人公が成果を持ち帰り、社会に還元することで秩序が回復する。         

# 設定
{plot_conditions_md}
""",
        },
    ],
)

print(completion.choices[0].message.content)

同じ入力を渡した場合に得られた結果は以下となった。

1. **孤独や社会からの疎外感**
   時代は2100年、地球は環境破壊により住みづらい惑星になっている。主人公である10代後半の男女5人(アヤ、ソウマ、リア、ケン、ヒトミ)は各々異なる事情で社会から疎外され、希望を見失い始めている。

2. **非日常的な世界への旅**
   ある日、彼らは未来のテクノロジーを駆使した宇宙船「スターライトノクターン号」に乗り込むことになる。この旅は、地球外での新たな居住可能惑星を見つけるための探査と知らされるが、彼らにとっては自己発見の旅でもある。

3. **未知の体験と成長**
   宇宙船の中で、彼らは様々な文明を持つ異星人と出会い、様々な惑星を訪れる。それぞれのエピソードで彼らは、自らの悩みや抱えていた問題の解決策を見つけていく。

4. **人生の洞察**
   旅の中で、彼らは過去の選択や後悔、未来への期待について考えさせられ、様々な智慧を得る。特に、ある惑星で出会った知的生命体からの言葉や、壮大な自然の美しさに触れた時に深い示唆を受ける。

5. **旅の終わりと現実への帰還**
   新しい居住先として探していた惑星が未発見となり、彼らは一度地球に戻ることを決意する。しかし、この旅は彼ら自身を変容させ、彼らはそれぞれ異なる視点で世界を見始める。

6. **新しい理解の獲得**
   現実世界に戻った彼らは、疎外感から解放され、新たな使命感を持つようになる。それは、地球の問題に立ち向かい、未来の世代が生きやすい世界を作ることである。

7. **社会への還元と新たな一歩**
   彼らは冒険で得た洞察をもとに、地球環境の改善、社会の変革、人々の意識改革に力を注ぐ。彼らの活動は次第に周囲に影響を与え、彼ら自身も、仲間との絆を深めながら未来を築くために新たな一歩を踏み出す。

この群像劇は、未来への希望と若者たちの成長を描くSF物語です。

こちらの方が、AIが「銀河鉄道の夜をインプットにした」ことを認識している分、AIが元の銀河鉄道の夜をベースに以下の読み替えをしてくれて物語として深みが出ていると個人的に感じる。

  • 銀河鉄道 → 宇宙船
  • 旅で出会う幻想的な人々 → 宇宙人
  • 旅で目撃する幻想的な景色 → 別の惑星の自然
  • etc...

感想

  • 頑張ってワークフローを組むより、1プロンプトでシンプルにやった方が面白い結果が得られたので、徒労感はあるが、LangChainの勉強にもなったのでよかった
  • 今回の検証ではSTEP3の物語の設定は人間が考えているが、ここも生成AIに物語が面白くなりそうな設定を何パターンか考えてもらったりできるとより面白いことができるのかもしれない
  • 生成された物語の面白さを別のAIが評価して、基準値超えるまでリトライさせるみたいなこともできるのだろうか?



以上の内容はhttps://kamatimaru.hatenablog.com/entry/2025/01/25/173113より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14