diff --git a/docs/PRESENTATION-RUNBOOK-2026-05-19.md b/docs/PRESENTATION-RUNBOOK-2026-05-19.md index b2ea621..d3b4294 100644 --- a/docs/PRESENTATION-RUNBOOK-2026-05-19.md +++ b/docs/PRESENTATION-RUNBOOK-2026-05-19.md @@ -88,6 +88,8 @@ Safe claims: - Capture -> recipe -> review -> export loop is functional. - Run artifacts and reopen/retry flow are functional. - Resurfacing digest and SongForge are report-only, source-linked outputs. +- SongForge is a source-linked text workflow for lyric drafts, spoken-word + variants, structure options, service-agnostic prompt packs, and source notes. - Services-mode transcription forwards `text`, `segments`, and `language` over HTTP when the backend emits rich details (WhisperX). Non-rich backends still return empty segment lists by design. @@ -102,6 +104,8 @@ Do not over-claim yet: - Fully autonomous issue/PR acceptance checking quality. - Resurfacing-digest auto-routing without human approval. +- SongForge generating final songs, mastered audio, or direct paid + music-generation service calls. ## 4) Reviewer Questions diff --git a/scripts/editorial_eval_fixture.py b/scripts/editorial_eval_fixture.py index 18ed53f..88c3b89 100644 --- a/scripts/editorial_eval_fixture.py +++ b/scripts/editorial_eval_fixture.py @@ -67,11 +67,18 @@ def run_songforge_eval(path: Path = SONGFORGE_FIXTURE) -> None: fixture["transcript"], fixture.get("knowledge_base") or {}, ) + if sorted(pack) != fixture["expected_pack_keys"]: + raise AssertionError("SongForge pack keys do not match fixture") + variant_names = [variant["name"] for variant in pack["structure_variants"]] + if variant_names != fixture["expected_structure_variants"]: + raise AssertionError("SongForge structure variants do not match fixture") + if pack["originality_guardrails"] != fixture["expected_originality_guardrails"]: + raise AssertionError("SongForge originality guardrails do not match fixture") markdown = songforge.render_markdown(pack) for expected in fixture["expected_markdown"]: if expected not in markdown: raise AssertionError(f"missing expected SongForge markdown: {expected!r}") - print("songforge-eval: lyric, spoken-word, prompt pack, and source notes rendered") + print("songforge-eval: structure variants, guardrails, prompt pack, and source notes rendered") if __name__ == "__main__": diff --git a/tests/fixtures/songforge_eval.json b/tests/fixtures/songforge_eval.json index 8be876f..e2c2add 100644 --- a/tests/fixtures/songforge_eval.json +++ b/tests/fixtures/songforge_eval.json @@ -3,11 +3,42 @@ "knowledge_base": { "voice.md": "Keep the voice intimate, warm, direct, and source-grounded. Prefer original language over borrowed style references." }, + "expected_pack_keys": [ + "emotional_arc", + "lyric_draft", + "mode", + "motifs", + "music_prompt_pack", + "originality_guardrails", + "phrases", + "safety_note", + "source_notes", + "spoken_word", + "structure_variants", + "themes", + "title" + ], + "expected_structure_variants": [ + "Hook-first source braid", + "Narrative arc build", + "Spoken-word crescendo" + ], + "expected_originality_guardrails": [ + "Use source themes and receipts as anchors, not copied lyrics.", + "Avoid named artist, soundalike, and copyrighted lyric references.", + "Keep the pack service-agnostic and text-only; do not claim final audio." + ], "expected_markdown": [ + "## Originality Guardrails", + "## Structure Variants", + "Hook-first source braid", + "Narrative arc build", + "Spoken-word crescendo", "## Lyric Draft", "## Spoken-Word Variant", "## Music Prompt Pack", "do not imitate living artists", + "service-agnostic and text-only", "KB: voice.md" ] } diff --git a/tests/test_export.py b/tests/test_export.py index cc93e9c..f308976 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -5,7 +5,7 @@ import pytest -from whisperforge_core import export, notion +from whisperforge_core import export, notion, songforge def _bundle(**overrides) -> notion.ContentBundle: @@ -261,6 +261,35 @@ def test_vault_export_preserves_markdown_metadata_sections(self, tmp_path): assert "**Fixture**" in content assert "**Path:** tests/data.txt" in content + def test_vault_export_preserves_songforge_sections(self, tmp_path): + article = songforge.render_markdown(songforge.build_pack( + "A capture about community trust, buried signal, and creative pressure.", + {"voice.md": "Keep the voice source-linked and direct."}, + )) + path = export.export_vault( + _bundle( + title="SongForge Vault", + article=article, + wisdom="", + outline="", + social_content="", + image_prompts="", + ), + vault_dir=tmp_path, + ) + content = path.read_text() + + headings = [ + "## Lyric Draft", + "## Spoken-Word Variant", + "## Music Prompt Pack", + "## Source Notes", + ] + positions = [content.find(heading) for heading in headings] + assert all(position > -1 for position in positions) + assert positions == sorted(positions) + assert "KB: voice.md" in content + def test_vault_export_writes_index_with_obsidian_link(self, tmp_path): path = export.export_vault(_bundle(title="Index Run"), vault_dir=tmp_path) index = tmp_path / "index.md" diff --git a/tests/test_songforge.py b/tests/test_songforge.py index 0770b4b..2a5bb39 100644 --- a/tests/test_songforge.py +++ b/tests/test_songforge.py @@ -1,7 +1,16 @@ """Tests for deterministic SongForge creative packs.""" +import json +from pathlib import Path + from whisperforge_core import songforge +FIXTURE = Path(__file__).with_name("fixtures") / "songforge_eval.json" + + +def _fixture() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + def test_songforge_pack_has_required_outputs_and_source_notes(): pack = songforge.build_pack( @@ -12,9 +21,13 @@ def test_songforge_pack_has_required_outputs_and_source_notes(): assert pack["themes"] assert pack["motifs"] assert pack["emotional_arc"] + assert pack["structure_variants"] assert pack["lyric_draft"] assert pack["spoken_word"] assert pack["music_prompt_pack"]["structure"] + assert all(variant["source_notes"] for variant in pack["structure_variants"]) + assert all(variant["guardrails"] for variant in pack["structure_variants"]) + assert pack["originality_guardrails"] assert any(note["source"] == "KB: voice.md" for note in pack["source_notes"]) @@ -22,13 +35,34 @@ def test_songforge_safety_policy_rejects_artist_imitation(): pack = songforge.build_pack("Make this source into a song.") assert "do not imitate living artists" in pack["safety_note"] + assert "soundalike" in " ".join(pack["originality_guardrails"]) assert "living-artist imitation" in pack["music_prompt_pack"]["avoid"] assert "soundalike" in " ".join(pack["music_prompt_pack"]["avoid"]) -def test_songforge_markdown_renders_three_creative_outputs(): +def test_songforge_fixture_pins_pack_shape(): + fixture = _fixture() + pack = songforge.build_pack( + fixture["transcript"], + fixture.get("knowledge_base") or {}, + ) + + assert sorted(pack) == fixture["expected_pack_keys"] + assert ( + [variant["name"] for variant in pack["structure_variants"]] + == fixture["expected_structure_variants"] + ) + assert pack["originality_guardrails"] == fixture["expected_originality_guardrails"] + markdown = songforge.render_markdown(pack) + for expected in fixture["expected_markdown"]: + assert expected in markdown + + +def test_songforge_markdown_renders_creative_outputs_and_guardrails(): markdown = songforge.render_markdown(songforge.build_pack("A voice memo about turning memory into music.")) + assert "## Originality Guardrails" in markdown + assert "## Structure Variants" in markdown assert "## Lyric Draft" in markdown assert "## Spoken-Word Variant" in markdown assert "## Music Prompt Pack" in markdown diff --git a/whisperforge_core/songforge.py b/whisperforge_core/songforge.py index 5ad4a57..e3e2257 100644 --- a/whisperforge_core/songforge.py +++ b/whisperforge_core/songforge.py @@ -11,6 +11,11 @@ "Original lyrics only. Use supplied transcript and KB material as source " "context; do not imitate living artists or interpolate copyrighted lyrics." ) +ORIGINALITY_GUARDRAILS = [ + "Use source themes and receipts as anchors, not copied lyrics.", + "Avoid named artist, soundalike, and copyrighted lyric references.", + "Keep the pack service-agnostic and text-only; do not claim final audio.", +] _STOPWORDS = { "about", "after", "again", "also", "because", "been", "being", "between", @@ -43,14 +48,17 @@ def build_pack( phrases = _phrases(transcript) emotional_arc = _emotional_arc(transcript, themes) source_notes = _source_notes(transcript, kb_notes, themes) + structure_variants = _structure_variants(emotional_arc, motifs, source_notes) return { "title": title, "mode": "songforge", "safety_note": SAFETY_NOTE, + "originality_guardrails": list(ORIGINALITY_GUARDRAILS), "themes": themes, "motifs": motifs, "phrases": phrases, "emotional_arc": emotional_arc, + "structure_variants": structure_variants, "lyric_draft": _lyric_draft(themes, motifs, phrases), "spoken_word": _spoken_word(themes, motifs, emotional_arc), "music_prompt_pack": _music_prompt_pack(themes, motifs, emotional_arc), @@ -61,11 +69,15 @@ def build_pack( def render_markdown(pack: Mapping[str, Any]) -> str: """Render a SongForge pack as markdown for Article/export surfaces.""" prompt_pack = pack.get("music_prompt_pack") or {} + guardrails = pack.get("originality_guardrails") or ORIGINALITY_GUARDRAILS parts = [ "# SongForge Creative Pack", "", f"Safety: {pack.get('safety_note') or SAFETY_NOTE}", "", + "## Originality Guardrails", + *[f"- {item}" for item in guardrails], + "", "## Themes", *[f"- {item}" for item in pack.get("themes") or []], "", @@ -79,8 +91,20 @@ def render_markdown(pack: Mapping[str, Any]) -> str: ] for item in pack.get("emotional_arc") or []: parts.append(f"- **{item.get('label', 'Beat')}:** {item.get('summary', '')}") + parts.extend(["", "## Structure Variants"]) + for variant in pack.get("structure_variants") or []: + sections = ", ".join(str(item) for item in variant.get("sections") or []) + source_refs = ", ".join(str(item) for item in variant.get("source_notes") or []) + variant_guardrails = ", ".join(str(item) for item in variant.get("guardrails") or []) + parts.extend([ + f"### {variant.get('name', 'Variant')}", + f"- **Best for:** {variant.get('best_for', '')}", + f"- **Sections:** {sections}", + f"- **Source notes:** {source_refs}", + f"- **Guardrails:** {variant_guardrails}", + "", + ]) parts.extend([ - "", "## Lyric Draft", str(pack.get("lyric_draft") or "").strip(), "", @@ -169,6 +193,48 @@ def _source_notes( return notes +def _structure_variants( + arc: list[dict[str, str]], + motifs: list[str], + source_notes: list[dict[str, str]], +) -> list[dict[str, Any]]: + source_labels = _source_labels(source_notes) + arc_labels = [item["label"] for item in arc] + motif = motifs[0] if motifs else "source refrain" + return [ + { + "name": "Hook-first source braid", + "best_for": "quick demo prompt or chorus-first lyric pass", + "sections": [ + "Cold open", "Chorus", "Verse", "Chorus", "Bridge", "Final refrain", + ], + "source_notes": source_labels[:2], + "guardrails": ORIGINALITY_GUARDRAILS[:2], + }, + { + "name": "Narrative arc build", + "best_for": "full lyric draft that follows the captured story", + "sections": arc_labels + ["Chorus", "Outro"], + "source_notes": source_labels[:3], + "guardrails": [ORIGINALITY_GUARDRAILS[0], ORIGINALITY_GUARDRAILS[2]], + }, + { + "name": "Spoken-word crescendo", + "best_for": "performance read or spoken-sung bridge", + "sections": [ + "Spoken intro", f"{motif} refrain", "Sung response", "Final spoken tag", + ], + "source_notes": source_labels[:2], + "guardrails": ORIGINALITY_GUARDRAILS[1:], + }, + ] + + +def _source_labels(source_notes: list[dict[str, str]]) -> list[str]: + labels = [str(note.get("source") or "Source") for note in source_notes] + return labels or ["Transcript"] + + def _kb_notes(knowledge_base: Mapping[str, str]) -> list[dict[str, str]]: notes = [] for name, content in sorted(knowledge_base.items()):