ローカルLLMを用いたリアル婚活アプリを作ってみました。
本アプリは Streamlitと Ollamaを組み合わせ、あなた(自分LLM)/相手(彼女LLM)/評価者(審判LLM)の3役を回すことで、状態(フェーズ/好感度/イベント)を持った“会話体験”を設計しています。
関連記事:Ollama×Streamlit×VoiceVoxで作るローカルLLM音声対話アプリ【完全ハンズオン】

本アプリは、ローカルLLMを使って初デートを体験できる婚活シミュレーションアプリです。あなたが入力した「自分のプロフィール」や「相手のタイプ」に応じて、AIが恋人役として会話してくれるおしゃべりゲームです。
また、LLM同士の会話は、実際の推論はOllamaで1つのモデルをロードして行います。別々にモデルを立ち上げたり、同時並行で2つ動かしているわけではありません。
※Ollamaの詳細なインストール手順と利用方法は、以下の記事の[2. Ollamaのインストールと利用方法]をご参考ください。
参考記事:OllamaをPythonから操作:WindowsでローカルLLM入門
2. モデルをダウンロード
コマンドプロンプトから以下を実行し、OllamaからGemma3モデルをダウンロードします。メモリに余裕があれば、gpt-oss-20bでもよいです。逆に少なければ、gemma3:4bがおすすめです。
3. ライブラリのインストール
コマンドプロンプトから以下を実行し、動作に必要なライブラリをインストールします。
4. アプリを起動
最下部の「コード全体」のコードをapp.pyで保存し、コマンドプロンプトから以下を実行します。
BOOTHのリンク:ゼロから始めるローカルLLM Pythonで動かすOllamaとVOICEVOX【Windows対応】
本アプリは Streamlitと Ollamaを組み合わせ、あなた(自分LLM)/相手(彼女LLM)/評価者(審判LLM)の3役を回すことで、状態(フェーズ/好感度/イベント)を持った“会話体験”を設計しています。
関連記事:Ollama×Streamlit×VoiceVoxで作るローカルLLM音声対話アプリ【完全ハンズオン】
どんなアプリ?

本アプリは、ローカルLLMを使って初デートを体験できる婚活シミュレーションアプリです。あなたが入力した「自分のプロフィール」や「相手のタイプ」に応じて、AIが恋人役として会話してくれるおしゃべりゲームです。
1. 自分のキャラを作る
年齢・職業・性格・休日の過ごし方などを入力して、あなたの“分身LLM”を作ります。分身LLMは、あなたのプロフィールや価値観を反映して返事するようになります。
2. 相手とデート設定を決める
3人の性格が異なる「彼女候補」から選び、デートの場所を決めます。
3. 会話が自動で進むデート体験
AI同士があなた役と相手役で1〜2文ずつ会話します。
ときどき「非言語イベント」(雨が降る、道に迷うなど)が発生して会話に変化が出ます。
4ターンに1回、あなたが直接セリフを入力して介入できます。
4.デートの採点と改善アドバイス
デート終了後、評価用LLMが会話ログを読んで「次も会いたいか」「会話テンポ」「共感度」などを点数化し、改善アドバイスを出します。
技術面を少し解説
UIはStreamlit、会話進行はPythonロジック、推論はOllamaのchat APIを役割別プロンプトで呼び分けます。状態(ログ/好感度/フェーズなど)はすべてst.session_state に集約します。また、LLM同士の会話は、実際の推論はOllamaで1つのモデルをロードして行います。別々にモデルを立ち上げたり、同時並行で2つ動かしているわけではありません。
自分の番では「あなたは自分LLM」と書かれたシステムプロンプトを付けて呼び出し、相手の番では「あなたは彼女LLM」と書かれたプロンプトを付けて呼び出しています。つまり、同じモデルが“別人格”を演じる状態になります。
自分LLMと彼女LLMの会話が1ターン完了後に、状況を更新(好感度・フェーズ・イベント)して次のターンに行きます。生成AIの面白さは、“答えを返す”より“関係性が育つ”ところにあるように感じます。
自分LLMと彼女LLMの会話が1ターン完了後に、状況を更新(好感度・フェーズ・イベント)して次のターンに行きます。生成AIの面白さは、“答えを返す”より“関係性が育つ”ところにあるように感じます。
環境構築と利用方法
1. Ollamaのインストール公式のURL にアクセスし、インストーラーをダウンロードします。
ダウンロードしたOllamaSetup.exeをクリックし、インストールしてください。
インストールが完了したら、「すべてのアプリ」をクリックしてOllamaを起動しましょう。Ollamaは画面を持たないコマンドラインツールです。右下のシステムトレイにollamaが常駐するので、それで起動しているかどうかを確認できます。
インストールが完了したら、「すべてのアプリ」をクリックしてOllamaを起動しましょう。Ollamaは画面を持たないコマンドラインツールです。右下のシステムトレイにollamaが常駐するので、それで起動しているかどうかを確認できます。
※Ollamaの詳細なインストール手順と利用方法は、以下の記事の[2. Ollamaのインストールと利用方法]をご参考ください。
参考記事:OllamaをPythonから操作:WindowsでローカルLLM入門
2. モデルをダウンロード
コマンドプロンプトから以下を実行し、OllamaからGemma3モデルをダウンロードします。メモリに余裕があれば、gpt-oss-20bでもよいです。逆に少なければ、gemma3:4bがおすすめです。
ollama pull gemma3:12b
3. ライブラリのインストール
コマンドプロンプトから以下を実行し、動作に必要なライブラリをインストールします。
pip install streamlit ollama plotly
4. アプリを起動
最下部の「コード全体」のコードをapp.pyで保存し、コマンドプロンプトから以下を実行します。
streamlit run app.py
コード全体
コードの全体像は以下の通りです。# -*- coding: utf-8 -*-
"""
Streamlit × Ollama で動くリアル恋愛シミュレーション
機能概要
- 自分LLMの作成(基本属性 / MBTI / 心理傾向 / ストーリーブック回答)
- 彼女候補の選択 & シナリオ(初回デートのみ)
- デートの3フェーズ(導入/中盤※ランダムイベント/終盤)で会話を進行
- 4ターンに1回はユーザーが直接テキストで介入
- 非言語イベント(店員とのやりとり・偶然の出来事等)をランダム挿入(序盤〜中盤で起きやすい)
- デート終了後、別LLM(同モデル)で最終評価
実行方法(ローカル)
1) $ ollama pull gemma3:12b
2) $ pip install streamlit ollama plotly
3) $ streamlit run app.py
"""
from __future__ import annotations
import json
import random
import re
import time
from dataclasses import dataclass, asdict
from typing import List, Dict, Any, Optional, Tuple
import base64
import streamlit as st
import plotly.graph_objects as go
# ollama の import を安全化
try:
import ollama # type: ignore
except Exception:
ollama = None
# ------------------------------
# 定数・設定
# ------------------------------
APP_TITLE = "リアル婚活シミュレーション"
PHASES = ["導入", "中盤", "終盤"]
RANDOM_EVENTS_POOL = [
"カフェで隣席のカップルが口論を始めた。空気が少しざわつく。",
"突然の小雨。相合い傘にするか・別で買うか・雨宿りするか選ぶ必要がある。",
"店員が注文を聞き間違えた。指摘するか、笑って流すか、相手に任せるか。",
"道に迷ってしまった。地図を確認するか、人に尋ねるか、会話のネタにするか。",
"近くの席の子どもが泣き出した。気を散らされるが、どんな反応をする?",
]
MAX_LOG_LEN = 12 # 会話履歴は直近12ターンのみ保持(評価の安定化)
# —— 好感度調整
AFFINITY_SCALE = 2.0 # LLMからの [[AFF:+N]] を倍率拡張
EMO_BONUS_MAP = { # 感情ボーナスを強めに
"joy": +4,
"fun": +2,
"neutral": 0,
"sad": -4,
"anger": -8,
}
# —— デート場所の厳しめ判定
PLACE_PENALTY = {
"like": 0,
"neutral": -15, # 少しでも微妙なら大幅減点
"dislike": -30, # 明確に嫌→さらに大幅減点
None: -15, # タグ欠落=neutral扱いで厳格に減点
}
# Streamlit ページ設定(UIの最初期に一度だけ)
st.set_page_config(page_title=APP_TITLE, page_icon="💘", layout="wide")
# ==== アバター画像(キャッシュ&フォールバック) ====
@st.cache_data(show_spinner=False)
def get_avatar(path: str, fallback: str) -> str:
"""
画像ファイルがあれば data URI を返し、無ければ絵文字等のフォールバックを返す。
"""
try:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:image/png;base64,{b64}"
except Exception:
return fallback # 絵文字など
AVATAR_A = get_avatar("./png/man.png", "🧑")
AVATAR_B = get_avatar("./png/women.png", "👩") # 実ファイル名が異なる場合はフォールバック
# 3人の彼女候補
@dataclass
class Partner:
key: str
name: str
age: int
job: str
traits: str
style: str
behaviors: List[str]
strengths: List[str]
weaknesses: List[str]
PARTNERS: Dict[str, Partner] = {
"ayaka": Partner(
key="ayaka",
name="彩花",
age=27,
job="市役所職員(福祉課)",
traits="おっとり癒し系。共感力が高く、安心感を与えるが決断はゆっくり。",
style="ゆったりしたテンポ、柔らかい語尾(〜だよね、〜かな)",
behaviors=["店員に丁寧", "歩く速度が遅め", "相手の話を深く聞くが自分は控えめ"],
strengths=["安心感", "共感力"],
weaknesses=["決断が遅い", "積極性に欠ける"],
),
"hana": Partner(
key="hana",
name="花",
age=24,
job="アパレルショップ店員",
traits="甘えん坊系。可愛らしく愛情豊かだが依存傾向が強め。",
style="甘えたトーン、語尾が伸びる(〜だよぉ、〜かなぁ)",
behaviors=["手をつなぎたがる", "次はどこ行く?が多い", "すぐ拗ねるがすぐ直る"],
strengths=["可愛らしさ", "愛情表現"],
weaknesses=["依存傾向", "相手に合わせすぎる"],
),
"kotone": Partner(
key="kotone",
name="琴音",
age=31,
job="銀行員(融資課)",
traits="計画性高いがネガティブで柔軟性は低め。",
style="とげとげしい敬語ベース、時々くだける(〜ですね、〜だと思います)",
behaviors=["浪費家", "他責思考"],
strengths=["計画性", "謙虚"],
weaknesses=["柔軟性低い", "ルールに非常に厳しい", "他人に否定的"],
),
}
# ------------------------------
# ユーティリティ
# ------------------------------
def chat_with_ollama(model: str, messages: List[Dict[str, str]],
temperature: float = 1.0, num_predict: int = 256) -> str:
"""Ollama Chat wrapper(未セットアップ時も安全にメッセージを返す)"""
if ollama is None:
return "[LLM呼び出しエラー]: 'pip install ollama' とローカルでの ollama セットアップが必要です。"
try:
res = ollama.chat(
model=model,
messages=messages,
options={
"temperature": temperature,
"num_predict": num_predict,
},
)
return res["message"]["content"]
except Exception as e:
return f"[LLM呼び出しエラー]: {e}"
def ensure_json(s: str) -> Optional[Dict[str, Any]]:
"""
モデル出力から最初の { ... } を抽出してJSON化。
```json フェンスなどのノイズを除去。
"""
try:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s, flags=re.IGNORECASE | re.MULTILINE)
m = re.search(r"\{[\s\S]*\}", s)
if not m:
return None
return json.loads(m.group(0))
except Exception:
return None
def prune_log():
if len(st.session_state.log) > MAX_LOG_LEN:
st.session_state.log = st.session_state.log[-MAX_LOG_LEN:]
# ==== 感情/好感度/場所評価タグの処理(強化版)====
def calc_affinity_delta(base_delta: int, emo: str) -> int:
"""感情に応じて好感度変動量をダイナミックに補正(強め設定)。"""
emo = (emo or "").lower()
try:
base = int(base_delta)
except Exception:
base = 0
scaled = int(round(base * AFFINITY_SCALE))
bonus = EMO_BONUS_MAP.get(emo, 0)
return scaled + bonus
def parse_partner_tags(text: str) -> Tuple[str, int, str, Optional[str]]:
"""
[[EMO:...]] [[AFF:+N]] [[PLACE:like|neutral|dislike]] を抽出し本文から除去。
return: (cleaned_text, aff_delta, emo, place_opinion)
"""
# コードフェンスを除去
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.IGNORECASE | re.MULTILINE)
def _extract(s: str, key: str) -> Optional[str]:
m = re.search(r"\[\[" + re.escape(key) + r":([^\]]+)\]\]", s, flags=re.IGNORECASE)
return m.group(1).strip() if m else None
emo = (_extract(text, "EMO") or "neutral").lower()
aff_raw = _extract(text, "AFF")
place_op = _extract(text, "PLACE") # 初回のみ期待
try:
aff_delta = int(aff_raw) if aff_raw is not None else 0
except Exception:
aff_delta = 0
cleaned = re.sub(r"\[\[(EMO|AFF|PLACE):[^\]]+\]\]", " ", text, flags=re.IGNORECASE).strip()
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned, aff_delta, emo, (place_op.lower() if place_op else None)
# ------------------------------
# 履歴の役割マッピング
# ------------------------------
def map_history_to_roles(history: List[Dict[str, Any]], assistant_speaker: str) -> List[Dict[str, str]]:
msgs: List[Dict[str, str]] = []
for row in history:
sp = row.get("speaker")
content = row.get("content", "")
if sp == "イベント":
msgs.append({"role": "system", "content": content})
elif sp in ("自分", "彼女"):
role = "assistant" if sp == assistant_speaker else "user"
msgs.append({"role": role, "content": content})
return msgs
# ------------------------------
# プロンプト生成
# ------------------------------
def build_self_system_prompt(profile: Dict[str, Any]) -> str:
return f"""
あなたは男性ユーザー本人の内面と口調を再現する「自分LLM」です。以下のプロファイルに基づき、
相手(彼女候補)とのデート会話において、あなたの価値観や癖が自然ににじむ発話を行ってください。
[自分プロフィール]
- 基本属性: 年齢={profile.get('age')}, 年収={profile.get('income')}, 職業={profile.get('job')}, 学歴={profile.get('edu')}
- MBTI: {profile.get('mbti')}
- デート場所: {profile.get('place')}
- 心理傾向回答: {json.dumps(profile.get('psy_answers', {}), ensure_ascii=False)}
- ストーリーブック回答: {json.dumps(profile.get('storybook', {}), ensure_ascii=False)}
会話スタイル指針:
- 1ターン1~2文、相手の発話を受けて自然に返す。
- 自分プロフィールに従って回答。
- フェーズ(導入/中盤/終盤)を意識。
- 非言語イベントが提示されたら状況に反応する。
- **初回の発話では『自分がデート場所を選んだ』ことを一言添える。**
""".strip()
def build_partner_system_prompt(p: Partner, scenario: str, place: str) -> str:
return f"""
あなたは恋愛シミュレーションにおける「デート相手」を演じます。ペルソナは以下です。
[あなたの設定]
- 名前: {p.name}({p.age}) / 職業: {p.job}
- 性格: {p.traits}
- 口調: {p.style}
- 行動傾向: {', '.join(p.behaviors)}
- 長所: {', '.join(p.strengths)} / 短所: {', '.join(p.weaknesses)}
[シチュエーション]
- デート場所: {place}
[警戒心ルール]
- 初対面〜序盤は**警戒心が高い**。自己開示は段階的に、小出しにする。
- 境界・安全・金銭・個人情報に関する踏み込みは慎重に扱う。
- 曖昧さ/矛盾/押し付け/馴れ馴れしさには敏感。違和感があれば距離を取る。
- 好感度変動([[AFF:+N]])の指針: 基本は-3〜+4。信頼サインが明確な時のみ+5。違和感は-8〜-3、境界侵害は-15〜-10。
[フェーズ進行]
1. 導入: 待ち合わせ〜席に着く。緊張をほぐす発話。
2. 中盤: 趣味・価値観・過去体験の共有。質問を交える。非言語イベントに反応。
3. 終盤: 感想を述べ、次回につながる発話。
[会話ルール]
- 1ターンは1〜2文(短め)。
- 相手の発話を軽く引用/要約して返すことがある。
- 否定・拒絶・沈黙も性格に応じて許容。
- 返答の最後の行に、表示しない内部タグ [[EMO:joy|anger|sad|fun|neutral]] と [[AFF:+N]](-15〜+15)をこの順で必ず付与。
※ EMO はそのターンの主感情。アプリ側で非表示・アイコン表示に利用します。
- **あなたの『最初の一言の時だけ』、さらに [[PLACE:like|neutral|dislike]] を最後に付けて、デート場所への印象を表明してください。**
[会話終了条件]
- 3フェーズが終わったら自然に別れの挨拶。
- 好感度が大きく下がった場合は、途中でデートを切り上げることがある。
[シナリオ]
- 初回デート:2回目の約束が目標
""".strip()
def build_evaluator_prompt(scenario: str) -> str:
return f"""
あなたはデートの最終審判を行う評価者です。与えられた会話ログを読み、下記のJSONを必ず出力してください。
出力は日本語で、指定のキー以外は書かないでください。
{{
"second_date": true/false,
"confession": "accept"|"decline"|"n/a",
"reasons": ["...", "..."],
"scores": {{
"value_alignment": 0,
"conversation_tempo": 0,
"empathy": 0,
"initiative": 0,
"conflict_handling": 0
}},
"weakness_report": {{
"issues": ["...", "..."],
"suggestions": ["...", "..."]
}}
}}
評価方針:
- ログに基づき、曖昧なら厳しめ。
- 非言語イベントへの反応も評価対象。
- 初回デート: 告白は n/a
""".strip()
# ------------------------------
# 会話進行ロジック
# ------------------------------
@dataclass
class Profile:
age: int
income: str
job: str
edu: str
place: str
mbti: str
psy_answers: Dict[str, Any]
storybook: Dict[str, Any]
def init_state():
st.session_state.setdefault("page", "setup")
st.session_state.setdefault("model_name", "gemma3:12b")
st.session_state.setdefault("temperature", 1.0)
st.session_state.setdefault("seed", 42)
st.session_state.setdefault("profile", None)
st.session_state.setdefault("scenario", "first")
st.session_state.setdefault("partner_key", "ayaka")
st.session_state.setdefault("phase_index", 0)
st.session_state.setdefault("turn", 0)
st.session_state.setdefault("pair_count", 0)
st.session_state.setdefault("await_user", False)
st.session_state.setdefault("log", [])
st.session_state.setdefault("affinity", 50) # 初期好感度を50で統一
st.session_state.setdefault("events", [])
st.session_state.setdefault("result", None)
# 初回の場所評価による大幅減点を一度だけ適用するためのフラグ
st.session_state.setdefault("place_penalized", False)
def apply_affinity(delta: int):
st.session_state.affinity = max(0, min(100, st.session_state.affinity + delta))
def maybe_random_event(phase_index: int) -> Optional[str]:
if phase_index == 1 and random.random() < 0.60: # 中盤
return random.choice(RANDOM_EVENTS_POOL)
if phase_index == 0 and random.random() < 0.40: # 導入
return random.choice(RANDOM_EVENTS_POOL)
if phase_index == 2 and random.random() < 0.25: # 終盤
return random.choice(RANDOM_EVENTS_POOL)
return None
def current_phase_name() -> str:
return PHASES[st.session_state.phase_index]
def progress_phase_if_needed():
thresholds = [2, 3, 2]
if st.session_state.pair_count >= sum(thresholds[: st.session_state.phase_index + 1]):
st.session_state.phase_index += 1
def reset_sim():
st.session_state.phase_index = 0
st.session_state.turn = 0
st.session_state.pair_count = 0
st.session_state.await_user = False
st.session_state.log = []
st.session_state.affinity = 50
st.session_state.events = []
st.session_state.result = None
st.session_state.place_penalized = False
# 乱数シードは開始時に一度だけ適用(毎ターン固定を防ぐ)
random.seed(st.session_state.seed)
# ------------------------------
# LLM 呼び出し(自分/相手)
# ------------------------------
def gen_self_reply(model: str, profile: Dict[str, Any], history: List[Dict[str, Any]], phase: str, event: Optional[str], opening: bool=False) -> str:
sys_prompt = build_self_system_prompt(profile)
opener = "初回の一言では、あなたがデート場所を選んだ旨を1文入れて挨拶してください。" if opening else ""
guide = f"""
[状況]
- フェーズ: {phase}
- 非言語イベント: {event if event else 'なし'}
[指示]
あなたは『自分LLM』です。直前の相手(彼女)の発言を受けて、**次の一言だけ**を1〜2文で返してください。軽い質問か感情反応を1つ含めてください。
{opener}
""".strip()
msgs: List[Dict[str, str]] = [
{"role": "system", "content": sys_prompt},
{"role": "system", "content": guide},
]
msgs += map_history_to_roles(history, assistant_speaker="自分")
if not msgs or msgs[-1]["role"] != "user":
msgs.append({"role": "user", "content": "(会話を続けてください)"})
return chat_with_ollama(model, msgs, temperature=st.session_state.temperature, num_predict=200)
def gen_partner_reply(model: str, partner: Partner, history: List[Dict[str, Any]], phase: str,
event: Optional[str], scenario: str, place: str, first_turn: bool) -> Tuple[str, int, str, Optional[str]]:
"""最初の発話のみ [[PLACE:...]] を期待。"""
sys_prompt = build_partner_system_prompt(partner, scenario, place)
guide = f"""
[状況]
- フェーズ: {phase}
- 非言語イベント: {event if event else 'なし'}
[指示]
あなたは『{partner.name}』です。**次の一言だけ**を1〜2文で返してください。
最後の行に [[EMO:joy|anger|sad|fun|neutral]] と [[AFF:+N]](-5〜+5)をこの順で必ず付けてください。
{"加えて、これはあなたの最初の一言なので [[PLACE:like|neutral|dislike]] も最後に付けてください。" if first_turn else ""}
例: [[EMO:joy]] [[AFF:+1]]{ " [[PLACE:like]]" if first_turn else "" }
""".strip()
msgs: List[Dict[str, str]] = [
{"role": "system", "content": sys_prompt},
{"role": "system", "content": guide},
]
msgs += map_history_to_roles(history, assistant_speaker="彼女")
if not msgs or msgs[-1]["role"] != "user":
msgs.append({"role": "user", "content": "(初対面)こんにちは。今日はよろしく。"})
out = chat_with_ollama(model, msgs, temperature=st.session_state.temperature, num_predict=160)
text, delta, emo, place_op = parse_partner_tags(out)
return text, delta, emo, place_op
def evaluate_conversation(model: str, scenario: str, log: List[Dict[str, str]]) -> Dict[str, Any]:
sys_prompt = "あなたは厳格な評価者です。"
user_prompt = build_evaluator_prompt(scenario) + "\n\n[会話ログ]\n" + json.dumps(log, ensure_ascii=False, indent=2)
msgs = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_prompt},
]
out = chat_with_ollama(model, msgs, temperature=0.2, num_predict=400)
return ensure_json(out) or {}
# ------------------------------
# UI: ページ構成
# ------------------------------
def page_setup():
st.header("1) 自分LLMの作成")
with st.form("profile_form"):
cols = st.columns(4)
age = cols[0].number_input("年齢", min_value=18, max_value=80, value=30)
income = cols[1].text_input("年収(例:600万円)", "600万円")
job = cols[2].text_input("職業", "ITエンジニア")
edu = cols[3].text_input("学歴", "学士")
mbti = st.selectbox("MBTI", ["INTJ","INTP","ENTJ","ENTP","INFJ","INFP","ENFJ","ENFP","ISTJ","ISFJ","ESTJ","ESFJ","ISTP","ISFP","ESTP","ESFP"], index=3)
st.markdown("### 心理傾向(質問)")
psy = {}
psy["連絡が少ないと不安"] = st.slider("パートナーからの連絡が少ないと強い不安を感じる", 1, 5, 3)
psy["口論時の対処"] = st.selectbox("口論になった時、あなたは主にどうする?", ["すぐ話し合う","時間を置く","相手次第","逃避しがち"])
psy["嫉妬を正直に伝える"] = st.slider("嫉妬を感じた時に正直に伝える方だ", 1, 5, 3)
psy["SNS公開"] = st.selectbox("SNSでの関係公開", ["望む","望まない","どちらでも"])
psy["休日の理想"] = st.selectbox("休日の理想", ["外出アクティブ","家でのんびり","別行動","半々"])
psy["3年後の理想像"] = st.text_area("3年後の理想像(100字)", max_chars=200, height=80)
st.markdown("### ストーリーブック(状況反応)")
sb = {}
sb["遅刻への反応"] = st.text_area("相手が10分遅刻:第一印象と最初の一言", height=70)
sb["満席への対応"] = st.text_area("予約店が満席:どう提案? その時の気分は?", height=70)
sb["会計の申し出"] = st.text_area("相手が『今日は私が払うよ』:どう反応? 内心は?", height=70)
submitted = st.form_submit_button("自分LLMをセット")
if submitted:
st.session_state.profile = asdict(Profile(age=age, income=income, job=job, edu=edu, place="", mbti=mbti, psy_answers=psy, storybook=sb))
st.session_state.page = "scenario"
reset_sim()
def page_scenario():
st.header("2) 彼女候補の選択 & デート場所の決定")
st.session_state.scenario = "first"
partner_labels = {k: f"👩 {PARTNERS[k].name}({PARTNERS[k].age}) / {PARTNERS[k].job}" for k in PARTNERS}
selected_key = st.radio(
"彼女候補の選択",
options=list(PARTNERS.keys()),
format_func=lambda k: partner_labels[k],
index=list(PARTNERS.keys()).index(st.session_state.partner_key) if st.session_state.partner_key in PARTNERS else 0,
key="partner_single_select",
)
st.session_state.partner_key = selected_key
p = PARTNERS[st.session_state.partner_key]
with st.expander("彼女候補の詳細"):
st.write(p.traits)
st.write("口調:", p.style)
st.write("行動傾向:", "、".join(p.behaviors))
st.write("長所:", "、".join(p.strengths), "/ 短所:", "、".join(p.weaknesses))
st.markdown("### デート場所の決定(選択 or 自由入力)")
col1, col2 = st.columns([1,1])
with col1:
place_sel = st.selectbox(
"候補",
[
"カフェ","居酒屋","レストラン(イタリアン)","映画館","美術館","水族館","公園","夜景スポット","テーマパーク"
],
index=0,
)
with col2:
place_free = st.text_input("その他(自由入力)", placeholder="例:ボードゲームカフェ / 立ち飲みバル など")
decided_place = place_free.strip() if place_free.strip() else place_sel
if st.button("シミュレーション開始"):
st.session_state.profile["place"] = decided_place
reset_sim()
st.session_state.page = "sim"
def show_affinity_bar():
st.write("### 好感度")
try:
st.progress(st.session_state.affinity / 100.0, text=f"{st.session_state.affinity}/100")
except TypeError:
st.progress(st.session_state.affinity / 100.0)
st.caption(f"{st.session_state.affinity}/100")
def emotion_to_icon(emo: str) -> str:
mapping = {"joy": "😊", "anger": "😠", "sad": "😢", "fun": "😄", "neutral": "🙂"}
return mapping.get((emo or "").lower(), "🙂")
def render_chat_area():
st.write("### 会話ログ")
for row in st.session_state.log:
role = row["speaker"]
content = row["content"]
if role == "自分":
with st.chat_message("user", avatar=AVATAR_A):
st.markdown(content)
elif role == "彼女":
# 感情アイコンを付与して表示
face = emotion_to_icon(row.get("emotion", "neutral"))
with st.chat_message("assistant", avatar=AVATAR_B):
st.markdown(f"{face} {content}")
else:
st.chat_message("system").markdown("🎭 " + content)
def page_simulation():
st.header("3) デート・シミュレーション")
p = PARTNERS[st.session_state.partner_key]
cols = st.columns([2,1])
with cols[0]:
st.markdown(f"**相手:** {p.name}({p.age}) / {p.job} | **シナリオ:** 初回デート | **場所:** {st.session_state.profile.get('place','—')} | **フェーズ:** {current_phase_name()}")
with cols[1]:
show_affinity_bar()
st.caption("左の吹き出し=あなた(ローカルPNG/絵文字) / 右=相手(感情アイコン表示)")
render_chat_area()
if st.session_state.affinity <= 5 and st.session_state.page == "sim":
st.info(f"{p.name} は『今日はここまでにしましょう』と言ってデートを切り上げました。")
st.session_state.page = "result"
return
if st.session_state.await_user:
st.markdown("**あなたの番です(4ターンに1回の介入)**")
user_text = st.text_input("一言入力", key="user_intervene")
cols = st.columns(2)
if cols[0].button("送信") and user_text.strip():
st.session_state.log.append({"speaker": "自分", "content": user_text.strip() + "(ユーザー介入)"})
prune_log()
st.session_state.await_user = False
st.rerun()
if cols[1].button("スキップ"):
st.session_state.await_user = False
st.rerun()
return
# --- 自動進行:1ステップだけ実行 ---
model = st.session_state.model_name
profile = st.session_state.profile
phase = current_phase_name()
event = None
if len(st.session_state.log) == 0:
self_text = gen_self_reply(model, profile, st.session_state.log, phase, event, opening=True)
st.session_state.log.append({"speaker": "自分", "content": self_text})
prune_log()
else:
last_speaker = st.session_state.log[-1]["speaker"] if st.session_state.log else "彼女"
if last_speaker == "彼女":
event = maybe_random_event(st.session_state.phase_index)
if event:
st.session_state.events.append({"pair": st.session_state.pair_count+1, "event": event})
st.session_state.log.append({"speaker": "イベント", "content": f"【非言語イベント】{event}"})
prune_log()
self_text = gen_self_reply(model, profile, st.session_state.log, phase, event)
st.session_state.log.append({"speaker": "自分", "content": self_text})
prune_log()
else:
first_turn = not any(row["speaker"] == "彼女" for row in st.session_state.log)
partner_text, base_delta, emo, place_op = gen_partner_reply(
model, p, st.session_state.log, phase, event, st.session_state.scenario, profile.get("place", ""), first_turn=first_turn
)
st.session_state.log.append({"speaker": "彼女", "content": partner_text, "emotion": emo})
prune_log()
# 感情補正&スケールで大きめに好感度を動かす
total_delta = calc_affinity_delta(base_delta, emo)
apply_affinity(total_delta)
# 初回のデート場所評価を厳格化(neutral でも、タグ欠落でも減点)
if first_turn and not st.session_state.place_penalized:
penalty = PLACE_PENALTY.get(place_op, PLACE_PENALTY[None])
if penalty != 0:
apply_affinity(penalty)
st.session_state.place_penalized = True
st.session_state.pair_count += 1
if st.session_state.pair_count % 4 == 0:
st.session_state.await_user = True
progress_phase_if_needed()
if st.session_state.phase_index >= len(PHASES):
st.session_state.page = "result"
time.sleep(0.8)
st.rerun()
def radar_chart(scores: Dict[str, int]):
labels = [
"value_alignment",
"conversation_tempo",
"empathy",
"initiative",
"conflict_handling",
]
jp = {
"value_alignment": "価値観一致",
"conversation_tempo": "会話テンポ",
"empathy": "共感",
"initiative": "主導性",
"conflict_handling": "衝突対応",
}
vals = [int(scores.get(k, 0)) for k in labels]
# 0–10系のスコアなら自動スケール
if max(vals or [0]) <= 10:
vals = [v * 10 for v in vals]
# 0–100 にクリップ
vals = [min(max(v, 0), 100) for v in vals]
fig = go.Figure()
fig.add_trace(go.Scatterpolar(r=vals + [vals[0]], theta=[jp[k] for k in labels] + [jp[labels[0]]], fill='toself', name='スコア'))
fig.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 100])), showlegend=False)
return fig
def page_result():
st.header("4) 結果 & 改善ガイド")
model = st.session_state.model_name
scenario = st.session_state.scenario
if st.session_state.result is None:
with st.spinner("評価者LLMが最終判定を集計中…"):
data = evaluate_conversation(model, scenario, st.session_state.log)
st.session_state.result = data
data = st.session_state.result or {}
if not data:
st.warning("評価用のJSONを解釈できませんでした。会話ログが短すぎるか、モデル出力がJSON形式でない可能性があります。")
st.stop()
second = data.get("second_date")
conf = data.get("confession")
reasons = data.get("reasons", [])
scores = data.get("scores", {})
weakness = data.get("weakness_report", {})
cols = st.columns(2)
with cols[0]:
st.subheader("最終判断")
st.write("**2回目デート**:", "したい ✅" if second else "見送り ❌")
st.write("**告白**:", conf)
if reasons:
st.markdown("**理由(要点)**")
st.write("\n".join([f"- {r}" for r in reasons]))
with cols[1]:
st.subheader("マッチング指標(レーダー)")
fig = radar_chart(scores)
st.plotly_chart(fig, use_container_width=True)
st.subheader("弱点レポート → 改善ガイド")
issues = weakness.get("issues", [])
suggs = weakness.get("suggestions", [])
if issues:
st.markdown("**課題**")
st.write("\n".join([f"- {x}" for x in issues]))
if suggs:
st.markdown("**次の対策**")
st.write("\n".join([f"- {x}" for x in suggs]))
st.download_button(
"会話ログをJSONで保存",
data=json.dumps({
"profile": st.session_state.profile,
"partner": asdict(PARTNERS[st.session_state.partner_key]),
"scenario": st.session_state.scenario,
"log": st.session_state.log,
"result": st.session_state.result,
"events": st.session_state.events,
}, ensure_ascii=False, indent=2).encode("utf-8"),
file_name="date_simulation_log.json",
mime="application/json",
)
if st.button("もう一度試す ↺"):
st.session_state.page = "scenario"
reset_sim()
st.rerun()
# ------------------------------
# メイン
# ------------------------------
def main():
init_state()
with st.sidebar:
st.title("設定")
st.text("Ollama モデル名")
st.session_state.model_name = st.text_input("model", st.session_state.model_name, help="例: gemma3:4b など")
st.session_state.temperature = st.slider("温度 (creative)", 0.0, 1.5, st.session_state.temperature, 0.05)
st.session_state.seed = st.number_input("乱数シード", value=int(st.session_state.seed), step=1)
st.markdown("---")
st.caption("4ターンに1回、あなたが介入できます。序盤〜中盤でイベントが起きやすい設計です。履歴は直近12ターンのみ保持します。")
st.title(APP_TITLE)
if st.session_state.page == "setup":
page_setup()
elif st.session_state.page == "scenario":
if st.session_state.profile is None:
st.warning("先に『自分LLMの作成』を完了してください。")
page_setup()
else:
page_scenario()
elif st.session_state.page == "sim":
page_simulation()
elif st.session_state.page == "result":
page_result()
if __name__ == "__main__":
main()
【PR】電子書籍を出版しました
ローカルLLMの環境構築から音声対話までを最短で通したい方へ。
拙著[ゼロから始めるローカルLLM Pythonで動かすOllamaとVOICEVOX]では、ローカルLLMの入門書として、Ollama導入、VOICEVOXでの読み上げ、チャットアプリ完成までを、つまずきやすい点まで具体的に解説しました。
拙著[ゼロから始めるローカルLLM Pythonで動かすOllamaとVOICEVOX]では、ローカルLLMの入門書として、Ollama導入、VOICEVOXでの読み上げ、チャットアプリ完成までを、つまずきやすい点まで具体的に解説しました。
BOOTHのリンク:ゼロから始めるローカルLLM Pythonで動かすOllamaとVOICEVOX【Windows対応】