Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/PRESENTATION-RUNBOOK-2026-05-19.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
9 changes: 8 additions & 1 deletion scripts/editorial_eval_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
31 changes: 31 additions & 0 deletions tests/fixtures/songforge_eval.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
31 changes: 30 additions & 1 deletion tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pytest

from whisperforge_core import export, notion
from whisperforge_core import export, notion, songforge


def _bundle(**overrides) -> notion.ContentBundle:
Expand Down Expand Up @@ -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"
Expand Down
36 changes: 35 additions & 1 deletion tests/test_songforge.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -12,23 +21,48 @@ 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"])


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
Expand Down
68 changes: 67 additions & 1 deletion whisperforge_core/songforge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand All @@ -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 []],
"",
Expand All @@ -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(),
"",
Expand Down Expand Up @@ -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()):
Expand Down
Loading