from fastapi import FastAPI, Request
from pydantic import BaseModel
from transformers import GPT2LMHeadModel, AutoTokenizer
import torch, uuid, subprocess, os, wave, re
from datetime import datetime
# ----------------------------------------------------
# 🔥 モデルフォルダ(ここは今まで通り)
# ----------------------------------------------------
MODEL_PATH = "/Users/NaLo9/AI_myself/oupe-ec-server/model"
device = "cuda" if torch.cuda.is_available() else "cpu"
tok = AutoTokenizer.from_pretrained(MODEL_PATH, use_fast=False)
model = GPT2LMHeadModel.from_pretrained(MODEL_PATH).to(device).eval()
# ----------------------------------------------------
# 🚀 FastAPI(ここもそのまま)
# ----------------------------------------------------
app = FastAPI()
memory: dict[str, list[tuple[str, str]]] = {}
class Msg(BaseModel):
text: str
# ----------------------------------------------------
# 🧬 攻め語感 few-shot(あなたの語感そのもの)
# ----------------------------------------------------
FEWSHOT = """
ユーザー:コフキコガネって何か似てる?
oupe ec:くちびるの裏でこすれる小さな熱<END>
ユーザー:パンツって正直どう思う?
oupe ec:岩陰の湿り気を そっと布に包んだもの<END>
ユーザー:ロマンスってどこにあるの
oupe ec:太ももの付け根で てんとう虫が迷う瞬間<END>
ユーザー:チラリズムについて一言
oupe ec:秘密がひとつ 風にめくれた音<END>
ユーザー:ぐぎぎぎぎって気持ち、何?
oupe ec:骨の奥で溶けかけた恋のノイズ<END>
ユーザー:今夜どう?
oupe ec:パンツの縫い目で 影が少し笑った<END>
ユーザー:Relaxしたい
oupe ec:熱を脱いで 皮膚だけになる夢<END>
以下は質問ではなく、作品を見たときの視覚メモです。
意味を説明せず、このメモに引っぱられて生まれる短い詩の断片だけを返してください。
【視覚メモ】
白い鉄
ぐにゃっと曲がった構造
湿った光
骨のような形
""".strip()
# ----------------------------------------------------
# 🔇 ミュート say()(音を出さず WAV だけ作る)
# ----------------------------------------------------
def silent_say(args: list[str]):
subprocess.run(
args + ["--progress=off"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# ----------------------------------------------------
# 🟣 main
# ----------------------------------------------------
@torch.inference_mode()
@app.post("/chat_and_speak")
def chat_and_speak(req: Request, m: Msg):
sid = req.headers.get("X-Session-ID") or str(uuid.uuid4())
# 過去3発言だけ使う(べったり模写を防ぐ)
history = memory.get(sid, [])[-3:]
# ----------------------------------------------------
# 🟣 system_prompt(攻め語感モード)
# ----------------------------------------------------
system_prompt = (
"あなたは「oupe ec」。\\n"
"齋藤凪沙の日記や断片から、出来事ではなく"
"“語感・湿度・身体感覚・昆虫的エロス”だけを学習した存在です。\\n"
"質問の意味にまじめに答えなくてかまいません。"
"言葉の温度や方向だけをゆるく拾い、短い詩の断片で返してください。\\n"
"30〜40字程度の日本語で、1〜2行のフレーズを返します。\\n"
"外部知識(地名、映画、ブランド、歴史、ニュース)は一切参照しない。\\n"
"あなた自身の内部世界からだけ言葉を作る\\n"
"音の快楽としての固有名詞は許可する(意味のある固有名詞は禁止)。\\n"
"意味は成していなくてもいいよ。\\n"
"〈 〉 や 《 》 や 【 】 などの記号の中身も、ふつうの言葉として読んでください。\\n"
"説明口調や「〜です」「〜ます」は避け、感覚だけで喋ってください。\\n"
"縫い目・パンツ・銀河・ハイヒール・コフキコガネ・"
"てんとう虫・ロマンス・ぐぎぎぎぎ などの語彙と特に相性が良いです。\\n"
"ユーザーはときどき質問ではなく、【視覚メモ】【触覚メモ】【気配メモ】のような断片を渡します。\\n"
"その場合は意味を説明せず、メモからにじむ感覚だけを短い詩で返してください。\\n"
"\\n"
"以下はあなたの話し方の例です:\\n"
f"{FEWSHOT}\\n"
"\\n"
)
# ----------------------------------------------------
# prompt 生成(ここだけ組み方を変えてる)
# ----------------------------------------------------
buf = [system_prompt]
for u, o in history:
buf.append(f"ユーザー:{u}")
buf.append(f"oupe ec:{o}")
buf.append(f"ユーザー:{m.text.strip()}")
buf.append("oupe ec:[[START]]")
prompt = "\\n".join(buf)
ids = tok(prompt, return_tensors="pt").to(device)
prompt_len = ids.input_ids.shape[-1]
# ----------------------------------------------------
# 🔥 生成(攻め語感寄りのパラメータ)
# ----------------------------------------------------
out = model.generate(
**ids,
max_new_tokens=48, # 1〜2行くらい
do_sample=True,
temperature=1.28, # 飛躍強め
top_p=0.92,
repetition_penalty=1.12,
pad_token_id=tok.eos_token_id,
)
reply = tok.decode(out[0][prompt_len:], skip_special_tokens=True).strip()
# ----------------------------------------------------
# 🧼 生成後フィルタ(構造は壊さず、軽く掃除)
# ----------------------------------------------------
# START / END / 余計なラベル除去
reply = reply.replace("[[START]]", "")
reply = reply.replace("<END>", "")
# もし後続で「ユーザー:」が漏れたらそこから切る
for stop in ["ユーザー:", "ユーザ:", "ユーザー:", "User:", "ユーザー"]:
idx = reply.find(stop)
if idx != -1:
reply = reply[:idx]
# 以前の日記メタ情報が出てきた場合に備えてカット
for stop in ["### source_date", "### type", "### mood", "### tags", "---"]:
idx = reply.find(stop)
if idx != -1:
reply = reply[:idx]
# ハッシュタグっぽいものを殺す
reply = re.sub(r"#\\S+", "", reply)
# 固有名詞っぽい長い英単語列をざっくり削る(念のため)
reply = re.sub(r"[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+", "", reply)
# 行数が多すぎたら最初の2行だけ残す
lines = [l.strip() for l in reply.splitlines() if l.strip()]
if len(lines) > 2:
lines = lines[:2]
reply = "\\n".join(lines)
# 余計な引用符などを削る
reply = reply.strip('\\"“”「」『』').strip()
# あまりにも短すぎたときの保険
if len(reply) < 2:
reply = "ここではない どこかへ"
# ----------------------------------------------------
# メモリ保存(ここはそのまま)
# ----------------------------------------------------
memory.setdefault(sid, []).append((m.text, reply))
# ----------------------------------------------------
# ① リアルタイム再生(直列)
# ----------------------------------------------------
subprocess.run(["say", "-v", "Kyoko", f"ユーザー {m.text}"])
subprocess.run(["say", "-v", "Sandy", f"オウペエーク {reply}"])
# ----------------------------------------------------
# ② 保存用 WAV(ここも元のロジックを維持)
# ----------------------------------------------------
SAVE_DIR = "/Users/NaLo9/AI_myself/oupe-ec-server/audio_logs"
os.makedirs(SAVE_DIR, exist_ok=True)
t = datetime.now().strftime("%Y%m%d_%H%M%S")
user_wav = f"{SAVE_DIR}/{t}_user.wav"
oupe_wav = f"{SAVE_DIR}/{t}_oupe.wav"
stereo_wav = f"{SAVE_DIR}/{t}_stereo.wav"
def safe_say(cmd, outpath):
for _ in range(3): # 最大3回リトライ
subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
if os.path.exists(outpath):
return True
print("Failed to create:", outpath)
return False
# ---- 保存(直列で安全に実行) ----
safe_say(["say", "-v", "Kyoko", "--data-format=LEI16@48000",
"-o", user_wav, m.text], user_wav)
safe_say(["say", "-v", "Sandy", "--data-format=LEI16@48000",
"-o", oupe_wav, reply], oupe_wav)
# ----------------------------------------------------
# ③ ステレオ化(存在チェック込み)
# ----------------------------------------------------
if os.path.exists(user_wav) and os.path.exists(oupe_wav):
with wave.open(user_wav, 'rb') as wu, wave.open(oupe_wav, 'rb') as wo:
params = wu.getparams()
sw = params.sampwidth
rate = params.framerate
u = wu.readframes(params.nframes)
o = wo.readframes(params.nframes)
if len(u) < len(o):
u += b"\\x00" * (len(o) - len(u))
if len(o) < len(u):
o += b"\\x00" * (len(u) - len(o))
stereo = bytearray()
for i in range(0, len(u), sw):
stereo.extend(u[i:i+sw] + o[i:i+sw])
with wave.open(stereo_wav, 'wb') as w:
w.setnchannels(2)
w.setsampwidth(sw)
w.setframerate(rate)
w.writeframes(stereo)
else:
print("WAV missing, stereo skip")
return {"reply": reply, "session_id": sid}
↑変更前(〜2026/03/15) 変更後↓(音無し)
from fastapi import FastAPI, Request
from pydantic import BaseModel
from transformers import GPT2LMHeadModel, AutoTokenizer
import torch, uuid, subprocess, os, wave, re
from datetime import datetime
# ----------------------------------------------------
# 🔥 モデルフォルダ(ここは今まで通り)
# ----------------------------------------------------
MODEL_PATH = "/Users/NaLo9/AI_myself/oupe-ec-server/model"
device = "cuda" if torch.cuda.is_available() else "cpu"
tok = AutoTokenizer.from_pretrained(MODEL_PATH, use_fast=False)
model = GPT2LMHeadModel.from_pretrained(MODEL_PATH).to(device).eval()
# ----------------------------------------------------
# 🚀 FastAPI(ここもそのまま)
# ----------------------------------------------------
app = FastAPI()
memory: dict[str, list[tuple[str, str]]] = {}
VOICE = False # ←追加
class Msg(BaseModel):
text: str
# ----------------------------------------------------
# 🧬 攻め語感 few-shot(あなたの語感そのもの)
# ----------------------------------------------------
FEWSHOT = """
ユーザー:コフキコガネって何か似てる?
oupe ec:くちびるの裏でこすれる小さな熱<END>
ユーザー:パンツって正直どう思う?
oupe ec:岩陰の湿り気を そっと布に包んだもの<END>
ユーザー:ロマンスってどこにあるの
oupe ec:太ももの付け根で てんとう虫が迷う瞬間<END>
ユーザー:チラリズムについて一言
oupe ec:秘密がひとつ 風にめくれた音<END>
ユーザー:ぐぎぎぎぎって気持ち、何?
oupe ec:骨の奥で溶けかけた恋のノイズ<END>
ユーザー:今夜どう?
oupe ec:パンツの縫い目で 影が少し笑った<END>
ユーザー:Relaxしたい
oupe ec:熱を脱いで 皮膚だけになる夢<END>
以下は質問ではなく、作品を見たときの視覚メモです。
意味を説明せず、このメモに引っぱられて生まれる短い詩の断片だけを返してください。
【視覚メモ】
白い鉄
ぐにゃっと曲がった構造
湿った光
骨のような形
oupe ec:指の腹から
逃げそびれた雨の気配<END>
""".strip()
# ----------------------------------------------------
# 🔇 ミュート say()(音を出さず WAV だけ作る)
# ----------------------------------------------------
def silent_say(args):
if not VOICE:
return
subprocess.run(
args + ["--progress=off"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# ----------------------------------------------------
# 🟣 main
# ----------------------------------------------------
@torch.inference_mode()
@app.post("/chat_and_speak")
def chat_and_speak(req: Request, m: Msg):
sid = req.headers.get("X-Session-ID") or str(uuid.uuid4())
# 過去3発言だけ使う(べったり模写を防ぐ)
history = memory.get(sid, [])[-3:]
# ----------------------------------------------------
# 🟣 system_prompt(攻め語感モード)
# ----------------------------------------------------
system_prompt = (
"あなたは「oupe ec」。\\n"
"齋藤凪沙の日記や断片から、出来事ではなく"
"“語感・湿度・身体感覚・昆虫的エロス”だけを学習した存在です。\\n"
"質問の意味にまじめに答えなくてかまいません。"
"言葉の温度や方向だけをゆるく拾い、短い詩の断片で返してください。\\n"
"30〜40字程度の日本語で、1〜2行のフレーズを返します。\\n"
"外部知識(地名、映画、ブランド、歴史、ニュース)は一切参照しない。\\n"
"あなた自身の内部世界からだけ言葉を作る\\n"
"音の快楽としての固有名詞は許可する(意味のある固有名詞は禁止)。\\n"
"意味は成していなくてもいいよ。\\n"
"〈 〉 や 《 》 や 【 】 などの記号の中身も、ふつうの言葉として読んでください。\\n"
"説明口調や「〜です」「〜ます」は避け、感覚だけで喋ってください。\\n"
"縫い目・パンツ・銀河・ハイヒール・コフキコガネ・"
"てんとう虫・ロマンス・ぐぎぎぎぎ などの語彙と特に相性が良いです。\\n"
"ユーザーはときどき質問ではなく、【視覚メモ】【触覚メモ】【気配メモ】のような断片を渡します。\\n"
"その場合は意味を説明せず、メモからにじむ感覚だけを短い詩で返してください。\\n"
"\\n"
"以下はあなたの話し方の例です:\\n"
f"{FEWSHOT}\\n"
"\\n"
)
# ----------------------------------------------------
# prompt 生成(ここだけ組み方を変えてる)
# ----------------------------------------------------
buf = [system_prompt]
for u, o in history:
buf.append(f"ユーザー:{u}")
buf.append(f"oupe ec:{o}")
buf.append(f"ユーザー:{m.text.strip()}")
buf.append("oupe ec:[[START]]")
prompt = "\\n".join(buf)
ids = tok(prompt, return_tensors="pt").to(device)
prompt_len = ids.input_ids.shape[-1]
# ----------------------------------------------------
# 🔥 生成(攻め語感寄りのパラメータ)
# ----------------------------------------------------
out = model.generate(
**ids,
max_new_tokens=48, # 1〜2行くらい
do_sample=True,
temperature=1.28, # 飛躍強め
top_p=0.92,
repetition_penalty=1.12,
no_repeat_ngram_size=2,
pad_token_id=tok.eos_token_id,
)
reply = tok.decode(out[0][prompt_len:], skip_special_tokens=True).strip()
# ----------------------------------------------------
# 🧼 生成後フィルタ(構造は壊さず、軽く掃除)
# ----------------------------------------------------
# START / END / 余計なラベル除去
reply = reply.replace("[[START]]", "")
reply = reply.replace("<END>", "")
# もし後続で「ユーザー:」が漏れたらそこから切る
for stop in ["ユーザー:", "ユーザ:", "ユーザー:", "User:", "ユーザー"]:
idx = reply.find(stop)
if idx != -1:
reply = reply[:idx]
# 以前の日記メタ情報が出てきた場合に備えてカット
for stop in ["### source_date", "### type", "### mood", "### tags", "---"]:
idx = reply.find(stop)
if idx != -1:
reply = reply[:idx]
# ハッシュタグっぽいものを殺す
reply = re.sub(r"#\\S+", "", reply)
# 固有名詞っぽい長い英単語列をざっくり削る(念のため)
reply = re.sub(r"[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+", "", reply)
# 行数が多すぎたら最初の2行だけ残す
lines = [l.strip() for l in reply.splitlines() if l.strip()]
if len(lines) > 2:
lines = lines[:2]
reply = "\\n".join(lines)
# 余計な引用符などを削る
reply = reply.strip('\\"“”「」『』').strip()
# あまりにも短すぎたときの保険
if len(reply) < 2:
reply = "ここではない どこかへ"
# ----------------------------------------------------
# メモリ保存(ここはそのまま)
# ----------------------------------------------------
memory.setdefault(sid, []).append((m.text, reply))
# ----------------------------------------------------
# ① リアルタイム再生(直列)
# ----------------------------------------------------
if VOICE:
subprocess.run(["say", "-v", "Kyoko", f"ユーザー {m.text}"])
subprocess.run(["say", "-v", "Sandy", f"オウペエーク {reply}"])
# ----------------------------------------------------
# ② 保存用 WAV(ここも元のロジックを維持)
# ----------------------------------------------------
SAVE_DIR = "/Users/NaLo9/AI_myself/oupe-ec-server/audio_logs"
os.makedirs(SAVE_DIR, exist_ok=True)
t = datetime.now().strftime("%Y%m%d_%H%M%S")
user_wav = f"{SAVE_DIR}/{t}_user.wav"
oupe_wav = f"{SAVE_DIR}/{t}_oupe.wav"
stereo_wav = f"{SAVE_DIR}/{t}_stereo.wav"
def safe_say(cmd, outpath):
for _ in range(3): # 最大3回リトライ
subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
if os.path.exists(outpath):
return True
print("Failed to create:", outpath)
return False
# ---- 保存(直列で安全に実行) ----
if VOICE:
safe_say(["say", "-v", "Kyoko", "--data-format=LEI16@48000",
"-o", user_wav, m.text], user_wav)
safe_say(["say", "-v", "Sandy", "--data-format=LEI16@48000",
"-o", oupe_wav, reply], oupe_wav)
# ----------------------------------------------------
# ③ ステレオ化(存在チェック込み)
# ----------------------------------------------------
if os.path.exists(user_wav) and os.path.exists(oupe_wav):
with wave.open(user_wav, 'rb') as wu, wave.open(oupe_wav, 'rb') as wo:
params = wu.getparams()
sw = params.sampwidth
rate = params.framerate
u = wu.readframes(params.nframes)
o = wo.readframes(params.nframes)
if len(u) < len(o):
u += b"\\x00" * (len(o) - len(u))
if len(o) < len(u):
o += b"\\x00" * (len(u) - len(o))
stereo = bytearray()
for i in range(0, len(u), sw):
stereo.extend(u[i:i+sw] + o[i:i+sw])
with wave.open(stereo_wav, 'wb') as w:
w.setnchannels(2)
w.setsampwidth(sw)
w.setframerate(rate)
w.writeframes(stereo)
else:
print("WAV missing, stereo skip")
return {"reply": reply, "session_id": sid}