prompts.py 8.17 KB
# -*- coding: utf-8 -*-
"""
音乐分析提示词模板构建器
支持从外部模板文件读取提示词,便于动态修改
"""

import os
from pathlib import Path
from typing import Dict, Any, Optional


# 模板文件路径(已迁移到 app/prompts/step2_music_decode)
PROMPTS_DIR = Path(__file__).parent.parent.parent / "prompts" / "step2_music_decode"
SYSTEM_PROMPT_FILE = PROMPTS_DIR / "music_analyze_system_prompt.md"
SYSTEM_PROMPT_PART_A_FILE = PROMPTS_DIR / "music_analyze_system_prompt_part_a.md"
SYSTEM_PROMPT_PART_B_FILE = PROMPTS_DIR / "music_analyze_system_prompt_part_b.md"
USER_PROMPT_FILE = PROMPTS_DIR / "music_analyze_user_prompt.md"
LYRICS_ONLY_PROMPT_FILE = PROMPTS_DIR / "music_lyrics_only_prompt.md"


def load_template(template_path: Path) -> str:
    """
    从文件加载模板

    Args:
        template_path: 模板文件路径

    Returns:
        模板内容字符串
    """
    if not template_path.exists():
        raise FileNotFoundError(f"模板文件不存在: {template_path}")

    with open(template_path, "r", encoding="utf-8") as f:
        content = f.read()

    # 只移除文件顶部的 Markdown 注释(以 # 开头的注释行)
    # 保留 ## 标题行和正文内容
    lines = content.split("\n")
    filtered_lines = []
    in_header = True

    for line in lines:
        stripped = line.strip()
        # 如果是空行,保留
        if not stripped:
            filtered_lines.append(line)
            continue

        # 如果在文件头部且是单行注释(# 但不是 ##),则跳过
        if in_header and stripped.startswith("#") and not stripped.startswith("##"):
            continue

        # 遇到 ## 标题或正文内容,不再是头部
        in_header = False
        filtered_lines.append(line)

    return "\n".join(filtered_lines)


class PromptBuilder:
    """音乐分析提示词模板构建器"""

    def __init__(self, label_level: int = 0):
        """
        初始化提示词构建器

        Args:
            label_level: 标签级别(0: 一级标签, 1: 一级+二级标签)
        """
        self.label_level = label_level

    def build_system_prompt(self) -> str:
        """构建系统提示词 - 直接加载静态模板"""
        return load_template(SYSTEM_PROMPT_FILE)

    def build_system_prompt_part_a(self) -> str:
        """构建系统提示词A组"""
        return load_template(SYSTEM_PROMPT_PART_A_FILE)

    def build_system_prompt_part_b(self) -> str:
        """构建系统提示词B组"""
        return load_template(SYSTEM_PROMPT_PART_B_FILE)

    def build_metadata_section(self, metadata: Optional[Dict[str, Any]] = None) -> str:
        """构建元数据部分"""
        if not metadata:
            return ""

        sections = ["## 音乐元数据"]
        for key, value in metadata.items():
            if key.startswith("_"):
                continue
            if value and str(value).strip():
                sections.append(f"- {key}: {value}")
        sections.append("")
        return "\n".join(sections)

    def build_output_format(
        self,
        include_lyrics: bool = False,
        include_bpm: bool = True,
    ) -> str:
        """构建输出格式说明"""
        if include_lyrics and include_bpm:
            format_spec = """{
  "genre": "",
  "sub_genre": "",
  "language": "",
  "vocal_type": "",
  "vocal_description": "",
  "emotion": [""],
  "scene": [""],
  "age": "",
  "rhythm_intensity": "",
  "is_sinking": false,
  "song_description": "",
  "visual_concept": "",
  "emotional_intensity": "",
  "bpm": 0,
  "lyrics": [{"time": "", "text": ""}]
}"""
        elif include_bpm:
            format_spec = """{
  "genre": "",
  "sub_genre": "",
  "language": "",
  "vocal_type": "",
  "vocal_description": "",
  "emotion": [""],
  "scene": [""],
  "age": "",
  "rhythm_intensity": "",
  "is_sinking": false,
  "song_description": "",
  "visual_concept": "",
  "emotional_intensity": "",
  "bpm": 0
}"""
        elif include_lyrics:
            format_spec = """{
  "genre": "",
  "sub_genre": "",
  "language": "",
  "vocal_type": "",
  "vocal_description": "",
  "emotion": [""],
  "scene": [""],
  "age": "",
  "rhythm_intensity": "",
  "is_sinking": false,
  "song_description": "",
  "visual_concept": "",
  "emotional_intensity": "",
  "lyrics": [{"time": "", "text": ""}]
}"""
        else:
            format_spec = """{
  "genre": "",
  "sub_genre": "",
  "language": "",
  "vocal_type": "",
  "vocal_description": "",
  "emotion": [""],
  "scene": [""],
  "age": "",
  "rhythm_intensity": "",
  "is_sinking": false,
  "song_description": "",
  "visual_concept": "",
  "emotional_intensity": ""
}"""

        return format_spec

    def build_user_prompt(
        self,
        metadata: Optional[Dict[str, Any]] = None,
        include_lyrics: bool = False,
        include_bpm: bool = True,
    ) -> str:
        """
        构建完整的用户提示词
        使用模板文件并替换占位符

        Args:
            metadata: 音乐元数据字典(可选)
            include_lyrics: 是否识别歌词(保留参数以兼容现有调用)
            include_bpm: 是否包含BPM识别(保留参数以兼容现有调用)

        Returns:
            完整的用户提示词
        """
        # 加载模板
        template = load_template(USER_PROMPT_FILE)

        # 准备替换字典 - 只替换元数据部分
        # 输出格式已在系统提示词中定义,不需要在用户提示词中重复
        replacements = {
            "{{METADATA_SECTION}}": self.build_metadata_section(metadata),
        }

        # 替换占位符
        result = template
        for placeholder, value in replacements.items():
            result = result.replace(placeholder, value)

        return result

    def build_lyrics_only_prompt(self) -> str:
        """构建仅识别歌词的提示词"""
        return load_template(LYRICS_ONLY_PROMPT_FILE)


def build_analyze_prompt(
    metadata: Optional[Dict[str, Any]] = None,
    include_lyrics: bool = False,
    label_level: int = 0,
) -> tuple[str, str]:
    """
    构建完整的分析提示词

    Args:
        metadata: 音乐元数据字典(可选)
        include_lyrics: 是否识别歌词
        label_level: 标签级别(0: 一级标签, 1: 一级+二级标签)

    Returns:
        (system_prompt, user_prompt) 元组
    """
    builder = PromptBuilder(label_level=label_level)
    system_prompt = builder.build_system_prompt()
    user_prompt = builder.build_user_prompt(
        metadata=metadata,
        include_lyrics=include_lyrics,
        include_bpm=True,
    )
    return system_prompt, user_prompt


def build_analyze_prompt_part_a(
    metadata: Optional[Dict[str, Any]] = None,
    include_lyrics: bool = False,
    label_level: int = 0,
) -> tuple[str, str]:
    """
    构建A组分析提示词(标签与基础信息)
    """
    builder = PromptBuilder(label_level=label_level)
    system_prompt = builder.build_system_prompt_part_a()
    user_prompt = builder.build_user_prompt(
        metadata=metadata,
        include_lyrics=include_lyrics,
        include_bpm=True,
    )
    return system_prompt, user_prompt


def build_analyze_prompt_part_b(
    metadata: Optional[Dict[str, Any]] = None,
    include_lyrics: bool = False,
    label_level: int = 0,
) -> tuple[str, str]:
    """
    构建B组分析提示词(节奏与视觉描述)
    """
    builder = PromptBuilder(label_level=label_level)
    system_prompt = builder.build_system_prompt_part_b()
    user_prompt = builder.build_user_prompt(
        metadata=metadata,
        include_lyrics=include_lyrics,
        include_bpm=True,
    )
    return system_prompt, user_prompt


def build_lyrics_prompt() -> str:
    """构建仅识别歌词的提示词"""
    builder = PromptBuilder()
    return builder.build_lyrics_only_prompt()


# 向后兼容:保留原有的构建函数
def build_user_prompt(
    metadata: Optional[Dict[str, Any]] = None,
    include_lyrics: bool = False,
    label_level: int = 0,
) -> str:
    """构建用户提示词(兼容函数)"""
    builder = PromptBuilder(label_level=label_level)
    return builder.build_user_prompt(
        metadata=metadata,
        include_lyrics=include_lyrics,
        include_bpm=True,
    )