diff --git a/assignments/daexvk/week2/graph.png b/assignments/daexvk/week2/graph.png new file mode 100644 index 0000000..ede4e2a Binary files /dev/null and b/assignments/daexvk/week2/graph.png differ diff --git a/assignments/daexvk/week2/graph.py b/assignments/daexvk/week2/graph.py new file mode 100644 index 0000000..6f90f50 --- /dev/null +++ b/assignments/daexvk/week2/graph.py @@ -0,0 +1,163 @@ +import os +from pathlib import Path +from typing import Annotated, Optional + +from dotenv import load_dotenv +from langchain.chat_models import init_chat_model +from langchain_core.messages import AIMessage, SystemMessage +from pydantic import ValidationError +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode, tools_condition +from typing_extensions import TypedDict + +from schema import PrepAnswer +from tools import ALL_TOOLS + + +def load_project_env() -> None: + """Load only the repository-root .env file.""" + env_path = Path(__file__).resolve().parents[3] / ".env" + load_dotenv(env_path, override=True) + + +load_project_env() + +os.environ.setdefault("LANGSMITH_TRACING", "false") +os.environ.setdefault("LANGSMITH_TRACING_V2", "false") + + +SYSTEM_PROMPT = """ +당신은 클래식 공연 예습을 돕는 RAG 에이전트입니다. + +[역할] +- 사용자가 공연 링크를 보내면 공연 상세 페이지를 읽고, 공연명/일시/장소/연주자/프로그램을 파악합니다. +- 사용자가 작품명이나 작곡가만 말하면 해당 작품을 중심으로 예습 자료를 찾습니다. +- 초보자가 공연 전에 궁금해할 내용을 중심으로 설명합니다. +- 너무 음악학적으로 깊게 들어가기보다, "이 곡이 뭔지", "왜 유명한지", "어떤 배경에서 만들어졌는지", "공연장에서 무엇을 들으면 좋은지"를 알려줍니다. + +[공연 링크가 있는 경우] +도구 호출은 가능한 한 다음 순서를 따르세요. +1) fetch_concert_page(url) + - 공연 상세 페이지의 텍스트를 가져옵니다. +2) extract_concert_program(page_text) + - 공연명, 날짜, 장소, 연주자, 프로그램 후보를 추출합니다. +3) extract_poster_image_text(url) + - 상세 정보가 긴 포스터 이미지 안에만 있어 텍스트 추출만으로 부족할 때 사용합니다. + - 페이지의 이미지 후보를 찾고 비전 OCR로 공연명/일시/장소/출연진/프로그램을 읽어옵니다. + - 사용자에게는 OCR 원문이나 실패 로그를 그대로 보여주지 말고, 정리된 공연 정보와 예습 포인트만 전달합니다. + - 이 도구는 이미지 기반 상세 정보를 추가로 긁어오므로, interrupt가 적용된 그래프에서는 사용자가 승인한 뒤 실행됩니다. +4) retrieve_work_overview(query) + - 각 작품이 어떤 곡인지 초보자용 개요를 검색합니다. +5) retrieve_creation_background(query) + - 작품의 작곡 배경과 맥락을 검색합니다. +6) retrieve_concert_listening_points(query) + - 공연장에서 들을 감상 포인트를 검색합니다. +7) retrieve_preview_keywords(query) + - 공연 전에 들어볼 만한 유튜브 검색어 또는 예습 키워드를 가져옵니다. + +[공연 링크가 없는 경우] +- 사용자의 질문에서 작품명, 작곡가, 악장, 공연 맥락을 파악하세요. +- 특정 작품/작곡가/악장에 대한 질문이면 필요한 도구를 선택적으로 호출하세요. + - retrieve_work_overview + - retrieve_creation_background + - retrieve_concert_listening_points + - retrieve_preview_keywords +- 일반적인 공연 예습 방법을 묻는 질문이면 도구를 호출하지 않아도 됩니다. + +[도구를 호출하지 않아도 되는 경우] +- 사용자가 일반 인사나 간단한 사용법을 묻는 경우. +- 특정 작품/공연 정보 검색이 필요 없는 일반 조언인 경우. +- 직전 메시지에 이미 충분한 예습 결과가 있고 재활용할 수 있는 경우. + +[응답 형식] +- 도구를 사용한 경우, 도구 결과를 근거로 한국어로 정리하세요. +- 초보자 친화적으로 답하세요. +- 확실하지 않은 정보는 추측하지 말고 불확실하다고 말하세요. +- 최종 답변에는 가능하면 다음을 포함하세요. + · 공연 정보 또는 작품명 + · 한 줄 요약 + · 작곡/역사적 배경 + · 공연장에서 들을 포인트 + · 공연 전 예습 추천 + · 유튜브 또는 웹 검색 키워드 + · 참고한 출처 또는 도구 결과 +""" + + +MODEL_NAME = os.getenv("CLASSICAL_AGENT_MODEL", "openai:gpt-5.4-mini") + +model_with_tools = init_chat_model(MODEL_NAME).bind_tools(ALL_TOOLS) +structured_model = init_chat_model(MODEL_NAME).with_structured_output(PrepAnswer, method="function_calling") + + +class AgentState(TypedDict): + messages: Annotated[list, add_messages] + used_tools: list[str] + final_answer: Optional[dict] + + +def _collect_used_tools(messages: list) -> list[str]: + used_tools = [] + + for message in messages: + for tool_call in getattr(message, "tool_calls", []) or []: + tool_name = tool_call.get("name") + if tool_name and tool_name not in used_tools: + used_tools.append(tool_name) + + return used_tools + + +def agent_node(state: AgentState): + messages = [SystemMessage(content=SYSTEM_PROMPT), *state["messages"]] + response = model_with_tools.invoke(messages) + + if response.tool_calls: + return { + "messages": [response], + "used_tools": _collect_used_tools([*state["messages"], response]), + } + + try: + final = structured_model.invoke(messages) + except ValidationError: + retry_messages = [ + *messages, + SystemMessage( + content=( + "직전 응답은 PrepAnswer 스키마 파싱에 실패했습니다. " + "도구를 더 호출하지 말고 PrepAnswer 스키마에 맞는 단일 구조화 응답만 생성하세요." + ) + ), + ] + final = structured_model.invoke(retry_messages) + + used_tools = _collect_used_tools(state["messages"]) + final.used_tools = used_tools + final_dict = final.model_dump() + + return { + "messages": [AIMessage(content=final.model_dump_json(indent=2))], + "used_tools": used_tools, + "final_answer": final_dict, + } + + +builder = StateGraph(AgentState) +builder.add_node("agent", agent_node) +builder.add_node("tools", ToolNode(ALL_TOOLS)) + +builder.add_edge(START, "agent") +builder.add_conditional_edges("agent", tools_condition) +builder.add_edge("tools", "agent") + +memory = InMemorySaver() +interrupt_memory = InMemorySaver() + +graph = builder.compile(checkpointer=memory) +graph_with_interrupt = builder.compile( + checkpointer=interrupt_memory, + interrupt_before=["tools"], +) diff --git a/assignments/daexvk/week2/schema.py b/assignments/daexvk/week2/schema.py new file mode 100644 index 0000000..be3b342 --- /dev/null +++ b/assignments/daexvk/week2/schema.py @@ -0,0 +1,34 @@ +from typing import Annotated, Optional + +from langgraph.graph.message import add_messages +from pydantic import BaseModel, Field +from typing_extensions import TypedDict + + +class PrepAnswer(BaseModel): + """클래식 공연 예습 에이전트의 최종 응답 스키마""" + + concert_title: Optional[str] = Field(default=None, description="Concert title, if known") + concert_date: Optional[str] = Field(default=None, description="Concert date, if known") + venue: Optional[str] = Field(default=None, description="Concert venue, if known") + performers: list[str] = Field(default_factory=list, description="Conductors, orchestras, soloists, etc.") + work_title: str = Field(description="Main classical work being discussed") + program_works: list[str] = Field(default_factory=list, description="Works found in the concert program") + composer: Optional[str] = Field(default=None, description="Composer, if identified") + difficulty: str = Field(description="beginner, intermediate, or advanced") + summary: str = Field(description="Beginner-friendly concert-prep summary") + background: list[str] = Field(description="Historical, composer, or creation background") + listening_points: list[str] = Field(description="What to listen for during the performance") + recommended_before_concert: list[str] = Field(description="Concrete prep actions before attending") + preview_keywords: list[str] = Field( + default_factory=list, + description="YouTube or web search keywords for preview listening", + ) + used_tools: list[str] = Field(description="Tool names used by the graph") + sources: list[str] = Field(description="Source URLs or source labels") + + +class AgentState(TypedDict): + messages: Annotated[list, add_messages] + used_tools: list[str] + final_answer: Optional[dict] diff --git a/assignments/daexvk/week2/tools.py b/assignments/daexvk/week2/tools.py new file mode 100644 index 0000000..27aa4bf --- /dev/null +++ b/assignments/daexvk/week2/tools.py @@ -0,0 +1,416 @@ +import base64 +import mimetypes +import os +import re +from html import unescape +from html.parser import HTMLParser +from pathlib import Path +from urllib.error import URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + +from dotenv import load_dotenv +from langchain.chat_models import init_chat_model +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool + + +class _VisibleTextParser(HTMLParser): + def __init__(self): + super().__init__() + self._skip = False + self._chunks = [] + + def handle_starttag(self, tag, attrs): + if tag in {"script", "style", "noscript"}: + self._skip = True + + def handle_endtag(self, tag): + if tag in {"script", "style", "noscript"}: + self._skip = False + + def handle_data(self, data): + text = data.strip() + if not self._skip and text: + self._chunks.append(text) + + def text(self) -> str: + return "\n".join(self._chunks) + + +def _compact_text(text: str, limit: int = 8000) -> str: + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip()[:limit] + + +def _fetch_html(url: str) -> str: + request = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urlopen(request, timeout=10) as response: + return response.read().decode("utf-8", errors="ignore") + + +def _load_project_env() -> None: + env_path = Path(__file__).resolve().parents[3] / ".env" + load_dotenv(env_path, override=False) + + +def _fetch_bytes(url: str, limit: int = 5_000_000) -> tuple[bytes, str]: + request = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urlopen(request, timeout=15) as response: + content_type = response.headers.get("content-type", "").split(";")[0].strip() + data = response.read(limit + 1) + + if len(data) > limit: + raise ValueError(f"image is too large: {url}") + + if not content_type: + content_type = mimetypes.guess_type(url)[0] or "image/jpeg" + + return data, content_type + + +def _extract_attr_values(html: str, attr_name: str) -> list[str]: + pattern = re.compile(rf"""{attr_name}=["']([^"']+)["']""", re.IGNORECASE) + return [unescape(value.strip()) for value in pattern.findall(html) if value.strip()] + + +def _extract_image_urls(html: str, base_url: str, limit: int = 6) -> list[str]: + raw_candidates = [] + + meta_pattern = re.compile( + r"""]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["'][^>]*>""", + re.IGNORECASE, + ) + raw_candidates.extend(unescape(value.strip()) for value in meta_pattern.findall(html)) + + for attr_name in ("src", "data-src", "data-original", "data-lazy-src"): + raw_candidates.extend(_extract_attr_values(html, attr_name)) + + for srcset in _extract_attr_values(html, "srcset"): + raw_candidates.extend(part.strip().split(" ")[0] for part in srcset.split(",") if part.strip()) + + background_pattern = re.compile(r"""background-image\s*:\s*url\((["']?)(.*?)\1\)""", re.IGNORECASE) + raw_candidates.extend(unescape(value.strip()) for _, value in background_pattern.findall(html)) + + image_urls = [] + for candidate in raw_candidates: + if not candidate or candidate.startswith("data:"): + continue + + absolute_url = urljoin(base_url, candidate) + lowered = absolute_url.lower() + if any(skip in lowered for skip in ("logo", "icon", "favicon", "spinner", "blank")): + continue + if any(lowered.split("?", 1)[0].endswith(ext) for ext in (".js", ".css", ".woff", ".woff2", ".ttf")): + continue + if absolute_url not in image_urls: + image_urls.append(absolute_url) + + def priority(image_url: str) -> tuple[int, str]: + lowered = image_url.lower() + score = 0 + if any(token in lowered for token in ("poster", "performance", "/down/", "/upload/", "/rent/", "concert")): + score -= 2 + if any(token in lowered for token in ("sns", "share", "banner")): + score += 2 + return score, image_url + + return sorted(image_urls, key=priority)[:limit] + + +def _ocr_poster_image(image_url: str, image_bytes: bytes, content_type: str, page_text: str, metadata: str) -> str: + _load_project_env() + model_name = os.getenv("CLASSICAL_POSTER_OCR_MODEL", os.getenv("CLASSICAL_AGENT_MODEL", "openai:gpt-5.4-mini")) + vision_model = init_chat_model(model_name) + image_data = base64.b64encode(image_bytes).decode("ascii") + data_url = f"data:{content_type};base64,{image_data}" + + message = HumanMessage( + content=[ + { + "type": "text", + "text": ( + "이 이미지는 클래식 공연 상세 페이지의 포스터일 수 있습니다. " + "이미지 안의 텍스트를 읽되, raw OCR 로그를 나열하지 말고 사용자가 바로 볼 수 있는 공연 정보로 정리하세요. " + "주변 페이지 텍스트와 이미지 메타데이터도 함께 참고해서 포스터에서 잘 안 읽히는 부분을 보완하세요. " + "반드시 다음 항목만 간결하게 구분하세요: 공연명, 일시, 장소, 출연진, 프로그램/곡명, 예습에 필요한 핵심 요약, 불확실한 정보. " + "확실하지 않은 내용은 추정하지 말고 '불확실한 정보'에 넣으세요. " + f"\n\n이미지 URL: {image_url}" + f"\n\n이미지 메타데이터:\n{metadata or '없음'}" + f"\n\n주변 페이지 텍스트:\n{page_text or '없음'}" + ), + }, + {"type": "image_url", "image_url": {"url": data_url}}, + ] + ) + + response = vision_model.invoke([message]) + return str(response.content) + + +def _score_documents(query: str, documents: list[dict]) -> list[dict]: + query_terms = set(re.findall(r"[A-Za-z가-힣0-9]+", query.lower())) + scored = [] + + for document in documents: + haystack = f"{document['title']} {document['content']}".lower() + score = sum(1 for term in query_terms if term in haystack) + scored.append((score, document)) + + scored.sort(key=lambda item: item[0], reverse=True) + return [document for score, document in scored if score > 0][:3] or documents[:2] + + +def _format_documents(query: str, documents: list[dict]) -> str: + selected = _score_documents(query, documents) + return "\n\n".join( + f"[source: {document['source']}]\n" + f"title: {document['title']}\n" + f"{document['content']}" + for document in selected + ) + + +WORK_OVERVIEW_DOCS = [ + { + "title": "Mahler Symphony No. 5 beginner overview", + "source": "local-rag:mahler-5-overview", + "content": ( + "말러 교향곡 5번은 다섯 악장으로 된 대규모 교향곡이다. 어둡고 장례 행진 같은 " + "출발에서 격렬한 투쟁, 고요한 사랑의 노래 같은 아다지에토, 밝은 피날레로 나아간다. " + "초보자는 전체를 하나의 감정 여행처럼 듣는 것이 좋다." + ), + }, + { + "title": "Beethoven Symphony No. 5 beginner overview", + "source": "local-rag:beethoven-5-overview", + "content": ( + "베토벤 교향곡 5번은 짧은 네 음 동기로 시작하는 대표적인 교향곡이다. 긴장과 투쟁의 " + "느낌에서 마지막 악장의 밝은 승리감으로 향하는 흐름이 뚜렷하다." + ), + }, + { + "title": "Classical concert beginner overview", + "source": "local-rag:concert-prep-overview", + "content": ( + "처음 클래식 공연을 볼 때는 모든 형식을 분석하려 하기보다 작품의 분위기, 반복되는 선율, " + "악기 색채, 큰 감정 변화에 집중하면 공연을 더 쉽게 따라갈 수 있다." + ), + }, +] + +BACKGROUND_DOCS = [ + { + "title": "Mahler Symphony No. 5 creation background", + "source": "local-rag:mahler-5-background", + "content": ( + "말러 교향곡 5번은 말러의 중기 양식을 대표하는 작품으로, 성악 없이 순수 관현악으로 " + "구성되어 있다. 개인적 위기와 회복, 알마와의 만남을 둘러싼 시기와 자주 연결해 설명된다. " + "다만 각 악장을 단일한 줄거리로 고정해 해석하는 것은 조심해야 한다." + ), + }, + { + "title": "Beethoven Symphony No. 5 creation background", + "source": "local-rag:beethoven-5-background", + "content": ( + "베토벤 교향곡 5번은 베토벤이 청력 악화와 창작적 전환을 겪던 시기의 작품이다. 흔히 " + "'운명'이라는 이미지로 설명되지만, 이 표제는 작품 이해를 돕는 별칭에 가깝다." + ), + }, +] + +LISTENING_POINT_DOCS = [ + { + "title": "Mahler Symphony No. 5 listening points", + "source": "local-rag:mahler-5-listening", + "content": ( + "말러 5번에서는 1악장의 트럼펫 신호와 장례 행진 리듬, 2악장의 격렬한 폭발, 3악장의 " + "왈츠적 움직임, 4악장 아다지에토의 현악기와 하프, 5악장의 밝고 복잡한 피날레를 " + "중심으로 들으면 좋다." + ), + }, + { + "title": "Beethoven Symphony No. 5 listening points", + "source": "local-rag:beethoven-5-listening", + "content": ( + "베토벤 5번은 첫 네 음 동기가 작품 전체에서 어떻게 변형되는지, 어두운 1악장에서 " + "밝은 4악장으로 분위기가 어떻게 전환되는지에 집중하면 좋다." + ), + }, +] + + +@tool +def fetch_concert_page(url: str) -> str: + """Fetch visible text from a concert detail page URL.""" + try: + html = _fetch_html(url) + except (ValueError, URLError, TimeoutError) as exc: + return f"공연 페이지를 가져오지 못했습니다. url={url}, error={exc}" + + parser = _VisibleTextParser() + parser.feed(html) + return _compact_text(parser.text()) + + +@tool +def extract_poster_image_text(url: str) -> str: + """Extract poster image details and return a cleaned concert-info summary.""" + try: + html = _fetch_html(url) + except (ValueError, URLError, TimeoutError) as exc: + return f"포스터 이미지 정보를 가져오지 못했습니다. url={url}, error={exc}" + + parser = _VisibleTextParser() + parser.feed(html) + page_text = _compact_text(parser.text(), limit=3000) + + image_pattern = re.compile(r"]*>", re.IGNORECASE) + attr_pattern = re.compile(r"""(alt|title|src|data-src|aria-label)=["']([^"']+)["']""", re.IGNORECASE) + + poster_rows = [] + for index, tag in enumerate(image_pattern.findall(html), start=1): + attrs = {key.lower(): unescape(value.strip()) for key, value in attr_pattern.findall(tag)} + haystack = " ".join(attrs.values()).lower() + if not attrs or ("poster" not in haystack and "포스터" not in haystack and "performance" not in haystack and index > 5): + continue + + poster_rows.append( + "\n".join( + [ + f"image_candidate: {index}", + f"alt: {attrs.get('alt', '')}", + f"title: {attrs.get('title', '')}", + f"src: {attrs.get('src') or attrs.get('data-src', '')}", + ] + ) + ) + + image_urls = _extract_image_urls(html, url) + metadata = "\n\n".join(poster_rows[:5]) + successful_results = [] + failure_notes = [] + for image_url in image_urls[:3]: + try: + image_bytes, content_type = _fetch_bytes(image_url) + except (ValueError, URLError, TimeoutError) as exc: + failure_notes.append(f"{image_url}: 이미지를 가져오지 못했습니다: {exc}") + continue + + if not content_type.startswith("image/"): + failure_notes.append(f"{image_url}: 이미지 content-type이 아닙니다: {content_type}") + continue + + try: + ocr_text = _ocr_poster_image(image_url, image_bytes, content_type, page_text, metadata) + except Exception as exc: + failure_notes.append(f"{image_url}: 비전 OCR에 실패했습니다: {type(exc).__name__}: {exc}") + continue + + successful_results.append((image_url, ocr_text)) + break + + if successful_results: + image_url, summary = successful_results[0] + return _compact_text( + "\n".join( + [ + "[포스터 기반 공연 정보 정리]", + summary, + "", + f"참고 이미지: {image_url}", + "참고: 포스터 이미지와 주변 페이지 텍스트를 함께 사용해 정리했습니다.", + ] + ), + limit=8000, + ) + + if not poster_rows and not failure_notes: + return "포스터 이미지 후보를 찾지 못했습니다. 페이지가 스크립트로 이미지를 동적으로 불러올 수 있습니다." + + sections = ["포스터 OCR을 완료하지 못했습니다. 확인 가능한 주변 정보만 정리합니다."] + if page_text: + sections.append("[주변 페이지 텍스트]\n" + page_text) + if poster_rows: + sections.append("[이미지 메타데이터]\n" + "\n\n".join(poster_rows[:3])) + if failure_notes: + sections.append("[처리 상태]\n" + "\n".join(f"- {note}" for note in failure_notes[:3])) + + return _compact_text("\n\n".join(sections), limit=6000) + + +@tool +def extract_concert_program(page_text: str) -> str: + """Extract likely concert title, date, venue, performers, and program lines from concert page text.""" + lines = [line.strip() for line in page_text.splitlines() if line.strip()] + title = lines[0] if lines else "unknown" + + date_pattern = re.compile(r"\d{4}[./-]\d{1,2}[./-]\d{1,2}|\d{1,2}[./-]\d{1,2}") + program_pattern = re.compile( + r"(symphony|concerto|sonata|quartet|overture|교향곡|협주곡|소나타|서곡|모음곡|아다지에토|말러|베토벤|브람스|차이콥스키|모차르트)", + re.IGNORECASE, + ) + performer_pattern = re.compile(r"(지휘|연주|협연|오케스트라|필하모닉|교향악단|Conductor|Orchestra|Soloist)", re.IGNORECASE) + + dates = [line for line in lines if date_pattern.search(line)][:3] + performers = [line for line in lines if performer_pattern.search(line)][:8] + program_lines = [line for line in lines if program_pattern.search(line)][:12] + + return _compact_text( + "\n".join( + [ + f"title: {title}", + f"dates: {dates}", + f"performers: {performers}", + "program_candidates:", + *program_lines, + ] + ) + ) + + +@tool +def retrieve_work_overview(query: str) -> str: + """Retrieve beginner-friendly overview documents about a classical work.""" + return _format_documents(query, WORK_OVERVIEW_DOCS) + + +@tool +def retrieve_creation_background(query: str) -> str: + """Retrieve historical, personal, and artistic background documents about a classical work.""" + return _format_documents(query, BACKGROUND_DOCS) + + +@tool +def retrieve_concert_listening_points(query: str) -> str: + """Retrieve beginner-friendly listening points for hearing a classical work in concert.""" + return _format_documents(query, LISTENING_POINT_DOCS) + + +@tool +def retrieve_preview_keywords(query: str) -> str: + """Return YouTube or web search keywords for preview listening before a concert.""" + base_query = query.strip() + if not base_query: + base_query = "클래식 공연 예습" + + keywords = [ + f"{base_query} 초보자 해설", + f"{base_query} 감상 포인트", + f"{base_query} beginner guide", + f"{base_query} live performance", + ] + + return "\n".join(f"- {keyword}" for keyword in keywords) + + +ALL_TOOLS = [ + fetch_concert_page, + extract_poster_image_text, + extract_concert_program, + retrieve_work_overview, + retrieve_creation_background, + retrieve_concert_listening_points, + retrieve_preview_keywords, +] diff --git a/assignments/daexvk/week2/week2_mission.ipynb b/assignments/daexvk/week2/week2_mission.ipynb new file mode 100644 index 0000000..8017773 --- /dev/null +++ b/assignments/daexvk/week2/week2_mission.ipynb @@ -0,0 +1,572 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8b20b5bb", + "metadata": {}, + "source": [ + "# Week 2 - Classical Prep ReAct Graph with Memory and Interrupt\n", + "\n", + "1주차 클래식 공연 예습 ReAct 그래프를 같은 도메인으로 재사용하고, week2 요구사항인 checkpointer, thread_id 분리, interrupt 실행제어, 맥락 질문 결과를 정리합니다.\n", + "\n", + "- 기본 그래프: `graph = builder.compile(checkpointer=memory)`\n", + "- 실행제어 그래프: `graph_with_interrupt = builder.compile(checkpointer=interrupt_memory, interrupt_before=[\"tools\"])`\n", + "- interrupt 시나리오: 공연 상세 내용이 긴 포스터 이미지에만 있을 때, 이미지/포스터 정보 추출 도구 실행 전 사용자의 승인을 받습니다.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "33396fb5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "target_dir: /Users/nozerose/Documents/GitHub/rag-agent-study/assignments/daexvk/week2\n" + ] + } + ], + "source": [ + "from pathlib import Path\n", + "import sys\n", + "\n", + "cwd = Path.cwd()\n", + "candidates = [cwd, cwd / \"assignments\" / \"daexvk\" / \"week2\", cwd / \"week2\"]\n", + "target_dir = next(path for path in candidates if (path / \"graph.py\").exists())\n", + "sys.path.insert(0, str(target_dir.resolve()))\n", + "\n", + "print(\"target_dir:\", target_dir.resolve())" + ] + }, + { + "cell_type": "markdown", + "id": "cb944546", + "metadata": {}, + "source": [ + "## Graph and Checkpointer\n", + "\n", + "week1의 `agent -> tools -> agent` ReAct 구조를 그대로 유지하고, week2에서는 compile 단계에 `InMemorySaver` checkpointer를 결합했습니다. `thread_id`가 같으면 같은 체크포인트 이력을 이어 쓰고, 다르면 별도의 대화로 저장됩니다.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "82f9fe4d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "graph: CompiledStateGraph\n", + "checkpointer: InMemorySaver\n", + "interrupt graph: CompiledStateGraph\n", + "interrupt checkpointer: InMemorySaver\n" + ] + } + ], + "source": [ + "import importlib\n", + "import tools as tools_module\n", + "import graph as graph_module\n", + "\n", + "# 노트북 커널이 이전 graph/tools 모듈을 캐시하는 경우가 있어 최신 파일을 강제로 다시 로드합니다.\n", + "importlib.reload(tools_module)\n", + "importlib.reload(graph_module)\n", + "\n", + "from graph import graph, graph_with_interrupt, memory, interrupt_memory\n", + "\n", + "print(\"graph:\", type(graph).__name__)\n", + "print(\"checkpointer:\", type(memory).__name__)\n", + "print(\"interrupt graph:\", type(graph_with_interrupt).__name__)\n", + "print(\"interrupt checkpointer:\", type(interrupt_memory).__name__)" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "b51b761e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Graph image saved to: /Users/nozerose/Documents/GitHub/rag-agent-study/assignments/daexvk/week2/graph.png\n" + ] + } + ], + "source": [ + "from pathlib import Path\n", + "from IPython.display import Image, Markdown, display\n", + "\n", + "output_dir = Path(\".\")\n", + "png_path = output_dir / \"graph.png\"\n", + "mermaid_path = output_dir / \"graph.mmd\"\n", + "\n", + "try:\n", + " png = graph.get_graph().draw_mermaid_png()\n", + " png_path.write_bytes(png)\n", + " display(Image(png))\n", + " print(f\"Graph image saved to: {png_path.resolve()}\")\n", + "except Exception as exc:\n", + " print(\"PNG rendering failed, saving Mermaid instead:\", exc)\n", + " mermaid = graph.get_graph().draw_mermaid()\n", + " mermaid_path.write_text(mermaid, encoding=\"utf-8\")\n", + "# display(Markdown(\"```mermaid\n", + "# \" + mermaid + \"\n", + "# ```\"))\n", + "# print(f\"Mermaid graph saved to: {mermaid_path.resolve()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "2785a93e", + "metadata": {}, + "source": [ + "## Helpers" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "d45db57a", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "\n", + "def invoke_with_thread(question: str, thread_id: str):\n", + " config = {\"recursion_limit\": 20, \"configurable\": {\"thread_id\": thread_id}}\n", + " result = graph.invoke(\n", + " {\"messages\": [HumanMessage(content=question)], \"used_tools\": [], \"final_answer\": None},\n", + " config=config,\n", + " )\n", + " return result, config\n", + "\n", + "\n", + "def _answer_get(answer, key, default=None):\n", + " if isinstance(answer, dict):\n", + " return answer.get(key, default)\n", + " return getattr(answer, key, default)\n", + "\n", + "\n", + "def summarize_result(label: str, question: str, result: dict):\n", + " answer = result.get(\"final_answer\")\n", + " latest = result[\"messages\"][-1].content\n", + " print(f\"[{label}]\")\n", + " print(\"Q:\", question)\n", + " print(\"used_tools:\", result.get(\"used_tools\", []))\n", + " if answer:\n", + " print(\"work:\", _answer_get(answer, \"work_title\"))\n", + " print(\"summary:\", _answer_get(answer, \"summary\", \"\")[:220].replace(\"\\n\", \" \"))\n", + " else:\n", + " print(latest[:300])\n", + " print(\"messages:\", len(result.get(\"messages\", [])))\n", + " print(\"latest_message_length:\", len(latest))\n" + ] + }, + { + "cell_type": "markdown", + "id": "dce13f7e", + "metadata": {}, + "source": [ + "## Context Questions\n", + "\n", + "맥락 질문은 3개 이상 남겼습니다.\n", + "\n", + "1. 첫 질문: 말러 5번 전체 예습\n", + "2. 같은 thread의 두 번째 질문: 직전 답변 맥락을 이어 4악장 아다지에토만 질문\n", + "3. 다른 thread의 같은 질문: 같은 내용을 새 thread에서 질문해 대화가 분리되는지 확인\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "326cf28c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[same thread / first question]\n", + "Q: https://www.bsgangseo.go.kr/nac/portal/concertSchedule/list.do?mid=0101000000&token=1780758062439 여기에서 공연을 추천해줘\n", + "used_tools: ['fetch_concert_page']\n", + "work: 공연일정 중 초보자에게 추천할 만한 클래식 공연\n", + "summary: 이 페이지에는 여러 공연이 함께 올라와 있습니다. 초보자라면 친숙한 곡이 나올 가능성이 높고 설명 제목도 쉬운 <오케스트라의 분위기 메이커들>, <현악기의 진짜 매력>, 그리고 부담 없이 듣기 좋은 [마티네콘서트] 성악 앙상블 시리즈 Ⅲ를 먼저 추천합니다. 대형곡을 원하면 NAC@ NAFO시리즈 - 교향곡 속의 합창도 좋지만, 처음이라면 제목만 봐도 접근성이 높은 프로그램부터 고르는 편이 편\n", + "messages: 4\n", + "latest_message_length: 1971\n" + ] + } + ], + "source": [ + "# 맥락 이용 질문 1\n", + "question = 'https://www.bsgangseo.go.kr/nac/portal/concertSchedule/list.do?mid=0101000000&token=1780758062439 여기에서 공연을 추천해줘'\n", + "result_same_1, config_same = invoke_with_thread(question, \"thread_1\")\n", + "summarize_result(\"same thread / first question\", question, result_same_1)" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "57fd7560", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[same thread / second question]\n", + "Q: 방금 추천 해준 공연 이름이 뭐였지 ? \n", + "used_tools: ['fetch_concert_page']\n", + "work: 공연일정 중 초보자에게 추천할 만한 클래식 공연\n", + "summary: 방금 추천한 건 낙동아트센터 공연일정에 있는 여러 클래식 공연이었어요. 그중 초보자에게는 <현악기의 진짜 매력>, <오케스트라의 분위기 메이커들>, 그리고 [마티네콘서트] 성악 앙상블 시리즈 Ⅲ 오네스토 앙상블의 '힐링 뮤직브런치'를 먼저 추천했습니다.\n", + "messages: 6\n", + "latest_message_length: 1150\n" + ] + } + ], + "source": [ + "question = '방금 추천 해준 공연 이름이 뭐였지 ? '\n", + "result_same_2, config_same = invoke_with_thread(question, \"thread_1\")\n", + "summarize_result(\"same thread / second question\", question, result_same_2)" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "1be91f1a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[different thread / same question]\n", + "Q: 너가 방금 추천해준 공연 이름이 뭐였더라 ?\n", + "used_tools: []\n", + "work: 알 수 없음\n", + "summary: 직전에 추천한 공연 정보를 현재 대화에서 확인할 수 없어서 공연 이름을 특정할 수 없습니다.\n", + "messages: 2\n", + "latest_message_length: 668\n" + ] + } + ], + "source": [ + "question = '너가 방금 추천해준 공연 이름이 뭐였더라 ?'\n", + "result_new, config_new = invoke_with_thread(question, \"thread_2\")\n", + "summarize_result(\"different thread / same question\", question, result_new)" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "f498f01c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[same thread / first question]\n", + "Q: 우리나라 클래식 연주자 중에서 유명한 사람이 누가 있어 ?\n", + "used_tools: []\n", + "work: 한국의 유명 클래식 연주자\n", + "summary: 질문은 특정 공연 예습보다는 한국의 유명 클래식 연주자 소개에 가깝습니다. 대표적인 연주자들을 악기별로 간단히 정리해드리면, 세계 무대에서 활약한 바이올리니스트 정경화·사라 장, 첼리스트 장한나, 피아니스트 조성진·손열음, 지휘자 정명훈, 성악가 조수미 등이 널리 알려져 있습니다.\n", + "messages: 2\n", + "latest_message_length: 876\n" + ] + } + ], + "source": [ + "# 맥락 이용 질문 2\n", + "question = '우리나라 클래식 연주자 중에서 유명한 사람이 누가 있어 ?'\n", + "result_3, config_3 = invoke_with_thread(question, \"thread_3\")\n", + "summarize_result(\"same thread / first question\", question, result_3)" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "c29d525b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[same thread / first question]\n", + "Q: 방금 추천해준 피아니스트 이름이 뭐지 ?\n", + "used_tools: []\n", + "work: 피아니스트 이름 확인\n", + "summary: 직전 답변에서 한국의 유명 클래식 연주자로 피아니스트 조성진, 손열음을 언급했습니다. 질문하신 '방금 추천해준 피아니스트'는 이 두 사람 중 하나일 가능성이 높습니다.\n", + "messages: 4\n", + "latest_message_length: 639\n" + ] + } + ], + "source": [ + "question = '방금 추천해준 피아니스트 이름이 뭐지 ?'\n", + "result_4, config_3 = invoke_with_thread(question, \"thread_3\")\n", + "summarize_result(\"same thread / first question\", question, result_4)" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "26873434", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[same thread / first question]\n", + "Q: 첼로 연주자 중에 유튜브 하는 사람 추천해줘 한국 사람으로\n", + "used_tools: ['retrieve_preview_keywords']\n", + "work: 한국인 첼리스트 유튜브 채널 추천\n", + "summary: 한국인 첼리스트 중 유튜브 활동을 하는 사람을 찾고 계시네요. 다만 현재 대화만으로는 실시간으로 채널의 ‘활성도’와 최신 운영 여부를 확정하기 어려워서, 안전하게는 특정 스타일별로 직접 확인하는 방식이 좋습니다. 원하시면 제가 한국인 첼리스트 유튜브 채널을 연주형/교육형/일상형으로 나눠 추천해드릴 수 있습니다.\n", + "messages: 8\n", + "latest_message_length: 1077\n" + ] + } + ], + "source": [ + "# 맥락 이용 질문 3\n", + "question = '첼로 연주자 중에 유튜브 하는 사람 추천해줘 한국 사람으로'\n", + "result_5, config_4 = invoke_with_thread(question, \"thread_4\")\n", + "summarize_result(\"same thread / first question\", question, result_5)" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "7adbd993", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[same thread / first question]\n", + "Q: 연주형으로 추천해줘\n", + "used_tools: ['retrieve_preview_keywords']\n", + "work: 한국인 첼리스트 유튜브 채널 추천(연주형)\n", + "summary: 연주형으로 활동하는 한국인 첼리스트를 찾고 계시네요. 실시간 채널 활성도는 변동될 수 있어서, 지금 시점에서 특정 채널의 최신 업로드 여부를 단정하긴 어렵습니다. 대신, 연주 영상 위주로 찾을 때는 ‘공식 채널/콘서트 실황/리사이틀 영상/Shorts’가 꾸준한 채널을 고르는 게 좋습니다.\n", + "messages: 10\n", + "latest_message_length: 1131\n" + ] + } + ], + "source": [ + "question = '연주형으로 추천해줘'\n", + "result_6, config_4 = invoke_with_thread(question, \"thread_4\")\n", + "summarize_result(\"same thread / first question\", question, result_6)" + ] + }, + { + "cell_type": "markdown", + "id": "2d9e21c5", + "metadata": {}, + "source": [ + "## State History Metrics for PR\n", + "\n", + "PR에 첨부할 `get_state_history()` 요약입니다. 같은 thread는 첫 질문과 두 번째 질문을 이어 받아 스냅샷과 메시지가 더 많고, 다른 thread는 같은 질문이라도 별도 대화로 시작합니다.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "b7572413", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "thread_1: snapshots=8, latest_messages=6, latest_message_length=1150\n", + "thread_2: snapshots=3, latest_messages=2, latest_message_length=668\n" + ] + } + ], + "source": [ + "def print_state_history_summary(label: str, config: dict):\n", + " history = list(graph.get_state_history(config))\n", + " latest = history[0]\n", + " messages = latest.values.get(\"messages\", [])\n", + " latest_msg = messages[-1].content if messages else \"\"\n", + " print(\n", + " f\"{label}: snapshots={len(history)}, \"\n", + " f\"latest_messages={len(messages)}, \"\n", + " f\"latest_message_length={len(latest_msg)}\"\n", + " )\n", + "\n", + "\n", + "print_state_history_summary(\"thread_1\", config_same)\n", + "print_state_history_summary(\"thread_2\", config_new)" + ] + }, + { + "cell_type": "markdown", + "id": "a2e5f88a", + "metadata": {}, + "source": [ + "## Interrupt Pattern: Poster OCR Approval\n", + "\n", + "요즘 공연 상세 페이지는 텍스트 대신 긴 포스터 이미지에 프로그램/연주자/시간이 들어있는 경우가 많습니다. 이 경우 `graph_with_interrupt`를 사용해 도구 노드 실행 직전에 멈추고, 사용자가 포스터 OCR을 승인한 뒤 `stream(None, config, ...)`로 이어서 실행합니다.\n", + "\n", + "`extract_poster_image_text`는 페이지의 이미지 후보와 주변 텍스트를 함께 참고하고, 사용자에게는 raw OCR 로그가 아니라 정리된 공연 정보만 반환합니다.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "d0376461", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "dict_keys(['agent'])\n", + "dict_keys(['__interrupt__'])\n", + "next: ('tools',)\n", + "approval_target_tool_calls: [{'name': 'extract_poster_image_text', 'args': {'url': 'https://classicbusan.busan.go.kr/product/performance/253216?q=OTdhYjcwN2I0Y2ZiNGQ3NWJmZjg3ZDFiZjFmZjFjMDc%3d'}, 'id': 'call_o6MOuThQgNOmzQ5zPVFaXrJC', 'type': 'tool_call'}]\n" + ] + } + ], + "source": [ + "from graph import graph_with_interrupt\n", + "\n", + "poster_config = {\"recursion_limit\": 12, \"configurable\": {\"thread_id\": \"poster-approval-demo\"}}\n", + "poster_question = (\n", + " \"본문 텍스트 추출은 이미 실패했어. 이 공연 링크의 세부 정보는 긴 포스터 이미지 안에만 있으니 \"\n", + " \"fetch_concert_page 대신 extract_poster_image_text 도구로 포스터 이미지를 승인 후 읽어서 공연 정보를 정리해줘. \"\n", + " \"URL: https://classicbusan.busan.go.kr/product/performance/253216?q=OTdhYjcwN2I0Y2ZiNGQ3NWJmZjg3ZDFiZjFmZjFjMDc%3d\"\n", + ")\n", + "\n", + "for event in graph_with_interrupt.stream(\n", + " {\"messages\": [HumanMessage(content=poster_question)], \"used_tools\": [], \"final_answer\": None},\n", + " config=poster_config,\n", + " stream_mode=\"updates\",\n", + "):\n", + " print(event.keys())\n", + "\n", + "pending_state = graph_with_interrupt.get_state(poster_config)\n", + "print(\"next:\", pending_state.next)\n", + "print(\"approval_target_tool_calls:\", pending_state.values[\"messages\"][-1].tool_calls)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e61bff1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "--- POSTER INFO SUMMARY ---\n", + "[포스터 기반 공연 정보 정리]\n", + "**공연명** \n", + "조성진 체임버 콘서트 (Seong-Jin Cho Chamber Concert)\n", + "\n", + "**일시** \n", + "2026. 07. 17. fri 17:00\n", + "\n", + "**장소** \n", + "부산콘서트홀\n", + "\n", + "**출연진** \n", + "- 조성진 \n", + "- 포스터에 피아노 없이 각기 악기를 든 실내악 출연진 사진이 함께 보이지만, 이름 표기는 포스터에서 선명하게 확인되지 않음\n", + "\n", + "**프로그램/곡명** \n", + "- 포스터 이미지에는 곡명이 보이지 않음 \n", + "- 프로그램 상세는 주변 페이지 텍스트에도 노출되지 않음\n", + "\n", + "**예습에 필요한 핵심 요약** \n", + "- 조성진이 중심이 되는 실내악 공연으로 보임 \n", + "- 부산콘서트홀에서 열리는 클래식 공연 상세 페이지의 포스터이며, “WORLD STAR SERIES” 표기가 있음 \n", + "- 예매 정보로 보이는 좌석 등급 및 가격이 포스터에 함께 표기됨: R 180,000 / S 160,000 / A 140,000 / B 120,000 / C 80,000 / 학생석 10,000\n", + "\n", + "**불확실한 정보** \n", + "- 정확한 편성(함께 연주하는 다른 출연진의 실명) \n", + "- 정확한 프로그램/곡목 \n", + "- 공연 시간 외 상세 러닝타임 \n", + "- 포스터 내 작은 사진들에 대한 출처/관계 설명\n", + "\n", + "참고 이미지: https://classicbusan.busan.go.kr/Down/Rent/202605/e2ee0e93-35ce-4d73-86fd-4c208c43205e.jpg\n", + "참고: 포스터 이미지와 주변 페이지 텍스트를 함께 사용해 정리했습니다.\n" + ] + } + ], + "source": [ + "# 사용자가 포스터 OCR을 승인했다고 가정하면 None 입력으로 같은 thread를 재개합니다.\n", + "# 내부 실행 로그는 숨기고, 사용자가 최종적으로 볼 포스터 정보 정리만 출력합니다.\n", + "for _ in graph_with_interrupt.stream(None, config=poster_config, stream_mode=\"updates\"):\n", + " pass\n", + "\n", + "resumed_state = graph_with_interrupt.get_state(poster_config)\n", + "tool_messages = [message for message in resumed_state.values[\"messages\"] if getattr(message, \"type\", None) == \"tool\"]\n", + "\n", + "print(\"--- POSTER INFO SUMMARY ---\")\n", + "if tool_messages:\n", + " print(tool_messages[-1].content[:2500])\n", + "else:\n", + " print(\"포스터 OCR 결과를 찾지 못했습니다.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cef2d302", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "- week1의 클래식 공연 예습 ReAct 그래프를 같은 도메인으로 재사용했습니다.\n", + "- `InMemorySaver` checkpointer를 `compile()`에 결합했습니다.\n", + "- 같은 `thread_id`의 두 번째 질문과 다른 `thread_id`의 같은 질문을 비교했습니다.\n", + "- `get_state_history()` 결과를 PR에 첨부할 수 있도록 정리했습니다.\n", + "- 실행제어 패턴으로 `interrupt_before=[\"tools\"]`를 적용했습니다.\n", + "- 포스터 이미지에 공연 상세 정보가 들어있는 경우, 승인 후 비전 OCR을 수행하고 사용자에게는 정리된 공연 정보만 보여주도록 구성했습니다.\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "rag-agent-study (3.12.7)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}