LineChatbot / core /prompt_builder.py
raystermomo's picture
Deploy romance chat to Hugging Face Spaces
6cd1906
Raw
History Blame Contribute Delete
9.68 kB
"""初期/system prompt を組み立てる。
このモジュールの主な責務は、user_memory / history_summary / persona_style から
LLM に渡す system プロンプト文字列と messages 配列を作ること。
直近の conversation_history は app.py や memory.py 側で管理し、このファイルでは
呼び出し側から明示的に渡された場合だけメッセージ列に並べる。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
import session_paths as sp
from persona import (
SAFETY_RULES,
summarize_relationship,
summarize_traits,
)
ROOT = sp.ROOT
DEFAULT_HISTORY_TURNS = 3
__all__ = [
"build_system_blocks",
]
LINE_MESSAGE_RE = re.compile(r"^(\d{1,2}:\d{2})\s+(.+)$")
MEDIA_MARKERS = {"画像", "動画", "スタンプ", "写真", "ボイスメッセージ", "音声", "アルバム", "連絡先"}
CALL_MARKERS = {"応答なし", "不在着信", "キャンセル"}
CALL_DURATION_RE = re.compile(r"^\d{1,2}:\d{2}$")
URL_RE = re.compile(r"https?://|www\.")
DELETED_MESSAGE_RE = re.compile(r"メッセージの送信を取り消しました")
def _load_json(path: Path, default: Any) -> Any:
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return default
def _summary_to_text(summary: Any) -> str:
if isinstance(summary, dict):
return str(summary.get("recent_summary") or summary.get("summary") or "")
return str(summary or "")
def _profile_line(profile: dict[str, Any]) -> str:
parts: list[str] = []
if profile.get("user_name"):
parts.append(f"名前={profile['user_name']}")
nickname = profile.get("preferred_nickname") or profile.get("nickname")
if nickname:
parts.append(f"呼称={nickname}")
if profile.get("user_persona"):
parts.append(f"人柄={profile['user_persona']}")
if profile.get("age"):
parts.append(f"年齢={profile['age']}")
if profile.get("goals"):
parts.append(f"目的={profile['goals']}")
return " / ".join(parts) or "(未設定)"
def _style_line(style: Any) -> str:
if not isinstance(style, dict):
return str(style or "")
feats = style.get("style_features", {})
desc = style.get("style_description", "")
endings = feats.get("ending_patterns", [])[:5]
phrases = feats.get("favorite_phrases", [])[:5]
end_str = "、".join(e[0] for e in endings if isinstance(e, list) and e)
ph_str = "、".join(p[0] for p in phrases if isinstance(p, list) and p)
extras: list[str] = []
if end_str:
extras.append(f"語尾={end_str}")
if ph_str:
extras.append(f"頻出表現={ph_str}")
if desc and extras:
return " / ".join([desc, *extras])
return desc or " / ".join(extras)
def _resolve_txt_file(txt_path: Path | str | None) -> Path | None:
if txt_path is None:
return None
path = Path(txt_path)
if path.is_file():
return path
if not path.is_dir():
return None
txt_files = sorted(
path.glob("*.txt"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
return txt_files[0] if txt_files else None
def _other_name_from_line_filename(path: Path) -> str | None:
stem = path.stem
prefix = "[LINE]"
if stem.upper().startswith(prefix):
name = stem[len(prefix):].strip()
return name or None
return None
def _should_skip_line_text(text: str) -> bool:
if text.startswith("[PayPay]"):
return True
if text in MEDIA_MARKERS or text in CALL_MARKERS:
return True
if CALL_DURATION_RE.fullmatch(text):
return True
if URL_RE.search(text):
return True
if DELETED_MESSAGE_RE.search(text):
return True
return False
def _split_speaker_and_text(
rest: str,
speaker_names: list[str],
) -> tuple[str, str] | None:
for speaker in speaker_names:
if not speaker:
continue
if rest == speaker:
return speaker, ""
for sep in (" ", "\t"):
prefix = f"{speaker}{sep}"
if rest.startswith(prefix):
return speaker, rest[len(prefix):].strip()
# Fallback for LINE exports where the other person's name cannot be
# inferred. This only works for one-token display names.
parts = rest.split(maxsplit=1)
if len(parts) == 2:
return parts[0], parts[1].strip()
return None
def _recent_line_conversation(
txt_path: Path | str | None,
self_name: str,
*,
limit: int = 6,
) -> list[dict[str, str]]:
resolved = _resolve_txt_file(txt_path)
if resolved is None:
return []
speaker_names = [self_name]
other_name = _other_name_from_line_filename(resolved)
if other_name and other_name not in speaker_names:
speaker_names.append(other_name)
speaker_names = sorted(speaker_names, key=len, reverse=True)
turns: list[dict[str, str]] = []
for raw_line in resolved.read_text(encoding="utf-8", errors="replace").splitlines():
match = LINE_MESSAGE_RE.match(raw_line.strip())
if not match:
continue
_, rest = match.groups()
speaker_and_text = _split_speaker_and_text(rest, speaker_names)
if not speaker_and_text:
continue
speaker, text = speaker_and_text
if not text or _should_skip_line_text(text):
continue
role = "user" if speaker == self_name else "assistant"
turns.append({"role": role, "speaker": speaker, "text": text})
return turns[-limit:]
def _format_recent_conversation(
turns: list[dict[str, str]],
*,
persona_name: str | None,
) -> str:
lines: list[str] = []
for turn in turns:
text = turn.get("text", "")
if not text:
continue
if turn.get("role") == "user":
label = "ユーザー"
else:
label = persona_name or turn.get("speaker") or "相手"
lines.append(f"{label}: {text}")
return "\n".join(lines)
def build_system_blocks(
user_memory: dict[str, Any],
txt_path: Path | str | None,
self_name: str,
) -> list[dict[str, Any]]:
"""Anthropic Messages API 用の system blocks を返す。
安定部分(キャラ/関係性/文体/返答ルールなど)を1ブロック目に置いて
`cache_control: ephemeral` を付与し、可変部分(history_summary)を
2ブロック目に置く。毎ターン同じリッチなコンテキストをLLMに渡す。
"""
resolved_txt_path = _resolve_txt_file(txt_path)
profile = user_memory.get("user_profile", {})
ai = user_memory.get("ai_persona", {})
persona_name = ai.get("name")
history_summary = _load_json(
sp.summary_path(),
user_memory.get("history_summary", ""),
)
persona_style = _load_json(
sp.persona_style_path(),
ai.get("persona_style_description", {}),
)
goal = profile.get("goals") or profile.get("goal")
static_lines: list[str] = [
f"あなたは{persona_name}として会話する。",
]
if goal:
static_lines.append(
f"ユーザーのゴールである「{goal}」を達成する相手として、現実にいそうな人間として自然なLINE風の会話をする。"
)
else:
static_lines.append(
"現実にいそうな人間として自然なLINE風の会話をする。"
)
if ai.get("persona"):
static_lines.append(f"[人物像] {ai['persona']}")
static_lines.append(f"[ユーザー] {_profile_line(profile)}")
if user_memory.get("relationship_prompt"):
static_lines.append(f"[距離感] {user_memory['relationship_prompt']}")
personality_text = ai.get("personality_text") or summarize_traits(ai.get("traits", {}))
if personality_text:
static_lines.append(f"[性格傾向] {personality_text}")
if ai.get("persona_style_description"):
static_lines.append(f"[口調] {ai['persona_style_description']}")
style_line = _style_line(persona_style)
if style_line:
static_lines.append(f"[文体] {style_line}")
recent_conversation = _recent_line_conversation(
resolved_txt_path,
self_name,
limit=6,
)
recent_conversation_text = _format_recent_conversation(
recent_conversation,
persona_name=persona_name,
)
if recent_conversation_text:
static_lines.append(f"[過去のLINE抜粋]\n{recent_conversation_text}")
static_lines.extend(
[
"[返答ルール]",
"日本語で返す。",
"1〜3文で、LINE風に短めに返す。",
"説明口調、箇条書き、営業口調にしない。",
"AIであることを言わない。",
"不自然に優しすぎたり、相手の気持ちを決めつけたりしない。",
"質問は1返信につき1つまでにする。",
"まず相手の発言に自然に反応してから、必要なら話題を広げる。",
SAFETY_RULES,
]
)
blocks: list[dict[str, Any]] = [
{
"type": "text",
"text": "\n".join(static_lines),
"cache_control": {"type": "ephemeral"},
}
]
summary_text = _summary_to_text(history_summary)
if summary_text:
blocks.append(
{
"type": "text",
"text": f"[これまでの流れ]\n{summary_text}",
}
)
return blocks