ohollo's picture
Split up experiment files
b5b19b9
Raw
History Blame Contribute Delete
5.54 kB
"""Parent orchestration graph for the harmonic analysis multi-agent system."""
import json
from typing import Annotated, TypedDict
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import HumanMessage
from langchain_core.tools import BaseTool
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from pydantic import BaseModel
from .analysis import build_analysis_subgraph
from .research import build_research_subgraph
_MIN_SONGS = 10
_INITIAL_LIMIT = 10
_LIMIT_INCREMENT = 10
_MAX_LIMIT = 50
class HarmonicInput(BaseModel):
user_input: str
def _merge_researched(existing: list[dict], new: list[dict]) -> list[dict]:
seen = {(s["title"], s["artist"]) for s in existing}
return existing + [s for s in new if (s["title"], s["artist"]) not in seen]
class HarmonicState(TypedDict):
user_input: str
limit: int
originality_score: float | None
neighbours: list[dict]
researched_neighbours: Annotated[list[dict], _merge_researched]
songs_sent_to_research: list[dict] # deterministic parent-owned log of dispatched songs
songs_to_research: list[dict] # set by parent before calling research subgraph
class HarmonicOutput(TypedDict):
response: str
def build_harmonic_graph(
analysis_llm: BaseChatModel,
research_llm: BaseChatModel,
mcp_tools: list[BaseTool],
search_tools: list[BaseTool],
min_songs: int = _MIN_SONGS,
presenter_llm: BaseChatModel | None = None,
) -> CompiledStateGraph:
"""Build the harmonic analysis → research multi-agent graph.
:param analysis_llm: LLM for harmonic analysis (must support tool calling).
:param research_llm: LLM for internet research (must support tool calling).
:param mcp_tools: Harmonic analysis MCP tools.
:param search_tools: Web search tools for research.
:param min_songs: Minimum well-known songs the researcher must find before the graph ends.
:param presenter_llm: LLM for generating the final summary. Defaults to analysis_llm.
"""
analysis_subgraph = build_analysis_subgraph(analysis_llm, mcp_tools)
research_subgraph = build_research_subgraph(research_llm, search_tools)
presenter_llm = presenter_llm or analysis_llm
def prepare_analysis(state: HarmonicState) -> dict:
return {
"limit": _INITIAL_LIMIT,
"originality_score": None,
"neighbours": [],
"researched_neighbours": [],
"songs_sent_to_research": [],
}
def prepare_research(state: HarmonicState) -> dict:
already_sent = {(s["title"], s["artist"]) for s in state["songs_sent_to_research"]}
to_research = [
n for n in state["neighbours"]
if (n["title"], n["artist"]) not in already_sent
]
return {
"songs_to_research": to_research,
"songs_sent_to_research": state["songs_sent_to_research"] + to_research,
}
def widen(state: HarmonicState) -> dict:
return {
"limit": state["limit"] + _LIMIT_INCREMENT,
"originality_score": None,
"neighbours": [],
}
def check_well_known(state: HarmonicState) -> str:
well_known = sum(
1 for s in state["researched_neighbours"]
if s.get("chart_peak") is not None or s.get("is_famous_artist")
)
if well_known >= min_songs or state["limit"] >= _MAX_LIMIT:
return "present"
return "widen"
def present(state: HarmonicState) -> dict:
similarity_by_song = {(n["title"], n["artist"]): n.get("similarity") for n in state["neighbours"]}
well_known = []
for s in state["researched_neighbours"]:
if s.get("chart_peak") is None and not s.get("is_famous_artist"):
continue
similarity = similarity_by_song.get((s["title"], s["artist"]))
if similarity is None:
continue
well_known.append({**s, "similarity": similarity})
prompt = (
f"The chord sequence has an originality score of {state['originality_score']:.4f} "
f"(0 = many harmonic matches, 1 = unique).\n\n"
f"The following well-known songs were found to be harmonically similar:\n"
f"{json.dumps(well_known, indent=2)}\n\n"
"Write a short, readable summary for a musician. List each song with its similarity score "
"and any interesting facts from the research (chart position, chart name, artist notes). "
"Do not reproduce raw JSON — write in natural prose."
)
response = presenter_llm.invoke([HumanMessage(content=prompt)])
return {"response": response.content}
graph = StateGraph(HarmonicState, input=HarmonicInput, output=HarmonicOutput)
graph.add_node("prepare_analysis", prepare_analysis)
graph.add_node("analysis", analysis_subgraph)
graph.add_node("prepare_research", prepare_research)
graph.add_node("research", research_subgraph)
graph.add_node("widen", widen)
graph.add_node("present", present)
graph.add_edge(START, "prepare_analysis")
graph.add_edge("prepare_analysis", "analysis")
graph.add_edge("analysis", "prepare_research")
graph.add_edge("prepare_research", "research")
graph.add_conditional_edges("research", check_well_known, ["widen", "present"])
graph.add_edge("widen", "analysis")
graph.add_edge("present", END)
return graph.compile()