"""初期/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