diff --git a/README.md b/README.md index 5157847..5b10001 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ $ uv sync --package #### 5. LangGraph 앱 실행 ```bash -$ uvx --from "langgraph-cli[inmem]" --with-editable . langgraph dev +$ uv run langgraph dev ``` ### 서버가 실행되면 다음 URL에서 접근할 수 있습니다: @@ -137,4 +137,4 @@ $ git commit -m "your commit message" > > - pre-commit은 커밋 전에 자동으로 실행되며, 검사에 실패하면 커밋이 중단됩니다. 모든 검사를 통과해야만 커밋이 완료됩니다. > - VSCode나 Cursor의 Git Graph를 사용하여 커밋할 때도 pre-commit이 자동으로 실행됩니다. -> - Git 클라이언트와 관계없이 모든 커밋 시점에서 pre-commit이 동작합니다. \ No newline at end of file +> - Git 클라이언트와 관계없이 모든 커밋 시점에서 pre-commit이 동작합니다. diff --git a/agents/management/modules/chains.py b/agents/management/modules/chains.py deleted file mode 100644 index 43b64ff..0000000 --- a/agents/management/modules/chains.py +++ /dev/null @@ -1,51 +0,0 @@ -"""LangChain 체인을 설정하는 함수 모듈 - -LCEL(LangChain Expression Language)을 사용하여 체인을 구성합니다. -기본적으로 modules.prompt 템플릿과 modules.models 모듈을 사용하여 LangChain 체인을 생성합니다. - -""" - -from langchain.schema.runnable import RunnablePassthrough, RunnableSerializable -from langchain_core.output_parsers import StrOutputParser - -from agents.management.modules.models import get_openai_model -from agents.management.modules.prompts import get_resource_planning_prompt - - -def set_resource_planning_chain() -> RunnableSerializable: - """ - 리소스 계획 수립에 사용할 LangChain 체인을 생성합니다. - - 이 함수는 LCEL(LangChain Expression Language)을 사용하여 체인을 구성합니다. - 체인은 다음 단계로 구성됩니다: - 1. 입력에서 project_id, request_type, query, team_members 등을 추출하여 프롬프트에 전달 - 2. 프롬프트 템플릿에 값을 삽입하여 최종 프롬프트 생성 - 3. LLM을 호출하여 리소스 계획 생성 수행 - 4. 결과를 문자열로 변환 - - 이 함수는 리소스 관리 노드에서 사용됩니다. - - Returns: - RunnableSerializable: 실행 가능한 체인 객체 - """ - # 리소스 계획을 위한 프롬프트 가져오기 - prompt = get_resource_planning_prompt() - # OpenAI 모델 가져오기 - model = get_openai_model() - - # LCEL을 사용하여 체인 구성 - return ( - # 입력에서 필요한 필드 추출 및 프롬프트에 전달 - RunnablePassthrough.assign( - project_id=lambda x: x["project_id"], # 프로젝트 ID 추출 - request_type=lambda x: x["request_type"], # 요청 유형 추출 - query=lambda x: x["query"], # 사용자 쿼리 추출 - team_members=lambda x: x.get("team_members", []), # 팀 구성원 추출 - resources_available=lambda x: x.get( - "resources_available", {} - ), # 가용 리소스 추출 - ) - | prompt # 프롬프트 적용 - | model # LLM 모델 호출 - | StrOutputParser() # 결과를 문자열로 변환 - ) diff --git a/agents/management/modules/conditions.py b/agents/management/modules/conditions.py deleted file mode 100644 index 4a48bdc..0000000 --- a/agents/management/modules/conditions.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -조건부 라우팅 함수 모듈 - -이 모듈은 LangGraph Workflow에서 조건부 라우팅을 처리하는 함수들을 제공합니다. -조건부 라우팅은 Workflow의 다음 단계를 동적으로 결정하는 데 사용됩니다. - -현재는 주석 처리된 예시 코드만 포함되어 있으며, 필요에 따라 실제 구현을 추가할 수 있습니다. -이 예시 코드는 ReAct 패턴에서 LLM의 출력에 따라 다음 노드를 결정하는 라우터 함수를 보여줍니다. - -Workflow가 확장됨에 따라 다양한 조건부 라우팅 함수를 이 모듈에 추가할 수 있습니다. -예를 들어, 콘텐츠 유형에 따른 라우팅, 사용자 요청 유형에 따른 라우팅 등을 구현할 수 있습니다. -""" - -# from typing import Literal - - -# def router(state) -> Literal["__end__", "tools"]: -# """ -# 모델의 출력을 기반으로 다음 노드를 결정하는 라우터 함수 -# -# 이 함수는 LLM의 마지막 메시지를 검사하여 도구 호출이 포함되어 있는지 확인하고, -# 그 결과에 따라 Workflow의 다음 단계를 결정합니다. -# -# 도구 호출이 있으면 "tools" 노드로 라우팅하고, 그렇지 않으면 Workflow를 종료합니다. -# 이는 ReAct 패턴의 일반적인 구현 방식으로, LLM이 도구를 사용해야 할지 또는 -# 최종 응답을 생성해야 할지를 결정하게 합니다. -# -# Args: -# state (State): 현재 Workflow 상태 객체 (메시지 기록 포함) -# -# Returns: -# str: 다음에 실행할 노드의 이름 ("__end__" 또는 "tools") -# -# Raises: -# ValueError: 마지막 메시지가 AIMessage 타입이 아닌 경우 -# -# 예시: -# ```python -# # Workflow에 조건부 에지 추가 -# builder.add_conditional_edges( -# "call_model", # 소스 노드 -# router, # 라우터 함수 -# { -# "__end__": "__end__", # 종료 조건 -# "tools": "execute_tools" # 도구 실행 조건 -# } -# ) -# ``` -# """ -# # 상태에서 마지막 메시지 가져오기 -# last_message = state.messages[-1] -# -# # 메시지 타입 검증 -# if not isinstance(last_message, AIMessage): -# raise ValueError( -# f"Expected AIMessage in output edges, but got {type(last_message).__name__}" -# ) -# -# # 도구 호출 여부에 따른 라우팅 결정 -# if not last_message.tool_calls: -# return "__end__" # 도구 호출이 없으면 Workflow 종료 -# -# # 도구 호출이 있으면 도구 실행 노드로 라우팅 -# return "tools" diff --git a/agents/management/modules/models.py b/agents/management/modules/models.py index b94ad2d..c8592ca 100644 --- a/agents/management/modules/models.py +++ b/agents/management/modules/models.py @@ -1,8 +1,3 @@ -"""모델 설정 함수 모듈 - -기본적으로 사용할 모델 인스턴스를 설정하고 생성하고 반환시킵니다. -""" - from langchain_openai import ChatOpenAI diff --git a/agents/management/modules/nodes.py b/agents/management/modules/nodes.py index eb96438..abbf048 100644 --- a/agents/management/modules/nodes.py +++ b/agents/management/modules/nodes.py @@ -1,48 +1,471 @@ -""" -노드 클래스 모듈 +from agents.base_node import BaseNode +from agents.management.modules.state import ManagementState +from agents.management.modules.prompts import ( + get_instagram_comment_analysis_prompt, + get_instagram_analysis_report_prompt, +) +from langchain_google_genai import ChatGoogleGenerativeAI -해당 클래스 모듈은 각각 노드 클래스가 BaseNode를 상속받아 노드 클래스를 구현하는 모듈입니다. +import json +import os +import requests +from typing import Dict, Any, List +from dotenv import load_dotenv +from datetime import datetime +from pydantic import BaseModel, Field +import sys -아래는 예시입니다. -""" +# Add the project root to sys.path to resolve module import issues for internal modules +project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") +) +sys.path.append(project_root) -from agents.base_node import BaseNode -from agents.management.modules.chains import set_resource_planning_chain -from agents.management.modules.state import ManagementState -class ResourceManagementNode(BaseNode): + + +load_dotenv(override=True) + + +class CommentAnalysis(BaseModel): + """A single analyzed Instagram comment.""" + + comment: str = Field(description="The original comment text.") + sentiment: str = Field( + description="Sentiment of the comment (e.g., '긍정', '부정', '중립')." + ) + comment_type: str = Field( + description="Type of the comment (e.g., '팬댓글', '질문', '비판')." + ) + reply_needed: str = Field( + description="Whether a reply is needed ('예', '아니오', '고려')." + ) + reason: str = Field( + description="Reason for the sentiment, type, and reply_needed assessment." + ) + + +class InstagramCommentsAnalysisOutput(BaseModel): + """The structured output for Instagram comment analysis.""" + + comments: List[CommentAnalysis] = Field( + description="A list of analyzed Instagram comments." + ) + + +class InstagramAnalysisReportOutput(BaseModel): + """The structured output for an Instagram comments analysis report.""" + + report_date: str = Field( + description="The date the report was generated (YYYY-MM-DD-HH-MM)." + ) + total_comments_analyzed: int = Field( + description="Total number of comments analyzed." + ) + summary: str = Field( + description="A comprehensive summary of the Instagram comments analysis." + ) + key_insights: List[str] = Field( + description="Key insights derived from the comment analysis, e.g., trends, recurring themes." + ) + sentiment_distribution: Dict[str, int] = Field( + description="Distribution of sentiments (e.g., {'긍정': X, '부정': Y, '중립': Z})." + ) + comment_type_distribution: Dict[str, int] = Field( + description="Distribution of comment types (e.g., {'팬댓글': A, '질문': B, '비판': C, '기타': D})." + ) + reply_needed_breakdown: Dict[str, int] = Field( + description="Breakdown of comments needing a reply (e.g., {'예': E, '아니오': F, '고려': G})." + ) + action_items: List[str] = Field( + description="Actionable recommendations for community management based on the analysis." + ) + + +class InstagramMediaFetchNode(BaseNode): + """ + 인스타그램 미디어 정보를 가져오는 노드 + """ + + def execute(self, state: ManagementState) -> Dict[str, Any]: + """ + 인스타그램 API를 통해 미디어 정보를 가져오거나 JSON 파일에서 읽어옵니다. + 댓글 수 변경을 감지하고 상태를 업데이트합니다. + """ + print("🔍 [InstagramMediaFetchNode] 실행 중...") + + json_file_path = state["comment_file_path"] + access_token = state["access_token"] + user_id = state["user_id"] + + # JSON 파일에서 이전 데이터 읽기 + previous_data = None + if os.path.exists(json_file_path): + try: + with open(json_file_path, "r", encoding="utf-8") as f: + previous_data = json.load(f) + print( + "📁 [InstagramMediaFetchNode] JSON 파일에서 이전 데이터를 읽어왔습니다." + ) + # print(f"first_media_id: {previous_data['first_media_id']} / previous_comments_count: {previous_data['previous_comments_count']}") + except Exception as e: + print(f"JSON 파일 읽기 오류: {str(e)}") + + # Instagram API에서 최신 미디어 데이터 가져오기 + print( + "📡 [InstagramMediaFetchNode] Instagram API에서 미디어 데이터를 가져오는 중..." + ) + + # 요청할 필드 지정 + fields = "id,caption,media_type,timestamp,username,like_count,comments_count" + + url = f"https://graph.instagram.com/v23.0/{user_id}/media?access_token={access_token}" + params = { + "fields": fields, + } + + try: + response = requests.get(url, params=params) + if response.status_code == 200: + data = response.json() + media_data = data.get("data", []) + + if not media_data: + return {"response": "미디어 데이터가 없습니다."} + + # 첫 번째 미디어 정보 + first_media = media_data[0] + first_media_id = first_media["id"] + current_comments_count = first_media["comments_count"] + + print( + f"📊 [InstagramMediaFetchNode] 첫 번째 미디어 {first_media_id}의 댓글 수: {current_comments_count}" + ) + + # 이전 데이터와 비교 + has_changes = False + previous_comments_count = 0 + + if previous_data: + previous_comments_count = previous_data.get( + "previous_comments_count", 0 + ) + has_changes = current_comments_count != previous_comments_count + print( + f"📊 [InstagramMediaFetchNode] 이전 댓글 수: {previous_comments_count}, 현재: {current_comments_count}, 변경: {has_changes}" + ) + + # 댓글 수 변경이 있을 때는 JSON 파일을 나중에 업데이트하도록 임시 데이터만 준비 + json_data = { + "media_data": media_data, + "first_media_id": first_media_id, + "previous_comments_count": current_comments_count, + } + + # 최초 실행, 댓글 수 변경이 있을 때만 JSON 파일 업데이트 + if not os.path.exists(json_file_path): + with open(json_file_path, "w", encoding="utf-8") as f: + json.dump(json_data, f, ensure_ascii=False, indent=2) + print( + "💾 [InstagramMediaFetchNode] 최초 실행: 미디어 데이터를 JSON 파일에 저장했습니다." + ) + else: + if has_changes: + print( + "📝 [InstagramMediaFetchNode] 댓글 수 변경 감지. JSON 파일은 댓글 수집 후 업데이트됩니다." + ) + else: + print( + "📝 [InstagramMediaFetchNode] 기존 JSON 파일이 존재하여 저장하지 않습니다." + ) + + return { + "media_data": media_data, + "first_media_id": first_media_id, + "current_comments_count": current_comments_count, + "previous_comments_count": previous_comments_count, + "has_changes": has_changes, + "json_data": json_data, # 댓글 수집 후 저장할 데이터 + "response": f"미디어 데이터를 성공적으로 가져왔습니다. 총 {len(media_data)}개의 포스트가 있습니다. 댓글 수 변경: {has_changes}", + } + else: + return { + "response": f"API 요청 실패: {response.status_code} - {response.text}" + } + except Exception as e: + return {"response": f"미디어 데이터 가져오기 중 오류 발생: {str(e)}"} + + +class InstagramCommentsFetchNode(BaseNode): + """ + 댓글 데이터를 가져오는 노드 + """ + + def execute(self, state: ManagementState) -> Dict[str, Any]: + """ + 첫 번째 미디어의 댓글 데이터를 가져옵니다. + """ + print("🔍 [InstagramCommentsFetchNode] 실행 중...") + + access_token = state["access_token"] + first_media_id = state["first_media_id"] + json_file_path = state["comment_file_path"] + json_data = state.get("json_data") + + if not first_media_id: + return {"response": "미디어 ID가 없습니다."} + + try: + # 댓글 데이터 가져오기 + comments_data = self._get_comments_data(access_token, first_media_id) + + # 댓글 리스트 출력 + print("\n📝 [InstagramCommentsFetchNode] 댓글 리스트:") + for i, comment in enumerate(comments_data, 1): + print( + f" {i}. {comment.get('text', 'N/A')} (by {comment.get('username', 'Unknown')})" + ) + print() + + # 댓글 수집 후 JSON 파일 저장 + if json_data: + # media_data의 첫 번째 항목의 comments_count 업데이트 + if json_data.get("media_data") and len(json_data["media_data"]) > 0: + json_data["media_data"][0]["comments_count"] = state.get( + "current_comments_count", 0 + ) + + # previous_comments_count 업데이트 + json_data["previous_comments_count"] = state.get( + "current_comments_count", 0 + ) + + with open(json_file_path, "w", encoding="utf-8") as f: + json.dump(json_data, f, ensure_ascii=False, indent=2) + print( + "💾 [InstagramCommentsFetchNode] 댓글 수집 완료 후 JSON 파일을 업데이트했습니다." + ) + + return { + "comments_data": comments_data, + "response": f"댓글 데이터를 성공적으로 가져왔습니다. 총 {len(comments_data)}개의 댓글이 있습니다.", + } + except Exception as e: + return {"response": f"댓글 데이터 가져오기 중 오류 발생: {str(e)}"} + + def _get_comments_data( + self, access_token: str, media_id: str + ) -> List[Dict[str, Any]]: + """ + Get comments API를 통해 특정 미디어의 댓글 데이터를 가져옵니다. + """ + url = f"https://graph.instagram.com/v23.0/{media_id}/comments?access_token={access_token}" + params = {"fields": "id,text,timestamp,username"} + + try: + response = requests.get(url, params=params) + if response.status_code == 200: + data = response.json() + comments = data.get("data", []) + print( + f"📝 [InstagramCommentsFetchNode] 미디어 {media_id}에서 {len(comments)}개의 댓글을 가져왔습니다." + ) + return comments + else: + print( + f"댓글 가져오기 API 오류: {response.status_code} - {response.text}" + ) + return [] + except Exception as e: + print(f"댓글 가져오기 네트워크 오류: {str(e)}") + return [] + + +class InstagramCommentsAnalysisNode(BaseNode): """ - 프로젝트에 필요한 리소스를 계획하고 관리하는 노드 + 인스타그램 댓글을 LLM(Gemini)으로 분석하는 노드 (langchain 기반) """ - def __init__(self, **kwargs): - super().__init__(**kwargs) # BaseNode 초기화 - self.chain = set_resource_planning_chain() # 리소스 계획 체인 설정 + def execute(self, state: ManagementState) -> Dict[str, Any]: + print("🔍 [InstagramCommentsAnalysisNode] 실행 중...") + comments_data = state.get("comments_data", []) + if not comments_data: + return {"response": "분석할 댓글 데이터가 없습니다."} + + # 댓글 텍스트만 추출 + comments_texts = [c.get("text", "") for c in comments_data if c.get("text")] + comments_str = "\n".join(comments_texts) + + # 프롬프트 생성 + prompt_template = get_instagram_comment_analysis_prompt() + prompt = prompt_template.format(comments=comments_str) + + try: + api_key = state.get("api_key", "") + if not api_key: + return { + "response": "GOOGLE_API_KEY 환경변수가 설정되어 있지 않습니다.", + "comment_analysis_result": None, + } + + llm = ChatGoogleGenerativeAI( + model="gemini-2.5-flash", google_api_key=api_key + ) + # Bind the Pydantic model as a tool + response = llm.invoke(prompt) + print(f"LLM Raw Response: {response.content}") + + # Parse the raw JSON string output from the LLM + try: + json_content = response.content + # Remove markdown code block fences if present + if json_content.startswith("```json") and json_content.endswith("```"): + json_content = json_content[len("```json") : -len("```")].strip() + + # Using .model_validate_json for Pydantic v2 + parsed_output = InstagramCommentsAnalysisOutput.model_validate_json( + json_content + ) + except Exception as parse_error: + raise ValueError( + f"Failed to parse LLM response as JSON: {parse_error}\nRaw content: {response.content}" + ) + + analysis_result_dict = parsed_output.model_dump() + + print( + f"[InstagramCommentsAnalysisNode] 분석 결과:\n{json.dumps(analysis_result_dict, ensure_ascii=False, indent=2)}" + ) + state["comment_analysis_result"] = analysis_result_dict + + # 분석 결과를 별도 json 파일로 저장 + now_str = datetime.now().isoformat() + analysis_file_path = state.get("comment_analysis_file_path") - def execute(self, state: ManagementState) -> dict: + if os.path.exists(analysis_file_path): + try: + with open(analysis_file_path, "r", encoding="utf-8") as f: + analysis_data = json.load(f) + except Exception: + analysis_data = {} + analysis_data[now_str] = analysis_result_dict + else: + # Ensure the directory exists before writing the file + os.makedirs(os.path.dirname(analysis_file_path), exist_ok=True) + analysis_data = {now_str: analysis_result_dict} + + with open(analysis_file_path, "w", encoding="utf-8") as f: + json.dump( + analysis_data, + f, + ensure_ascii=False, + indent=2, + separators=(",", ": "), + ) + print( + f"[InstagramCommentsAnalysisNode] 분석 결과를 {analysis_file_path}에 저장했습니다." + ) + return { + "comment_analysis_result": analysis_result_dict, + "response": "댓글 분석이 완료되었습니다.", + } + except Exception as e: + print(f"[InstagramCommentsAnalysisNode] Gemini 호출 오류: {str(e)}") + return { + "response": f"Gemini 호출 오류: {str(e)}", + "comment_analysis_result": None, + } + + +class NoChangeNode(BaseNode): + """ + 댓글 수 변경이 없을 때 실행되는 노드 + """ + + def execute(self, state: ManagementState) -> Dict[str, Any]: """ - 주어진 상태(state)에서 project_id, request_type, query 등의 정보를 추출하여 - 리소스 계획 체인에 전달하고, 결과를 응답으로 반환합니다. + 댓글 수 변경이 없을 때의 처리를 수행합니다. """ - # 팀 구성원 기본값 처리 - team_members = state.get("team_members", []) + print("🔍 [NoChangeNode] 실행 중...") + + return {"response": "댓글 수에 변경이 없습니다. 모니터링을 계속합니다."} + + +class InstagramAnalysisReportNode(BaseNode): + """ + 인스타그램 댓글 분석 보고서를 생성하는 노드 + """ + + def execute(self, state: ManagementState) -> Dict[str, Any]: + print("🔍 [InstagramAnalysisReportNode] 실행 중...") + report_file_path = state.get("analysis_report_file_path") - # 리소스 계획 체인 실행 - resource_plan = self.chain.invoke( - { - "project_id": state["project_id"], # 프로젝트 ID - "request_type": state["request_type"], # 요청 유형 - "query": state["query"], # 사용자 쿼리 - "team_members": team_members, # 팀 구성원 - "resources_available": state.get( - "resources_available", {} - ), # 사용 가능한 리소스 + # Load analysis data directly from state + analyzed_data = state.get("comment_analysis_result") + if not analyzed_data: + return { + "response": "분석 데이터가 상태에 존재하지 않습니다.", + "analysis_report": None, } + print("📁 [InstagramAnalysisReportNode] 분석 데이터를 상태에서 불러왔습니다.") + + # Prompt LLM for report generation + prompt_template = get_instagram_analysis_report_prompt() + prompt = prompt_template.format( + analyzed_data=json.dumps(analyzed_data, ensure_ascii=False, indent=2) ) - # 상태 업데이트 - state["resource_plan"] = resource_plan + try: + api_key = state.get("api_key", "") + if not api_key: + return { + "response": "GOOGLE_API_KEY 환경변수가 설정되어 있지 않습니다.", + "analysis_report": None, + } + + llm = ChatGoogleGenerativeAI( + model="gemini-2.5-flash", google_api_key=api_key + ) + response = llm.invoke(prompt) + print(f"LLM Raw Report Response: {response.content}") + + # Parse the raw JSON string output from the LLM + try: + json_content = response.content + if json_content.startswith("```json") and json_content.endswith("```"): + json_content = json_content[len("```json") : -len("```")].strip() - # 생성된 리소스 계획을 응답으로 반환 - return {"response": resource_plan} + parsed_report = InstagramAnalysisReportOutput.model_validate_json( + json_content + ) + except Exception as parse_error: + raise ValueError( + f"Failed to parse LLM report as JSON: {parse_error}\nRaw content: {response.content}" + ) + + report_dict = parsed_report.model_dump() + report_dict["media_id"] = state.get("user_id", "") + + # Save the report to a new JSON file + os.makedirs(os.path.dirname(report_file_path), exist_ok=True) + with open(report_file_path, "w", encoding="utf-8") as f: + json.dump( + report_dict, f, ensure_ascii=False, indent=2, separators=(",", ": ") + ) + print( + f"[InstagramAnalysisReportNode] 분석 보고서를 {report_file_path}에 저장했습니다." + ) + + return { + "analysis_report": report_dict, + "response": "분석 보고서 생성이 완료되었습니다.", + } + except Exception as e: + print( + f"[InstagramAnalysisReportNode] Gemini 호출 또는 보고서 생성 오류: {str(e)}" + ) + return { + "response": f"Gemini 호출 또는 보고서 생성 오류: {str(e)}", + "analysis_report": None, + } diff --git a/agents/management/modules/prompts.py b/agents/management/modules/prompts.py index 16d9764..9734ac8 100644 --- a/agents/management/modules/prompts.py +++ b/agents/management/modules/prompts.py @@ -84,3 +84,123 @@ def get_resource_planning_prompt(): "resources_available", ], # 프롬프트에 삽입될 변수들 ) + + +def get_instagram_comment_analysis_prompt(): + """ + Returns a prompt template for analyzing Instagram comments for a singer-songwriter artist management. + The prompt analyzes each comment for: + 1. Sentiment analysis (Positive/Negative) + 2. Comment type identification (advertisement, hate comment, fan comment, etc.) + 3. Whether a reply is needed + Input: comments (list of comment texts) + Output: Structured analysis for effective community management. + """ + comment_analysis_template = """You are an Instagram management agent for a singer-songwriter artist. Analyze each comment from the Instagram post and provide detailed insights for effective community management. + +For each comment, analyze: +1. SENTIMENT: Determine if the comment is Positive (P) or Negative (N) +2. COMMENT TYPE: Identify the type of comment: + - Fan comment: Supportive, encouraging, or appreciative + - Advertisement/Spam: Promotional content, unrelated links, or spam + - Hate comment: Malicious, offensive, or harmful content + - Question: Asking about music, schedule, or personal matters + - Criticism: Constructive or destructive feedback + - Other: Any other type +3. REPLY NEEDED: Determine if the artist or management should reply: + - "Yes": For questions, constructive criticism, or important fan comments + - "No": For spam, hate comments, or simple appreciation comments + - "Consider": For borderline cases that might need attention + +Comment List: +{comments} + +Provide your analysis as a JSON string that strictly adheres to the following structure. The JSON should contain a single key `comments`, which is a list of analyzed comment dictionaries. Each comment dictionary must include the keys: `comment`, `sentiment`, `comment_type`, `reply_needed`, and `reason`. All values for `sentiment`, `comment_type`, `reply_needed`, and `reason` must be in Korean. + +Example JSON format: +```json +{{ + "comments": [ + {{ + "comment": "Original comment text", + "sentiment": "긍정", + "comment_type": "팬댓글", + "reply_needed": "예", + "reason": "Reason for assessment" + }} + ] +}} +``` +""" + + return PromptTemplate( + template=comment_analysis_template, + input_variables=["comments"], + ) + + +def get_instagram_analysis_report_prompt(): + """ + Returns a prompt template for generating an Instagram comments analysis report. + The prompt takes analyzed comment data as input and generates a structured report. + """ + report_template = """You are an expert Instagram analyst. Based on the provided Instagram comments analysis data, generate a comprehensive analysis report. + +The data is a JSON object where keys are timestamps and values are dictionaries containing a 'comments' list. Each comment in the 'comments' list has 'comment', 'sentiment', 'comment_type', 'reply_needed', and 'reason' fields. + +Analyzed Comments Data: +{analyzed_data} + +Your report must be a JSON string that strictly adheres to the `InstagramAnalysisReportOutput` schema. The JSON should contain the following keys: +- `report_date`: Current date in YYYY-MM-DD format. +- `total_comments_analyzed`: Total number of comments processed in the provided data. +- `summary`: A comprehensive summary of the overall sentiment and key themes. +- `key_insights`: A list of 3-5 key insights, trends, or recurring themes from the comments. +- `sentiment_distribution`: An object showing the count for each sentiment (긍정, 부정, 중립). +- `comment_type_distribution`: An object showing the count for each comment type (팬댓글, 광고/스팸, 악성댓글, 질문, 비판, 기타). +- `reply_needed_breakdown`: An object showing the count for each reply_needed status (예, 아니오, 고려). +- `action_items`: A list of 3-5 actionable recommendations for community management based on your analysis. + +Ensure all text values are in Korean. The output must be a single JSON object. + +Example JSON format: +```json +{{ + "report_date": "2024-07-27-03-10", + "total_comments_analyzed": 100, + "summary": "전반적으로 긍정적인 팬덤 반응을 보이며, 일부 질문과 개선점에 대한 의견이 있었습니다.", + "key_insights": [ + "아티스트에 대한 강한 지지와 애정 표현이 많음", + "신곡 및 활동 계획에 대한 팬들의 궁금증 증가", + "일부 비판적 의견은 건설적인 방향으로 제시됨" + ], + "sentiment_distribution": {{ + "긍정": 80, + "부정": 10, + "중립": 10 + }}, + "comment_type_distribution": {{ + "팬댓글": 70, + "질문": 15, + "비판": 5, + "광고/스팸": 5, + "악성댓글": 2, + "기타": 3 + }}, + "reply_needed_breakdown": {{ + "예": 20, + "아니오": 70, + "고려": 10 + }}, + "action_items": [ + "팬들의 질문에 대한 FAQ 문서 업데이트", + "긍정적인 팬 댓글에 주기적으로 감사 댓글 남기기", + "비판적 의견에 대한 내부 검토 및 개선 방안 마련" + ] +}} +``` +""" + return PromptTemplate( + template=report_template, + input_variables=["analyzed_data"], + ) diff --git a/agents/management/modules/state.py b/agents/management/modules/state.py index 9ae62c1..dc7c888 100644 --- a/agents/management/modules/state.py +++ b/agents/management/modules/state.py @@ -1,31 +1,37 @@ """ -아래는 예시입니다. +인스타그램 API 워크플로우를 위한 상태 정의 """ from __future__ import annotations -from dataclasses import dataclass -from typing import Annotated, TypedDict, List, Dict, Optional +from typing import Annotated, TypedDict, List, Dict, Optional, Any from langgraph.graph.message import add_messages -@dataclass class ManagementState(TypedDict): """ - 관리(Management) Workflow의 상태를 정의하는 TypedDict 클래스 + 인스타그램 API 워크플로우의 상태를 정의하는 TypedDict 클래스 - 프로젝트, 팀, 리소스의 관리와 크리에이터 직업 성장을 위한 Workflow에서 사용되는 상태 정보를 정의합니다. + 인스타그램 포스트 정보와 댓글 모니터링을 위한 Workflow에서 사용되는 상태 정보를 정의합니다. LangGraph의 상태 관리를 위한 클래스로, Workflow 내에서 처리되는 데이터의 형태와 구조를 지정합니다. """ - project_id: str # 프로젝트 ID (예: "PRJ-2023-001", "EP-MARVEL-S01") - request_type: str # 요청 유형 (예: "resource_allocation", "team_management", "creator_development") - query: str # 사용자 쿼리 또는 요청사항 + access_token: str # 인스타그램 액세스 토큰 + api_key: str # google api key + user_id: str # 인스타그램 사용자 ID + comment_file_path: str # JSON 파일 경로 + comment_analysis_file_path: str + media_data: Optional[List[Dict[str, Any]]] # 미디어 데이터 목록 + first_media_id: Optional[str] # 첫 번째 미디어 ID + current_comments_count: Optional[int] # 현재 댓글 수 + previous_comments_count: Optional[int] # 이전 댓글 수 + comments_data: Optional[List[Dict[str, Any]]] # 댓글 데이터 + has_changes: Optional[bool] # 댓글 수 변경 여부 + json_data: Optional[Dict[str, Any]] # JSON 파일에 저장할 데이터 response: Annotated[ - list, add_messages - ] - team_members: Optional[List[str]] = None # 팀 구성원 목록 - resources_available: Optional[Dict[str, any]] = None # 사용 가능한 리소스 정보 - resource_plan: Optional[str] = None # 리소스 계획 콘텐츠 - # 응답 메시지 목록 (add_messages로 주석되어 메시지 추가 기능 제공) + List[Any], add_messages + ] # 응답 메시지 목록 (add_messages로 주석되어 메시지 추가 기능 제공) + comment_analysis_result: Optional[Any] # 댓글 분석 결과 + analysis_report_file_path: Optional[str] # 분석 보고서 파일 경로 + analysis_report: Optional[Any] # 분석 보고서 결과 diff --git a/agents/management/modules/tools.py b/agents/management/modules/tools.py deleted file mode 100644 index 9b9b4fe..0000000 --- a/agents/management/modules/tools.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -도구(Tools) 모듈 - -이 모듈은 Management Workflow에서 사용할 수 있는 다양한 도구를 정의합니다. -도구는 LLM이 프로젝트 관리, 리소스 할당, 팀 관리 등을 지원하는 함수들입니다. - -아래는 엔터테인먼트 프로젝트 관리에 적합한 도구의 예시입니다: -- 프로젝트 일정 관리 도구: 일정 생성, 수정, 추적 -- 팀 구성원 할당 도구: 팀원 정보 검색 및 역할 할당 -- 리소스 검색 도구: 가용 리소스 출력 및 할당 상태 확인 -- 참조 자료 검색 도구: 엔터테인먼트 산업의 프로젝트 관리 사례 검색 -- 협업 지원 도구: 팀원간 커뮤니케이션 및 협업 지원 -""" - -# 예시 코드: 리소스 검색 도구 -# from typing import Dict, List, Optional -# from datetime import datetime - -# def search_available_resources(resource_type: str, time_period: Optional[Dict[str, datetime]] = None) -> List[Dict]: -# """ -# 주어진 리소스 유형과 시간에 따라 사용 가능한 리소스를 검색합니다. -# -# Args: -# resource_type: 검색할 리소스 유형 (예: 'studio', 'equipment', 'staff') -# time_period: 시간 기간 (예: {'start': datetime(2023, 6, 1), 'end': datetime(2023, 6, 30)}) -# -# Returns: -# List[Dict]: 사용 가능한 리소스 목록 -# """ -# # 실제 구현에서는 데이터베이스를 쿼리하거나 API를 호출하여 사용 가능한 리소스를 가져옴 -# pass - -# 예시 코드: 프로젝트 일정 관리 도구 -# def get_project_schedule(project_id: str) -> Dict: -# """ -# 특정 프로젝트의 일정을 가져옵니다. -# -# Args: -# project_id: 프로젝트 ID -# -# Returns: -# Dict: 프로젝트 일정 정보 -# """ -# # 실제 구현에서는 프로젝트 관리 시스템 API를 호출하여 일정을 가져옴 -# pass - -# from react_agent.configuration import Configuration - - -# async def search( -# query: str, *, config: Annotated[RunnableConfig, InjectedToolArg] -# ) -> Optional[list[dict[str, Any]]]: -# """ -# 일반 웹 결과를 검색합니다. - -# 이 함수는 Tavily 검색 엔진을 사용하여 검색을 수행합니다. 이 엔진은 포괄적이고 정확하며 신뢰할 수 있는 결과를 제공하도록 설계되었습니다. 특히 현재 이벤트에 대한 질문에 대답하는 데 유용합니다. -# """ -# configuration = Configuration.from_runnable_config(config) -# wrapped = TavilySearchResults(max_results=configuration.max_search_results) -# result = await wrapped.ainvoke({"query": query}) -# return cast(list[dict[str, Any]], result) - - -# TOOLS: List[Callable[..., Any]] = [search] diff --git a/agents/management/modules/utils.py b/agents/management/modules/utils.py deleted file mode 100644 index 0497b49..0000000 --- a/agents/management/modules/utils.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -유틸리티 및 보조 함수 모듈 - -이 모듈은 텍스트 처리 Workflow에서 사용할 수 있는 다양한 유틸리티 함수를 제공합니다. -현재는 주석 처리된 예시 코드만 포함되어 있으며, 필요에 따라 실제 구현을 추가할 수 있습니다. - -아래 예시 코드는 ReAct Agent 패턴에서 사용될 수 있는 유틸리티 함수들입니다. -이 함수들은 메시지 처리 및 모델 로딩과 관련된 기능을 제공합니다. - -추후 개발 시 필요한 유틸리티 함수를 이 모듈에 추가하여 코드 재사용성을 높일 수 있습니다. -예를 들어, 텍스트 전처리, 포맷팅, 데이터 변환 등의 기능을 구현할 수 있습니다. - -아래는 예시입니다. -""" - -# from langchain.chat_models import init_chat_model -# from langchain_core.language_models import BaseChatModel -# from langchain_core.messages import BaseMessage - - -# def get_message_text(msg: BaseMessage) -> str: -# """메시지의 텍스트 내용을 가져옵니다.""" -# content = msg.content -# if isinstance(content, str): -# return content -# elif isinstance(content, dict): -# return content.get("text", "") -# else: -# txts = [c if isinstance(c, str) else (c.get("text") or "") for c in content] -# return "".join(txts).strip() - - -# def load_chat_model(fully_specified_name: str) -> BaseChatModel: -# """완전히 지정된 이름에서 채팅 모델을 로드합니다. - -# 매개변수: -# fully_specified_name (str): 'provider/model' 형식의 문자열. -# """ -# provider, model = fully_specified_name.split("/", maxsplit=1) -# return init_chat_model(model, model_provider=provider) diff --git a/agents/management/workflow.py b/agents/management/workflow.py index 20ba00b..2c141f5 100644 --- a/agents/management/workflow.py +++ b/agents/management/workflow.py @@ -1,15 +1,22 @@ from langgraph.graph import StateGraph from agents.base_workflow import BaseWorkflow -from agents.management.modules.nodes import ResourceManagementNode +from agents.management.modules.nodes import ( + InstagramMediaFetchNode, + InstagramCommentsFetchNode, + NoChangeNode, + InstagramCommentsAnalysisNode, + InstagramAnalysisReportNode, +) from agents.management.modules.state import ManagementState +from langchain.utils.env import get_from_env class ManagementWorkflow(BaseWorkflow): """ - 콘텐츠 관리를 위한 Workflow 클래스 + 인스타그램 API 모니터링을 위한 Workflow 클래스 - 이 클래스는 프로젝트, 팀, 리소스의 관리와 크리에이터 직업 성장을 위한 Workflow를 정의합니다. + 이 클래스는 인스타그램 포스트의 댓글 수 변화를 모니터링하는 Workflow를 정의합니다. BaseWorkflow를 상속받아 기본 구조를 구현하고, ManagementState를 사용하여 상태를 관리합니다. """ @@ -19,36 +26,174 @@ def __init__(self, state): def build(self): """ - 관리 Workflow 그래프 구축 메서드 + 인스타그램 모니터링 Workflow 그래프 구축 메서드 - StateGraph를 사용하여 콘텐츠 관리를 위한 Workflow 그래프를 구축합니다. - 현재는 리소스 관리 노드를 포함하고 있으며, 추후 조건부 에지를 추가하여 - 다양한 경로를 가진 Workflow를 구축할 수 있습니다. + StateGraph를 사용하여 인스타그램 API 모니터링을 위한 Workflow 그래프를 구축합니다. + 조건부 에지를 사용하여 댓글 수 변경 여부에 따라 다른 경로를 실행합니다. Returns: CompiledStateGraph: 컴파일된 상태 그래프 객체 """ builder = StateGraph(self.state) - # 리소스 관리 노드 추가 - builder.add_node("resource_management", ResourceManagementNode()) - # 시작 노드에서 리소스 관리 노드로 연결 - builder.add_edge("__start__", "resource_management") - # 리소스 관리 노드에서 종료 노드로 연결 - builder.add_edge("resource_management", "__end__") - - # 조건부 에지 추가 예시 - # builder.add_conditional_edges( - # "resource_planning", - # # resource_planning 실행이 완료된 후, 다음 노드(들)는 - # # router의 출력을 기반으로 예약됩니다 - # router, - # ) + + # 노드 추가 + builder.add_node("media_fetch", InstagramMediaFetchNode()) + builder.add_node("comments_fetch", InstagramCommentsFetchNode()) + builder.add_node("analysis", InstagramCommentsAnalysisNode()) + builder.add_node("no_change", NoChangeNode()) + builder.add_node("generate_report", InstagramAnalysisReportNode()) + + # 시작 노드에서 미디어 가져오기 노드로 연결 + builder.add_edge("__start__", "media_fetch") + + # 조건부 에지: 댓글 수 변경 여부에 따라 분기 + builder.add_conditional_edges( + "media_fetch", + self._should_fetch_comments, + { + "fetch_comments": "comments_fetch", + "no_change": "no_change", + "api_error": "__end__", # API 오류 시 종료 + }, + ) + + # 댓글 가져오기 노드에서 분석 노드로, 분석 노드에서 종료 + builder.add_edge("comments_fetch", "analysis") + builder.add_edge( + "analysis", "generate_report" + ) # Connect analysis to report node + builder.add_edge("generate_report", "__end__") # Connect report node to end workflow = builder.compile() # 그래프 컴파일 workflow.name = self.name # Workflow 이름 설정 return workflow + def _should_fetch_comments(self, state: ManagementState) -> str: + """ + 댓글을 가져올지 결정하는 라우터 함수 + + Args: + state: 현재 상태 + + Returns: + str: 다음 노드 이름 ("fetch_comments", "no_change", 또는 "api_error") + """ + print("🔄 [Router] 다음 노드 결정 중...") + + # API 오류 체크 + if "API 요청에 실패했습니다" in str(state.get("response", [])): + print("🔄 [Router] API 오류 감지 → api_error 경로 선택") + return "api_error" + + # For testing purposes, force fetch_comments + # return "fetch_comments" + + has_changes = state.get("has_changes", False) + + if has_changes: + print("🔄 [Router] 댓글 수 변경 감지 → fetch_comments 경로 선택") + return "fetch_comments" + else: + print("🔄 [Router] 댓글 수 변경 없음 → no_change 경로 선택") + return "no_change" + + +# def test_instagram_workflow(): +# USER_ID = "17841451140542851" + +# global run_count +# run_count += 1 +# print(f"\n===== {run_count}번째 실행 =====") +# """ +# 인스타그램 워크플로우를 테스트합니다. +# """ +# # 인스타그램 API 인증 정보 (실제 값으로 교체 필요) +# access_token = ACCESS_TOKEN # 실제 액세스 토큰으로 교체 +# user_id = USER_ID # 실제 사용자 ID로 교체 + +# # 초기 상태 설정 +# initial_state = { +# "access_token": access_token, +# "user_id": user_id, +# "comment_file_path": "./data/instagram_data.json", +# "comment_analysis_file_path": "./data/instagram_data_analysis.json", +# "json_data": None, # JSON 파일에 저장할 데이터 +# "response": [] +# } + +# try: +# # 워크플로우 실행 +# print("인스타그램 워크플로우를 시작합니다...") + +# # 워크플로우 빌드 +# workflow = management_workflow.build() + +# # 워크플로우 실행 +# result = workflow.invoke(initial_state) + +# # 결과 출력 +# print("\n=== 워크플로우 실행 결과 ===") +# for message in result.get("response", []): +# if hasattr(message, 'content'): +# print(f"응답: {message.content}") +# else: +# print(f"응답: {message}") + +# # 상태 정보 출력 +# print(f"\n=== 상태 정보 ===") +# print(f"미디어 데이터 개수: {len(result.get('media_data', []))}") +# print(f"첫 번째 미디어 ID: {result.get('first_media_id')}") +# print(f"현재 댓글 수: {result.get('current_comments_count')}") +# print(f"이전 댓글 수: {result.get('previous_comments_count')}") +# print(f"변경 여부: {result.get('has_changes')}") + +# if result.get('comments_data'): +# print(f"댓글 데이터 개수: {len(result.get('comments_data', []))}") + +# # API 오류 체크 +# response_text = str(result.get("response", [])) +# if "API 요청에 실패했습니다" in response_text or "Invalid OAuth access token" in response_text: +# print(f"\n⚠️ API 오류가 발생했습니다. 토큰을 확인해주세요.") +# print(f" - 토큰이 만료되었을 수 있습니다.") +# print(f" - 토큰이 올바른지 확인해주세요.") +# print(f" - 인스타그램 API 권한을 확인해주세요.") + +# except Exception as e: +# print(f"워크플로우 실행 중 오류 발생: {str(e)}") + + +# def run_scheduler(): +# # 10분마다 워크플로우 실행 +# schedule.every(1).minutes.do(test_instagram_workflow) +# # 또는 매 1시간마다: schedule.every().hour.do(test_instagram_workflow) + +# print("스케줄러가 시작되었습니다. (Ctrl+C로 종료)") +# while True: +# schedule.run_pending() +# time.sleep(1) -# 관리 Workflow 인스턴스 생성 management_workflow = ManagementWorkflow(ManagementState) + +if __name__ == "__main__": + # run_count = 0 + # # run_scheduler() + # test_instagram_workflow() + workflow = management_workflow.build() + + ACCESS_TOKEN = get_from_env("instagram_api_key", "INSTAGRAM_API_KEY") + API_KEY = get_from_env("google_api_key", "GOOGLE_API_KEY") + USER_ID = "17841451140542851" + + initial_state = { + "access_token": ACCESS_TOKEN, + "user_id": USER_ID, + "api_key": API_KEY, + "comment_file_path": "./data/instagram_data.json", + "comment_analysis_file_path": "./data/instagram_data_analysis.json", + "analysis_report_file_path": "./data/analysis_report.json", + "json_data": None, + "response": [], + } + result = workflow.invoke(initial_state) + print(result) diff --git a/data/analysis_report.json b/data/analysis_report.json new file mode 100644 index 0000000..6e9e106 --- /dev/null +++ b/data/analysis_report.json @@ -0,0 +1,38 @@ +{ + "report_date": "2024-07-27", + "total_comments_analyzed": 7, + "summary": "분석된 댓글들은 긍정, 부정, 중립적인 다양한 감정을 포함하고 있습니다. 팬들의 직접적인 질문과 아티스트의 발전에 대한 비판적 의견이 있었으며, 일부 악성 댓글과 개인적인 감정 표현도 관찰되었습니다. 전반적으로 팬들과의 소통 기회와 함께 부정적인 댓글에 대한 현명한 대응 전략이 필요한 시점입니다.", + "key_insights": [ + "팬들은 아티스트의 개인적인 면모(예: 요리 실력)에 대한 궁금증을 가지고 있으며, 이는 소통의 좋은 기회입니다.", + "아티스트의 현 상태에 대한 비판적이지만 건설적인 의견이 존재하며, 이는 신중한 답변 고려가 필요합니다.", + "인신공격성 악성 댓글은 무대응이 최선으로 판단됩니다.", + "단순 동의나 의미 없는 테스트성 댓글, 개인 감정 표현 댓글은 개별적인 답변이 불필요합니다.", + "질문 댓글은 팬과의 유대감을 형성하고 소통을 강화할 수 있는 중요한 요소입니다." + ], + "sentiment_distribution": { + "긍정": 2, + "부정": 3, + "중립": 2 + }, + "comment_type_distribution": { + "팬댓글": 1, + "광고/스팸": 0, + "악성댓글": 1, + "질문": 2, + "비판": 1, + "기타": 2 + }, + "reply_needed_breakdown": { + "예": 2, + "아니오": 4, + "고려": 1 + }, + "action_items": [ + "게시물 내용 및 아티스트의 개인적 면모에 대한 질문에 적극적으로 답변하여 팬들과의 소통을 강화하십시오.", + "발전을 지적하는 비판적 댓글에 대해서는 신중하게 검토 후 소통 여부를 결정하여 팬들의 의견을 존중하는 모습을 보여주십시오.", + "악성 댓글에는 일체 대응하지 않고 무시하는 전략을 유지하여 불필요한 논란을 방지하십시오.", + "아티스트의 일상적인 모습이나 취미와 관련된 추가 콘텐츠를 기획하여 팬들과의 친밀도를 높이는 기회를 마련하십시오.", + "의미 없는 댓글이나 단순 동의 댓글에 대한 개별 답변보다는, 전반적인 감사 댓글 등으로 팬 커뮤니티에 대한 관심을 표현할 수 있습니다." + ], + "media_id": "17841451140542851" +} \ No newline at end of file diff --git a/data/instagram_data.json b/data/instagram_data.json new file mode 100644 index 0000000..b761db8 --- /dev/null +++ b/data/instagram_data.json @@ -0,0 +1,195 @@ +{ + "media_data": [ + { + "id": "18007485335525524", + "caption": "정호영 쉐프의 냉제육\n\n내가 해봤던 요리 중 가장 쉬운 편에 속하지만\n맛 또한 가장 좋은 편에 속한다.\n\n꼭 해먹어 보길 바람처럼 왔다가 이슬처럼 갈 순 없잖아", + "media_type": "IMAGE", + "timestamp": "2024-05-18T17:56:11+0000", + "username": "yimbapchunguk", + "like_count": 13, + "comments_count": 7 + }, + { + "id": "18008998250043407", + "caption": "여유 long time no see\n원팬 파스타 sucks\nI prefer old way", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2023-11-09T07:15:54+0000", + "username": "yimbapchunguk", + "like_count": 11, + "comments_count": 0 + }, + { + "id": "18003786535984507", + "caption": "동기님들 방문해 주셨습니다… 근데 이제 인스피릿을 곁들인", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2023-08-29T17:05:47+0000", + "username": "yimbapchunguk", + "like_count": 20, + "comments_count": 6 + }, + { + "id": "17913183179789753", + "caption": "메타코미디클럽 이선민씨 방문해주셨습니다", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2023-08-05T07:05:01+0000", + "username": "yimbapchunguk", + "like_count": 19, + "comments_count": 1 + }, + { + "id": "17887411151864640", + "caption": "냉우동 맛도리\n커티삭 맛도리", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2023-07-21T03:36:27+0000", + "username": "yimbapchunguk", + "like_count": 17, + "comments_count": 4 + }, + { + "id": "17954969525209194", + "caption": "찬물로 파 씻다가 손구락 얼음", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2023-01-24T13:43:49+0000", + "username": "yimbapchunguk", + "like_count": 22, + "comments_count": 3 + }, + { + "id": "17884996073691257", + "caption": "바쁘다 바뻐 형빈사회\n집에서 밥을 거의 못먹다가 오랜만에 꽤 공들여서 한 저녁\n학회듣고 빨래하고 뭐하고 하다가 10시 반에 겨우 먹음\n후식은 컵누들", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-11-15T16:08:58+0000", + "username": "yimbapchunguk", + "like_count": 18, + "comments_count": 3 + }, + { + "id": "17958508052095266", + "caption": "개지리는 카레 레시피 알아냄\n견과류 빻아서 넣으면 맛있음\n나는 구운 캐슈너트 넣었음\n후첨가 버터는 유통기한 지났음\n카레 넣기 전에 이미 국물 지렸음", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-10-18T04:22:23+0000", + "username": "yimbapchunguk", + "like_count": 17, + "comments_count": 5 + }, + { + "id": "18313440799027884", + "caption": "많이도 해서 먹었습니다", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-08-19T12:06:56+0000", + "username": "yimbapchunguk", + "like_count": 20, + "comments_count": 7 + }, + { + "id": "17910861560562909", + "caption": "간만에 임밥천국이 영업했어요.\n저희 매장 기본찬인 웨지 감자와 봉골레 두접시, 육전 한사바리 요리했습니다.\n+와인 6병 , 어 리를빗 꼬냑\n\n#봉골레 #봉골레파스타 #봉골레맛집 #육전 #육전맛집 #임밥천국", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-07-01T15:47:18+0000", + "username": "yimbapchunguk", + "like_count": 19, + "comments_count": 1 + }, + { + "id": "17936239340067834", + "caption": "쌀이 세컵밖에 안남아서 냄비밥을 했다. 냄비밥은 정말 맛있다. \n간만에 소고기먹었다. 소가 맛있는 이유는 자주 못먹어서 인듯ㅋㅋ \n\n#백종원레시피 #소불고기 #냄비밥 #최강록레시피", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-06-11T16:23:58+0000", + "username": "yimbapchunguk", + "like_count": 24, + "comments_count": 7 + }, + { + "id": "17928318890256046", + "caption": "오랜만에 손님 맞이로 임밥천국을 개시했읍니다. 꽤나 만족스러운 맛이었읍니다. 문의주세요. 단체 환영.\n\n#골뱅이무침 #부추전 #백종원레시피 #아하부장레시피", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-05-08T04:45:29+0000", + "username": "yimbapchunguk", + "like_count": 12, + "comments_count": 4 + }, + { + "id": "17925246794186696", + "caption": "카레는 역시 저 골든 커리가 젤 맛있는듯. 양파 캬라멜라이징 싹 돌리고 소고기 마이야르 촥 굽고 브섯이랑 댕건 스윽 구워서 가니시로 올리면 코코이찌방야 저리 가라야~\n\n#카레 #일본식카레 #골든카레 #goldencurry #임밥천국", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-03-19T12:26:32+0000", + "username": "yimbapchunguk", + "like_count": 10, + "comments_count": 16 + }, + { + "id": "17933571104071046", + "caption": "이번 봉골레 실패했다 면발 무슨 칼국수냐? 애들 앞에서 임밥천국 쪽팔리게 에이씨 빡치네 \n\n#봉골레파스타", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-03-14T11:16:53+0000", + "username": "yimbapchunguk", + "like_count": 10, + "comments_count": 6 + }, + { + "id": "17916218135489138", + "caption": "토맛토맛토 스파게티는 참 간편해~\n\n#토마토파스타", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-03-04T09:41:44+0000", + "username": "yimbapchunguk", + "like_count": 9, + "comments_count": 3 + }, + { + "id": "17930629043024490", + "caption": "백종원 선생님의 콩내물 불게기를 해보았다. 설탕을 넣다 쫌 쏟아서 달달했다. 백선생님 오늘도 감사했습니다… 방구석 제자가 되니 기쁘네요 😎\n\n#백종원레시피 #콩나물불고기 #임밥천국", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-02-22T04:13:32+0000", + "username": "yimbapchunguk", + "like_count": 14, + "comments_count": 0 + }, + { + "id": "17982896386458231", + "caption": "설날 기념 잡채를 만들어보았다. 편스토랑에서 어남선생의 레시피를 카피했다. 편하고 맛이 좋았다. 신선하게 시금치 대신 알배추를 넣었다. 달고 담백하니 맛이 좋았다. 하이볼 세잔 먹음. \n\n#편스토랑레시피 #어남선생레시피 #잡채", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-02-01T13:38:06+0000", + "username": "yimbapchunguk", + "like_count": 8, + "comments_count": 3 + }, + { + "id": "18184518865091131", + "caption": "닭개장… 어렵고 힘들 것 같나? 그렇다. 두시간을 걸려서 만들었다. 이번엔 msg를 안넣고 만들었고 그래도 맛있었다. 나중에 친구들 오면 만들어서 쏘주랑 먹어야지\n\n#닭개장 #아하부장 #아하부장레시피", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-01-27T14:04:40+0000", + "username": "yimbapchunguk", + "like_count": 5, + "comments_count": 4 + }, + { + "id": "17959135507574073", + "caption": "고기 굽기는 아직 서투르다… 코팅 팬을 또 태워버렸어… 설거지는 두렵지만 맛은 아주 맛있었다. 이런 맛이라면 그깟 설거지 백번은 해주마!!!\n\n#돼지고기목살구이 #마이야르 #몬트리올시즈닝", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-01-22T14:09:32+0000", + "username": "yimbapchunguk", + "like_count": 5, + "comments_count": 4 + }, + { + "id": "17916204953221655", + "caption": "소고기 무국\n깊다… 냄비가 3m쯤 되려나…? 착각하게 하는 맛 \n백종원이 없는 다른 멀티버스에서 임형빈 너는 뭘 먹고 지내냐?!!!\n#소고기무국 #백종원 #백종원레시피", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-01-17T13:39:02+0000", + "username": "yimbapchunguk", + "like_count": 6, + "comments_count": 2 + }, + { + "id": "17949593110651400", + "caption": "제육볶음\n#공격수셰프 #공격수셰프레시피 #제육볶음", + "media_type": "CAROUSEL_ALBUM", + "timestamp": "2022-01-17T10:52:03+0000", + "username": "yimbapchunguk", + "like_count": 2, + "comments_count": 2 + } + ], + "first_media_id": "18007485335525524", + "previous_comments_count": 7 +} \ No newline at end of file diff --git a/data/instagram_data_analysis.json b/data/instagram_data_analysis.json new file mode 100644 index 0000000..bbb32e6 --- /dev/null +++ b/data/instagram_data_analysis.json @@ -0,0 +1,267 @@ +{ + "2025-07-27T02:52:27.554443": { + "comments": [ + { + "comment": "Test", + "sentiment": "긍정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "의미 없는 테스트성 댓글로, 특별한 상호작용이 필요하지 않습니다." + }, + { + "comment": "@mirageee_00 맞아요", + "sentiment": "긍정", + "comment_type": "팬댓글", + "reply_needed": "아니오", + "reason": "단순한 동의 표현으로, 개별적인 답글이 필수는 아닙니다." + }, + { + "comment": "@joo_bob_1212 당신의 못된 심보", + "sentiment": "부정", + "comment_type": "악성댓글", + "reply_needed": "아니오", + "reason": "인신공격성 악성댓글이므로, 무시하거나 삭제하는 것이 좋습니다." + }, + { + "comment": "@gangster_joo 아직 제자리 걸음입니다", + "sentiment": "부정", + "comment_type": "비판", + "reply_needed": "고려", + "reason": "진척이 없음을 지적하는 내용으로, 아티스트의 상황에 따라 짧은 긍정적 답변(예: '더 노력하겠습니다' 등)을 고려해볼 수 있습니다." + }, + { + "comment": "왜 이유없이 화가나지 ?", + "sentiment": "부정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "본인의 감정을 표현하는 내용으로, 아티스트가 직접 답글할 필요는 없습니다." + }, + { + "comment": "소금 넣고 끓는 물에 담구는 그거?", + "sentiment": "중립", + "comment_type": "질문", + "reply_needed": "예", + "reason": "게시물 내용과 관련된 명확한 질문으로, 답변을 통해 소통을 강화할 수 있습니다." + }, + { + "comment": "저한테 해줬던 봉골레 파스타는 일취월장 하셨나요?? 임쉪??", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "아티스트의 개인적인 경험 및 기술에 대한 친밀한 질문으로, 답글을 통해 팬과의 유대감을 강화할 수 있습니다." + } + ] + }, + "2025-07-27T03:00:52.552544": { + "comments": [ + { + "comment": "Test", + "sentiment": "긍정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "테스트성 댓글로, 특별한 의미나 질문이 없어 답장 불필요합니다." + }, + { + "comment": "@mirageee_00 맞아요", + "sentiment": "긍정", + "comment_type": "팬댓글", + "reply_needed": "아니오", + "reason": "게시물 내용에 대한 단순한 동의 표현으로, 개별적인 답장이 필수는 아닙니다." + }, + { + "comment": "@joo_bob_1212 당신의 못된 심보", + "sentiment": "부정", + "comment_type": "악성댓글", + "reply_needed": "아니오", + "reason": "아티스트에 대한 인신공격성 악성 댓글이므로 대응하지 않는 것이 좋습니다." + }, + { + "comment": "@gangster_joo 아직 제자리 걸음입니다", + "sentiment": "부정", + "comment_type": "비판", + "reply_needed": "고려", + "reason": "아티스트의 활동에 대한 부정적인 평가성 댓글입니다. 직접적인 답변보다는 긍정적인 메시지로 대응할지, 혹은 무시할지 고려해볼 수 있습니다." + }, + { + "comment": "왜 이유없이 화가나지 ?", + "sentiment": "부정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "아티스트에게 직접적으로 관련된 내용이 아닌 개인적인 감정 표현으로, 답장 불필요합니다." + }, + { + "comment": "소금 넣고 끓는 물에 담구는 그거?", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "게시물 내용과 관련된 직접적인 질문이므로 답장하는 것이 좋습니다." + }, + { + "comment": "저한테 해줬던 봉골레 파스타는 일취월장 하셨나요?? 임쉪??", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "아티스트와 개인적인 친분이 있는 듯한 팬의 질문으로, 관계를 돈독히 하고 소통을 이어가기 위해 답장하는 것이 좋습니다." + } + ] + }, + "2025-07-27T03:13:38.989365": { + "comments": [ + { + "comment": "Test", + "sentiment": "긍정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "내용이 불분명하며 단순한 테스트성 댓글로 판단됩니다. 특별한 상호작용이 필요하지 않습니다." + }, + { + "comment": "@mirageee_00 맞아요", + "sentiment": "긍정", + "comment_type": "팬댓글", + "reply_needed": "아니오", + "reason": "게시물 내용에 대한 긍정적인 공감을 표현하는 댓글입니다. 간단한 동의 표현으로 별도의 답글은 필수는 아닙니다." + }, + { + "comment": "@joo_bob_1212 당신의 못된 심보", + "sentiment": "부정", + "comment_type": "악성댓글", + "reply_needed": "아니오", + "reason": "악의적인 의도를 담은 비방성 댓글입니다. 악성 댓글에는 대응하지 않는 것이 일반적인 관리 원칙입니다." + }, + { + "comment": "@gangster_joo 아직 제자리 걸음입니다", + "sentiment": "부정", + "comment_type": "비판", + "reply_needed": "고려", + "reason": "아티스트의 상황이나 진행 상황에 대한 비판적인 시각을 내포하고 있습니다. 공격적인 비판은 아니므로, 상황에 따라 아티스트가 직접 소통하며 오해를 풀거나 입장을 설명하는 것을 고려할 수 있습니다." + }, + { + "comment": "왜 이유없이 화가나지 ?", + "sentiment": "부정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "댓글 작성자 본인의 감정을 표출하는 내용으로, 아티스트에게 직접적으로 관련된 내용은 아닙니다. 개입할 필요가 없습니다." + }, + { + "comment": "소금 넣고 끓는 물에 담구는 그거?", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "게시물 내용과 관련된 구체적인 질문입니다. 팬과의 소통을 위해 답변하여 궁금증을 해소해 주는 것이 좋습니다." + }, + { + "comment": "저한테 해줬던 봉골레 파스타는 일취월장 하셨나요?? 임쉪??", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "아티스트와의 개인적인 경험을 바탕으로 한 친근하고 구체적인 질문입니다. 팬과의 유대감을 강화하고 소통을 활성화하기 위해 반드시 답변해야 합니다." + } + ] + }, + "2025-07-27T03:15:54.059670": { + "comments": [ + { + "comment": "Test", + "sentiment": "기타", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "의미 없는 테스트 댓글로, 특별한 상호작용이 필요하지 않습니다." + }, + { + "comment": "@mirageee_00 맞아요", + "sentiment": "긍정", + "comment_type": "팬댓글", + "reply_needed": "아니오", + "reason": "게시물 내용에 대한 단순한 동의 표현으로, 별도의 답변이 필수는 아닙니다." + }, + { + "comment": "@joo_bob_1212 당신의 못된 심보", + "sentiment": "부정", + "comment_type": "악성댓글", + "reply_needed": "아니오", + "reason": "악의적인 비난 댓글로, 대응하지 않는 것이 좋습니다." + }, + { + "comment": "@gangster_joo 아직 제자리 걸음입니다", + "sentiment": "기타", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "댓글 작성자의 개인적인 상황에 대한 언급으로 보이며, 아티스트의 답변이 꼭 필요하지는 않습니다." + }, + { + "comment": "왜 이유없이 화가나지 ?", + "sentiment": "기타", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "댓글 작성자의 개인적인 감정 표현으로, 아티스트에게 직접적인 답변을 요구하는 질문이 아닙니다." + }, + { + "comment": "소금 넣고 끓는 물에 담구는 그거?", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "게시물 내용과 관련된 직접적인 질문으로, 답변을 통해 팬의 궁금증을 해소하고 소통을 유도할 수 있습니다." + }, + { + "comment": "저한테 해줬던 봉골레 파스타는 일취월장 하셨나요?? 임쉪??", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "아티스트와의 친밀한 관계를 보여주는 개인적인 질문으로, 답변을 통해 팬과의 유대감을 강화하고 긍정적인 상호작용을 이어갈 수 있습니다." + } + ] + }, + "2025-08-10T23:03:33.843554": { + "comments": [ + { + "comment": "Test", + "sentiment": "기타", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "의미 없는 테스트성 댓글로, 개별적인 답변이 필요하지 않습니다." + }, + { + "comment": "@mirageee_00 맞아요", + "sentiment": "긍정", + "comment_type": "팬댓글", + "reply_needed": "아니오", + "reason": "게시물 내용에 대한 단순한 동의 표현으로, 개별적인 답글은 필수가 아닙니다." + }, + { + "comment": "@joo_bob_1212 당신의 못된 심보", + "sentiment": "부정", + "comment_type": "악성댓글", + "reply_needed": "아니오", + "reason": "인신공격성 악성 댓글로, 대응하지 않는 것이 바람직합니다." + }, + { + "comment": "@gangster_joo 아직 제자리 걸음입니다", + "sentiment": "부정", + "comment_type": "비판", + "reply_needed": "고려", + "reason": "발전이 없음을 지적하는 댓글로, 아티스트가 해당 내용에 대해 소통하고자 한다면 답변을 고려할 수 있습니다." + }, + { + "comment": "왜 이유없이 화가나지 ?", + "sentiment": "부정", + "comment_type": "기타", + "reply_needed": "아니오", + "reason": "개인적인 감정을 표현하는 댓글로, 아티스트가 직접 답변할 필요는 없습니다." + }, + { + "comment": "소금 넣고 끓는 물에 담구는 그거?", + "sentiment": "기타", + "comment_type": "질문", + "reply_needed": "예", + "reason": "게시물 내용에 대한 구체적인 질문으로, 답변을 통해 팬과의 소통을 강화할 수 있습니다." + }, + { + "comment": "저한테 해줬던 봉골레 파스타는 일취월장 하셨나요?? 임쉪??", + "sentiment": "긍정", + "comment_type": "질문", + "reply_needed": "예", + "reason": "아티스트의 개인적인 면모와 관련된 친근한 질문으로, 팬과의 유대감을 형성하고 소통하는 좋은 기회입니다." + } + ] + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4ac26a9..0800bb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ dependencies = [ "langgraph>=0.3.27", "langchain-community>=0.2.17", "python-dotenv>=1.0.1", + "langchain-google-genai>=2.1.8", + "pandas>=2.3.1", + "schedule>=1.2.2", + "ipykernel>=6.29.5", + "pydantic>=2.0.0", + "ruff>=0.12.9", ] [dependency-groups]