diff --git a/assignments/pykido/week2/graph.png b/assignments/pykido/week2/graph.png new file mode 100644 index 0000000..28ad3c0 Binary files /dev/null and b/assignments/pykido/week2/graph.png differ diff --git a/assignments/pykido/week2/graph.py b/assignments/pykido/week2/graph.py new file mode 100644 index 0000000..6a4b840 --- /dev/null +++ b/assignments/pykido/week2/graph.py @@ -0,0 +1,110 @@ +import os +from typing import Literal + +from dotenv import find_dotenv, load_dotenv +from langchain.chat_models import init_chat_model +from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode +from langgraph.types import Command, interrupt + +from schema import ReActAnswer +from state import AgentState +from tools import TOOLS + +load_dotenv(find_dotenv(), override=True) +os.environ["LANGSMITH_TRACING"] = "false" +os.environ["LANGSMITH_TRACING_V2"] = "false" + +SYSTEM_PROMPT = """당신은 알고리즘 코딩 테스트 준비를 돕는 학습 코치입니다. + +도구: +- get_algorithm_pattern(name): 패턴 설명 +- recommend_problems(topic, level, problem_id): 프로그래머스 문제 추천/조회 +- review_solution(problem_id, user_code): 풀이 리뷰용 rubric (코드는 직접 채점하지 않으므로 rubric 받은 뒤 사용자 코드와 비교해 리뷰 작성) + +원칙: +1. 개념/패턴 → get_algorithm_pattern +2. 문제 추천/검색 → recommend_problems +3. 풀이 리뷰 요청 → review_solution(problem_id, user_code) 호출 후 rubric 기반 비교 +4. 도구 결과만 신뢰. mock DB miss 면 솔직히 말할 것 +5. 이전 대화에서 다룬 문제·패턴을 기억하고 맥락에 맞게 이어서 답할 것 +6. 한국어 답변, 코드/패턴 키는 영어 원형 +""" + +HINT_DIRECTIVE = ( + "학습자가 힌트만 요청했습니다. review_solution을 다시 호출하지 말고, " + "레퍼런스 정답·접근을 그대로 공개하지 마세요. 막힌 지점을 스스로 찾도록 " + "방향성 힌트 1~2개만 한국어로 제시하세요." +) + +model = init_chat_model("openai:gpt-4.1-mini", temperature=0.1) +model_with_tools = model.bind_tools(TOOLS) +structured_model = model.with_structured_output(ReActAnswer) + + +def agent_node(state: AgentState) -> dict: + messages = state["messages"] + if not any(isinstance(m, SystemMessage) for m in messages): + messages = [SystemMessage(content=SYSTEM_PROMPT), *messages] + return {"messages": [model_with_tools.invoke(messages)]} + + +def review_gate(state: AgentState) -> Command[Literal["tools", "agent"]]: + last = state["messages"][-1] + decision = interrupt( + { + "question": "풀이 리뷰를 어떻게 받을까요?", + "options": {"hint": "막힌 부분 힌트만", "full": "레퍼런스 기반 전체 리뷰"}, + } + ) + if str(decision) == "full": + return Command(goto="tools") + tool_messages = [ + ToolMessage(content=HINT_DIRECTIVE, tool_call_id=tc["id"], name=tc["name"]) + for tc in last.tool_calls + ] + return Command(goto="agent", update={"messages": tool_messages}) + + +def format_answer_node(state: AgentState) -> dict: + prompt = ( + "바로 앞 assistant 답변을 ReActAnswer 스키마로 정리하세요. " + "answer에는 그 답변 내용을 담되 직전 질문과 무관한 이전 추천·설명까지 다시 끌어오지는 마세요. " + "used_tools: 실제 호출한 도구만 / sources: 근거 ID·패턴 키 / " + "confidence: 도구 직접 근거 0.85+, 추측이면 ≤0.7" + ) + answer = structured_model.invoke([*state["messages"], HumanMessage(content=prompt)]) + return {"final_answer": answer.model_dump()} + + +def route_after_agent(state: AgentState) -> Literal["review_gate", "tools", "format_answer"]: + last = state["messages"][-1] + if not last.tool_calls: + return "format_answer" + if any(tc["name"] == "review_solution" for tc in last.tool_calls): + return "review_gate" + return "tools" + + +def build_graph(): + builder = StateGraph(AgentState) + builder.add_node("agent", agent_node) + builder.add_node("review_gate", review_gate) + builder.add_node("tools", ToolNode(TOOLS)) + builder.add_node("format_answer", format_answer_node) + + builder.add_edge(START, "agent") + builder.add_conditional_edges( + "agent", + route_after_agent, + {"review_gate": "review_gate", "tools": "tools", "format_answer": "format_answer"}, + ) + builder.add_edge("tools", "agent") + builder.add_edge("format_answer", END) + + return builder.compile(checkpointer=InMemorySaver()) + + +graph = build_graph() diff --git a/assignments/pykido/week2/run.ipynb b/assignments/pykido/week2/run.ipynb new file mode 100644 index 0000000..f62cbf3 --- /dev/null +++ b/assignments/pykido/week2/run.ipynb @@ -0,0 +1,461 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1a80aa88", + "metadata": {}, + "source": [ + "# Week 2: 알고리즘 코치에 State · Memory · Control 더하기\n", + "\n", + "1주차 알고리즘 코딩테스트 코치(`StateGraph` ReAct 그래프)를 그대로 가져와, 한 번 실행되고 끝나던 그래프를 상태가 유지되는 workflow로 확장했다.\n", + "\n", + "1주차 대비 달라진 점\n", + "\n", + "- 노트북 단일 파일을 `state.py` / `schema.py` / `tools.py` / `graph.py` 모듈로 분리 (1주차 PR 리뷰 반영)\n", + "- `InMemorySaver` checkpointer + `thread_id`로 대화 상태를 분리·복원\n", + "- 풀이 정답을 공개하기 직전 `interrupt()`로 멈춰 학습자가 *힌트만* 받을지 *전체 리뷰*를 받을지 선택 (HITL)\n", + "\n", + "도구 3개(`get_algorithm_pattern`, `recommend_problems`, `review_solution`)와 응답 스키마 `ReActAnswer`는 1주차와 동일하다." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d1065f35", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:15.320703Z", + "iopub.status.busy": "2026-05-24T16:23:15.320631Z", + "iopub.status.idle": "2026-05-24T16:23:15.970248Z", + "shell.execute_reply": "2026-05-24T16:23:15.969793Z" + } + }, + "outputs": [], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "from langgraph.types import Command\n", + "\n", + "from graph import graph" + ] + }, + { + "cell_type": "markdown", + "id": "5f3ee543", + "metadata": {}, + "source": [ + "## 그래프 구조\n", + "\n", + "`agent`가 도구 호출 여부를 정하면 `route_after_agent`가 세 갈래로 나눈다.\n", + "\n", + "- 도구 호출 없음 → `format_answer` (구조화 응답)\n", + "- `review_solution` 호출 포함 → `review_gate` (HITL 검토)\n", + "- 그 외 도구 호출 → `tools` (`ToolNode`)\n", + "\n", + "`review_gate`는 `interrupt()`로 멈춘 뒤 선택에 따라 `tools`(전체 리뷰)나 `agent`(힌트만)로 `Command(goto=...)` 라우팅한다." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "db7f285f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:15.971506Z", + "iopub.status.busy": "2026-05-24T16:23:15.971435Z", + "iopub.status.idle": "2026-05-24T16:23:15.974675Z", + "shell.execute_reply": "2026-05-24T16:23:15.974308Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from IPython.display import Image\n", + "\n", + "Image(filename=\"graph.png\")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "06676035", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:15.975950Z", + "iopub.status.busy": "2026-05-24T16:23:15.975859Z", + "iopub.status.idle": "2026-05-24T16:23:15.978669Z", + "shell.execute_reply": "2026-05-24T16:23:15.978344Z" + } + }, + "outputs": [], + "source": [ + "def show(state):\n", + " answer = state.get(\"final_answer\")\n", + " if not answer:\n", + " return\n", + " print(f\"used_tools={answer['used_tools']} sources={answer['sources']} confidence={answer['confidence']}\")\n", + " print(answer[\"answer\"])\n", + "\n", + "\n", + "def ask(question, thread_id):\n", + " config = {\"configurable\": {\"thread_id\": thread_id}}\n", + " print(f\"\\nQ [{thread_id}] {question}\")\n", + " print(\"-\" * 80)\n", + " state = graph.invoke({\"messages\": [HumanMessage(content=question)], \"final_answer\": None}, config)\n", + " if \"__interrupt__\" in state:\n", + " payload = state[\"__interrupt__\"][0].value\n", + " print(f\"INTERRUPT · {payload['question']} {payload['options']}\")\n", + " return\n", + " show(state)\n", + "\n", + "\n", + "def resume(choice, thread_id):\n", + " config = {\"configurable\": {\"thread_id\": thread_id}}\n", + " print(f\"resume({choice!r})\")\n", + " print(\"-\" * 80)\n", + " show(graph.invoke(Command(resume=choice), config))" + ] + }, + { + "cell_type": "markdown", + "id": "74fd914d", + "metadata": {}, + "source": [ + "## 1. 같은 `thread_id` — 대화 맥락을 기억한다\n", + "\n", + "`alice` thread 하나로 세 질문을 이어서 던진다. 두 번째·세 번째 질문은 앞 발화에서 다룬 문제를 직접 지칭하지 않고 \"방금 그 문제\", \"그거\"로만 가리킨다. checkpointer가 이전 메시지를 복원하므로 맥락이 이어진다." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9b313a44", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:15.980037Z", + "iopub.status.busy": "2026-05-24T16:23:15.979975Z", + "iopub.status.idle": "2026-05-24T16:23:38.985336Z", + "shell.execute_reply": "2026-05-24T16:23:38.984537Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Q [alice] binary-search 토픽에 Lv.3 문제 하나 추천해줘.\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "used_tools=['recommend_problems'] sources=['pgs-43238'] confidence=0.95\n", + "Lv.3 난이도의 binary-search 토픽 문제로 \"입국심사\" 문제를 추천합니다. 문제 링크: https://school.programmers.co.kr/learn/courses/30/lessons/43238. 문제 설명: n명과 각 입국 심사대 처리 시간 times[]가 주어질 때, 모두 심사하기 위한 최소 시간을 구하는 문제입니다. 답이 시간 t에 대해 단조성을 가지므로 parametric binary search 기법을 사용합니다.\n", + "\n", + "Q [alice] 방금 추천한 그 문제, 핵심 접근법만 한 줄로 알려줘.\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "used_tools=['binary-search'] sources=['pgs-43238', 'binary-search'] confidence=0.9\n", + "입국심사 문제의 핵심 접근법은, 모든 심사가 끝나는 최소 시간을 이분 탐색으로 찾되, 주어진 시간 t에 대해 심사 가능한 인원 수가 n명 이상인지 단조성을 이용해 판단하는 parametric binary search입니다.\n", + "\n", + "Q [alice] 그거 풀 때 자주 하는 실수도 짚어줘.\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "used_tools=['get_algorithm_pattern'] sources=['pgs-43238', 'binary-search'] confidence=0.7\n", + "\"입국심사\" 문제를 풀 때 자주 하는 실수는 다음과 같습니다:\n", + "\n", + "1. 이분 탐색 범위 설정 실수: 최소 시간(lo)을 0으로, 최대 시간(hi)을 충분히 큰 값(예: 가장 오래 걸리는 심사대 시간 × n)으로 정확히 잡지 않아 탐색이 제대로 이루어지지 않는 경우가 있습니다.\n", + "\n", + "2. 단조성 조건 판단 오류: 주어진 시간 t에 대해 심사 가능한 인원 수를 계산할 때, n명 이상인지 정확히 비교하지 않거나, 등호 포함 여부를 헷갈려 답이 틀리는 경우가 있습니다.\n", + "\n", + "3. 이분 탐색 종료 조건 혼동: lo <= hi 구간을 정확히 유지하지 않고 lo < hi로 하여 무한 루프나 오답이 발생할 수 있습니다.\n", + "\n", + "4. 시간 초과 주의: 심사 가능한 인원 수 계산 시 반복문을 잘못 써서 시간 복잡도가 커지는 실수를 합니다. (times 배열을 한 번만 순회하며 계산해야 합니다.)\n", + "\n", + "이 점들 유의하면 문제 해결에 도움이 됩니다.\n" + ] + } + ], + "source": [ + "ask(\"binary-search 토픽에 Lv.3 문제 하나 추천해줘.\", \"alice\")\n", + "ask(\"방금 추천한 그 문제, 핵심 접근법만 한 줄로 알려줘.\", \"alice\")\n", + "ask(\"그거 풀 때 자주 하는 실수도 짚어줘.\", \"alice\")" + ] + }, + { + "cell_type": "markdown", + "id": "5881c0f4", + "metadata": {}, + "source": [ + "## 2. 다른 `thread_id` — 맥락이 분리된다\n", + "\n", + "`bob` thread는 위 대화를 모른다. `alice`에서 추천받은 문제를 가리켜도 무슨 문제인지 알 수 없어, 어떤 문제인지 되묻는다." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a14eef1d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:38.987754Z", + "iopub.status.busy": "2026-05-24T16:23:38.987500Z", + "iopub.status.idle": "2026-05-24T16:23:41.673952Z", + "shell.execute_reply": "2026-05-24T16:23:41.673257Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Q [bob] 방금 내가 추천받은 그 문제 번호가 뭐였지? 그거 다시 복습하고 싶어.\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "used_tools=[] sources=[] confidence=0.7\n", + "제가 이전 대화 내용을 확인해보니, 아직 문제 추천을 해드린 적이 없습니다. 어떤 주제나 난이도의 문제를 복습하고 싶으신지 알려주시면, 그에 맞는 문제를 추천해드리고 복습을 도와드릴게요!\n" + ] + } + ], + "source": [ + "ask(\"방금 내가 추천받은 그 문제 번호가 뭐였지? 그거 다시 복습하고 싶어.\", \"bob\")" + ] + }, + { + "cell_type": "markdown", + "id": "de4bce76", + "metadata": {}, + "source": [ + "## 3. interrupt (HITL) — 정답 공개 전에 멈춘다 · 전체 리뷰\n", + "\n", + "풀이 리뷰를 요청하면 `review_gate`가 `review_solution` 호출 직전에 멈추고 선택지를 제시한다. `full`로 재개하면 레퍼런스 rubric을 받아 전체 리뷰를 작성한다." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8d2e3c91", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:41.675255Z", + "iopub.status.busy": "2026-05-24T16:23:41.675158Z", + "iopub.status.idle": "2026-05-24T16:23:49.044667Z", + "shell.execute_reply": "2026-05-24T16:23:49.043628Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Q [review-full] pgs-43238 입국심사 풀이 리뷰해줘.\n", + "\n", + "def solution(n, times):\n", + " t = 0\n", + " while True:\n", + " t += 1\n", + " if sum(t // x for x in times) >= n:\n", + " return t\n", + "\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INTERRUPT · 풀이 리뷰를 어떻게 받을까요? {'hint': '막힌 부분 힌트만', 'full': '레퍼런스 기반 전체 리뷰'}\n", + "resume('full')\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "used_tools=['review_solution'] sources=['pgs-43238', 'binary-search'] confidence=0.9\n", + "사용자 코드는 시간 t를 1씩 증가시키며 처리 가능한 인원 수를 계산하는 선형 탐색 방식으로, n이 매우 클 경우 시간 초과가 발생합니다. 문제의 핵심은 처리 인원 수가 시간에 대해 단조 증가하므로 이분 탐색으로 최소 시간을 찾는 것입니다. 따라서 효율적인 풀이를 위해서는 이분 탐색을 적용해 탐색 범위를 좁히는 방식으로 개선해야 합니다.\n" + ] + } + ], + "source": [ + "review_code = '''pgs-43238 입국심사 풀이 리뷰해줘.\n", + "\n", + "def solution(n, times):\n", + " t = 0\n", + " while True:\n", + " t += 1\n", + " if sum(t // x for x in times) >= n:\n", + " return t\n", + "'''\n", + "\n", + "ask(review_code, \"review-full\")\n", + "resume(\"full\", \"review-full\")" + ] + }, + { + "cell_type": "markdown", + "id": "1b92ccd9", + "metadata": {}, + "source": [ + "## 4. interrupt (HITL) — 힌트만\n", + "\n", + "같은 요청을 `hint`로 재개하면 `review_solution`을 실행하지 않고 정답 접근을 감춘 채 방향성 힌트만 준다. `used_tools`가 비어 있는 것으로 레퍼런스를 열지 않았음을 확인할 수 있다." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "97201c00", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:49.047765Z", + "iopub.status.busy": "2026-05-24T16:23:49.047543Z", + "iopub.status.idle": "2026-05-24T16:23:55.023780Z", + "shell.execute_reply": "2026-05-24T16:23:55.023375Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Q [review-hint] pgs-43238 입국심사 풀이 리뷰해줘.\n", + "\n", + "def solution(n, times):\n", + " t = 0\n", + " while True:\n", + " t += 1\n", + " if sum(t // x for x in times) >= n:\n", + " return t\n", + "\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INTERRUPT · 풀이 리뷰를 어떻게 받을까요? {'hint': '막힌 부분 힌트만', 'full': '레퍼런스 기반 전체 리뷰'}\n", + "resume('hint')\n", + "--------------------------------------------------------------------------------\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "used_tools=[] sources=['pgs-43238'] confidence=0.7\n", + "입국심사 문제는 이분 탐색을 활용하는 것이 핵심입니다. 현재 코드는 시간을 1씩 늘리며 처리 가능한 사람 수를 계산하는데, 이는 n과 times가 클 때 매우 비효율적입니다. 힌트로는 (1) 시간을 1씩 증가시키지 말고 가능한 시간 범위를 이분 탐색으로 좁혀가면서 최소 시간을 찾아보세요. (2) 중간 시간에서 처리 가능한 사람 수를 계산해 n과 비교하며 탐색 범위를 조절하는 방식을 생각해보세요.\n" + ] + } + ], + "source": [ + "ask(review_code, \"review-hint\")\n", + "resume(\"hint\", \"review-hint\")" + ] + }, + { + "cell_type": "markdown", + "id": "5522519c", + "metadata": {}, + "source": [ + "## 5. State history — 누적된 스냅샷\n", + "\n", + "`alice` thread는 매 superstep마다 상태 스냅샷이 쌓인다. time travel과 디버깅의 출발점이다." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "fc520d2e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-24T16:23:55.025250Z", + "iopub.status.busy": "2026-05-24T16:23:55.025145Z", + "iopub.status.idle": "2026-05-24T16:23:55.029441Z", + "shell.execute_reply": "2026-05-24T16:23:55.029014Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "alice thread snapshots: 16\n", + "누적 메시지 수: 10\n" + ] + } + ], + "source": [ + "history = list(graph.get_state_history({\"configurable\": {\"thread_id\": \"alice\"}}))\n", + "print(f\"alice thread snapshots: {len(history)}\")\n", + "print(f\"누적 메시지 수: {len(history[0].values['messages'])}\")" + ] + }, + { + "cell_type": "markdown", + "id": "63cb861c", + "metadata": {}, + "source": [ + "## 정리\n", + "\n", + "- 같은 `thread_id`는 앞 발화를 복원해 \"방금 그 문제\" 같은 지시어를 해석하고, 다른 `thread_id`는 맥락이 분리된다.\n", + "- 제어 패턴으로 **interrupt(HITL)** 를 골랐다. 학습 코치 도메인에서 *정답 공개*는 되돌릴 수 없는 행동이라, 그 직전에 학습자가 개입할 지점을 두는 것이 자연스럽기 때문이다. 단순 패턴 조회·문제 추천은 멈추지 않는다.\n", + "- `Command(resume=...)`로 같은 thread에서 이어 실행하며, `full`/`hint` 선택이 이후 그래프 경로(`tools` vs `agent`)를 바꾼다." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/assignments/pykido/week2/schema.py b/assignments/pykido/week2/schema.py new file mode 100644 index 0000000..ef4beaf --- /dev/null +++ b/assignments/pykido/week2/schema.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + + +class ReActAnswer(BaseModel): + """ReAct agent의 최종 답변 형식.""" + + answer: str = Field(description="한국어 마크다운 답변. 코드/패턴 키는 영어 원형.") + used_tools: list[str] = Field(default_factory=list, description="실제 호출된 도구 이름들.") + sources: list[str] = Field(default_factory=list, description="근거 식별자 (예: 'pgs-43238', 'binary-search').") + confidence: float = Field(ge=0.0, le=1.0, description="도구 결과 직접 근거면 0.85+, 추측 섞이면 ≤0.7.") diff --git a/assignments/pykido/week2/state.py b/assignments/pykido/week2/state.py new file mode 100644 index 0000000..8d4c5fd --- /dev/null +++ b/assignments/pykido/week2/state.py @@ -0,0 +1,7 @@ +from typing import Optional + +from langgraph.graph import MessagesState + + +class AgentState(MessagesState): + final_answer: Optional[dict] diff --git a/assignments/pykido/week2/tools.py b/assignments/pykido/week2/tools.py new file mode 100644 index 0000000..bb2bb79 --- /dev/null +++ b/assignments/pykido/week2/tools.py @@ -0,0 +1,302 @@ +from langchain_core.tools import tool + + +PROBLEMS: dict[str, dict] = { + "pgs-43238": { + "id": "pgs-43238", + "title": "입국심사", + "platform": "Programmers", + "level": "Lv.3", + "topics": ["binary-search"], + "url": "https://school.programmers.co.kr/learn/courses/30/lessons/43238", + "description": ( + "n명과 각 입국 심사대 처리 시간 times[]가 주어질 때, 모두 심사하기 위한 최소 시간을 구하는 문제. " + "답이 시간 t에 대해 단조 → parametric binary search." + ), + "reference_approach": ( + "f(t) = sum(t // x for x in times)는 t에 대해 단조 증가. f(t) >= n 인 최소 t를 이분 탐색." + ), + "reference_complexity": "O(M log(max(times) * n)), M = len(times)", + "key_checkpoints": [ + "lo=1, hi=max(times)*n 으로 범위를 충분히 크게", + "단조성을 명시 (시간↑ → 처리 인원↑)", + "f(mid) >= n 일 때 hi=mid-1 (lower bound)", + ], + "common_pitfalls": [ + "lo=0 이면 무한 루프", + "hi=max(times) 만 두면 사람 수 많을 때 답 못 찾음", + "O(N) 선형 탐색은 N=10^9 스케일에서 TLE", + ], + }, + "pgs-43236": { + "id": "pgs-43236", + "title": "징검다리", + "platform": "Programmers", + "level": "Lv.4", + "topics": ["binary-search"], + "url": "https://school.programmers.co.kr/learn/courses/30/lessons/43236", + "description": "바위 좌표가 주어지고 n개를 제거할 수 있을 때 가장 짧은 점프 거리의 최댓값. parametric search.", + "reference_approach": "최소 점프 거리 d 이분 탐색. f(d) = 거리 d 미만이 되는 인접 쌍을 그리디 제거 횟수.", + "reference_complexity": "O(R log(distance)), R = len(rocks)", + "key_checkpoints": [ + "바위 정렬", + "제거 시뮬레이션을 그리디로 (마지막 위치 추적)", + "upper bound 형태 — 답=lo-1 또는 hi 처리", + ], + "common_pitfalls": [ + "distance 배열을 정렬하는 잘못된 접근 (단조성 깨짐)", + "d 범위를 좌표값이 아닌 인덱스로 잡음", + ], + }, + "pgs-43165": { + "id": "pgs-43165", + "title": "타겟 넘버", + "platform": "Programmers", + "level": "Lv.2", + "topics": ["bfs-dfs"], + "url": "https://school.programmers.co.kr/learn/courses/30/lessons/43165", + "description": "각 원소 앞에 +/-를 붙여 target을 만드는 경우의 수. DFS/백트래킹 입문.", + "reference_approach": "각 i에서 +/- 이진 분기 DFS. i==len 도달 시 누적합==target 검사.", + "reference_complexity": "O(2^N)", + "key_checkpoints": [ + "base case (i == len(numbers))", + "+ 와 - 두 분기 모두 호출", + "DP 메모이제이션 ((i, sum)) 도 가능", + ], + "common_pitfalls": [ + "DFS를 BFS로 짜며 메모리 폭발", + "누적합을 list append/pop 부수효과로", + ], + }, + "pgs-118667": { + "id": "pgs-118667", + "title": "두 큐 합 같게 만들기", + "platform": "Programmers", + "level": "Lv.2", + "topics": ["two-pointers", "queue"], + "url": "https://school.programmers.co.kr/learn/courses/30/lessons/118667", + "description": "두 큐 합을 같게 만드는 최소 작업 횟수. pop/insert를 두 포인터처럼.", + "reference_approach": "deque 1개로 합쳐 보고 합이 큰 쪽에서 pop → 작은 쪽에 push. 종료 상한 4*N.", + "reference_complexity": "O(N)", + "key_checkpoints": [ + "전체 합 홀수면 -1", + "두 합을 O(1) 로 갱신 (재계산 X)", + "4*N 초과 시 -1", + ], + "common_pitfalls": [ + "매 step sum() 호출로 O(N^2) → TLE", + "list pop(0) 으로 O(N^2)", + "종료 조건 누락", + ], + }, + "pgs-67258": { + "id": "pgs-67258", + "title": "보석 쇼핑", + "platform": "Programmers", + "level": "Lv.3", + "topics": ["sliding-window", "hash-map"], + "url": "https://school.programmers.co.kr/learn/courses/30/lessons/67258", + "description": "모든 종류의 보석을 포함하는 가장 짧은 연속 구간. 가변 슬라이딩 윈도우.", + "reference_approach": "left/right 두 포인터로 윈도우 늘리며 dict 카운트 갱신, 모든 종류 포함되면 left 줄여 최소화.", + "reference_complexity": "O(N)", + "key_checkpoints": [ + "전체 보석 종류 수 미리 계산 (set)", + "left 이동 시 dict 카운트 0이면 키 삭제", + "답 [s+1, e+1] 1-index 반환", + ], + "common_pitfalls": [ + "left 이동을 if가 아닌 while로 좁혀야 최소 윈도우", + ], + }, + "pgs-42898": { + "id": "pgs-42898", + "title": "등굣길", + "platform": "Programmers", + "level": "Lv.3", + "topics": ["dp"], + "url": "https://school.programmers.co.kr/learn/courses/30/lessons/42898", + "description": "M x N 격자에서 (1,1)→(M,N) 오른쪽/아래만 이동, 물웅덩이 피해 가는 경로 수 (mod 1e9+7).", + "reference_approach": "dp[r][c] = dp[r-1][c] + dp[r][c-1]. 물웅덩이는 0. 매 갱신마다 mod.", + "reference_complexity": "O(M * N)", + "key_checkpoints": [ + "dp 인덱싱 (1-based vs 0-based) 일관성", + "물웅덩이를 갱신 전에 0", + "매 갱신마다 mod", + ], + "common_pitfalls": [ + "DFS 재귀로 짜면 메모이제이션 없으면 지수", + ], + }, +} + +PATTERNS: dict[str, dict] = { + "binary-search": { + "name_en": "Binary Search", + "name_ko": "이분 탐색", + "when_to_use": "정렬 배열에서 값 찾기, 또는 답이 단조성을 가지는 최적화 (parametric search).", + "complexity": "O(log N)", + "template": ( + "def binary_search(arr, target):\n" + " lo, hi = 0, len(arr) - 1\n" + " while lo <= hi:\n" + " mid = (lo + hi) // 2\n" + " if arr[mid] == target: return mid\n" + " elif arr[mid] < target: lo = mid + 1\n" + " else: hi = mid - 1\n" + " return -1" + ), + "common_pitfalls": [ + "lo <= hi vs lo < hi 혼동 (전자가 닫힌 구간에 안전)", + "parametric search 단조성 증명 누락", + "lower/upper bound 모호 → bisect 사용", + ], + }, + "sliding-window": { + "name_en": "Sliding Window", + "name_ko": "슬라이딩 윈도우", + "when_to_use": "연속 부분배열/문자열의 통계. 가변 크기는 'longest/shortest with X' 패턴.", + "complexity": "O(N)", + "template": ( + "def longest_unique_substring(s):\n" + " seen = {}\n" + " left = best = 0\n" + " for right, c in enumerate(s):\n" + " if c in seen and seen[c] >= left:\n" + " left = seen[c] + 1\n" + " seen[c] = right\n" + " best = max(best, right - left + 1)\n" + " return best" + ), + "common_pitfalls": [ + "left 갱신 누락으로 윈도우 안 좁혀짐", + "빠지는 원소 통계 미반영 (해시맵 누수)", + ], + }, + "two-pointers": { + "name_en": "Two Pointers", + "name_ko": "투 포인터", + "when_to_use": "정렬 배열에서 양 끝에서 좁혀가며 쌍/구간 찾기, 또는 동방향 다른 속도.", + "complexity": "O(N)", + "template": ( + "def two_sum_sorted(arr, target):\n" + " lo, hi = 0, len(arr) - 1\n" + " while lo < hi:\n" + " s = arr[lo] + arr[hi]\n" + " if s == target: return (lo, hi)\n" + " elif s < target: lo += 1\n" + " else: hi -= 1\n" + " return None" + ), + "common_pitfalls": [ + "정렬 안 된 배열에 적용", + "포인터 이동 조건 반전으로 무한 루프", + ], + }, + "bfs-dfs": { + "name_en": "BFS / DFS", + "name_ko": "너비/깊이 우선 탐색", + "when_to_use": "그래프/격자 도달 가능, 가중치 1 최단 거리 (BFS), 연결 요소.", + "complexity": "O(V + E)", + "template": ( + "from collections import deque\n" + "def bfs(graph, start):\n" + " visited = {start}\n" + " q = deque([start])\n" + " while q:\n" + " node = q.popleft()\n" + " for nxt in graph[node]:\n" + " if nxt not in visited:\n" + " visited.add(nxt); q.append(nxt)" + ), + "common_pitfalls": [ + "방문 처리를 pop 시점에 해서 중복 push", + "DFS 재귀 한도(1000) 초과 → sys.setrecursionlimit", + ], + }, + "dp": { + "name_en": "Dynamic Programming", + "name_ko": "동적 계획법", + "when_to_use": "최적 부분 구조 + 중복 부분 문제. 부분 답을 재사용.", + "complexity": "보통 상태 수 × 전이 비용", + "template": ( + "# bottom-up 격자 DP\n" + "def grid_paths(m, n, blocked):\n" + " dp = [[0]*(n+1) for _ in range(m+1)]\n" + " dp[1][1] = 0 if (1,1) in blocked else 1\n" + " for r in range(1, m+1):\n" + " for c in range(1, n+1):\n" + " if (r, c) in blocked: dp[r][c] = 0; continue\n" + " if (r, c) != (1, 1):\n" + " dp[r][c] = dp[r-1][c] + dp[r][c-1]\n" + " return dp[m][n]" + ), + "common_pitfalls": [ + "top-down에서 메모이제이션 누락", + "상태 정의 모호 (필요 변수 누락)", + "mod 연산 누락", + ], + }, +} + + +@tool +def get_algorithm_pattern(pattern_name: str) -> dict: + """알고리즘 패턴의 설명/시간복잡도/템플릿/실수 모음을 조회한다. + + Args: + pattern_name: 'binary-search', 'sliding-window', 'two-pointers', 'bfs-dfs', 'dp' 중 하나. + """ + if pattern_name not in PATTERNS: + return {"error": f"pattern '{pattern_name}' not found", "available_patterns": list(PATTERNS)} + return PATTERNS[pattern_name] + + +@tool +def recommend_problems( + topic: str | None = None, + level: str | None = None, + problem_id: str | None = None, +) -> list[dict]: + """프로그래머스 문제를 토픽/레벨로 추천하거나 ID로 단건 조회한다. + + Args: + topic: 토픽 키 ('binary-search', 'sliding-window', 'two-pointers', 'bfs-dfs', 'dp', 'hash-map', 'queue' 등). + level: 'Lv.2', 'Lv.3' 등 레벨 부분 문자열. + problem_id: 'pgs-<번호>' 지정 시 단건 조회 (다른 필터 무시). + """ + def project(p): + return {k: p[k] for k in ("id", "title", "level", "topics", "url", "description")} + + if problem_id is not None: + return [project(PROBLEMS[problem_id])] if problem_id in PROBLEMS else [] + return [ + project(p) for p in PROBLEMS.values() + if (not topic or topic in p["topics"]) + and (not level or level.lower() in p["level"].lower()) + ] + + +@tool +def review_solution(problem_id: str, user_code: str) -> dict: + """풀이 코드 리뷰용 reference rubric을 가져온다. + + 이 도구는 코드를 실행하지 않는다. 정답 접근법/복잡도/체크포인트/실수 목록을 반환하므로 + 호출자(=agent)가 user_code와 rubric을 비교해 직접 리뷰를 작성한다. + """ + if problem_id not in PROBLEMS: + return {"error": f"problem_id '{problem_id}' not found", "available_ids": list(PROBLEMS)} + p = PROBLEMS[problem_id] + return { + "problem_id": p["id"], + "title": p["title"], + "level": p["level"], + "topics": p["topics"], + "reference_approach": p["reference_approach"], + "reference_complexity": p["reference_complexity"], + "key_checkpoints": p["key_checkpoints"], + "common_pitfalls": p["common_pitfalls"], + "user_code_excerpt": user_code[:600], + } + + +TOOLS = [get_algorithm_pattern, recommend_problems, review_solution]