diff --git a/backend/.env.example b/backend/.env.example index 873a935..93093bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,9 +6,12 @@ OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... # Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定 OLLAMA_API_BASE=http://localhost:11434 -# Amazon Bedrock (API key方式) — モデル名は bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 形式で指定 -AWS_BEARER_TOKEN_BEDROCK= +# Amazon Bedrock (IAM経由 — Access Key方式) — モデル名は bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 形式で指定 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= AWS_REGION_NAME=us-east-1 +# 旧: API key方式(Bearer Token)。IAMを使う場合は不要 +AWS_BEARER_TOKEN_BEDROCK= # デバッグ (true にすると LiteLLM の詳細ログが出力される) DEBUG=false diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 8764a66..fc2b07c 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -3,6 +3,7 @@ import psutil from pydantic import BaseModel from app.core.scheduler import send_useless_tip, send_weather_laundry_notification +from app.core.weather_cache import get_latest as get_latest_weather router = APIRouter(prefix="/api/system", tags=["system"]) @@ -61,3 +62,9 @@ async def test_useless_tip(): """どうでもいいtips→Discord通知を手動で即時実行する(動作確認用)""" await send_useless_tip() return {"status": "sent"} + + +@router.get("/weather/latest") +async def get_latest_weather_data(): + """直近で取得した明日の天気データを返す(未取得ならnull)""" + return get_latest_weather() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c8dc468..6b90eec 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -10,8 +10,13 @@ CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").spl DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") # Amazon Bedrock -# AWS_BEARER_TOKEN_BEDROCK (API key方式) / AWS_REGION_NAME は -# LiteLLM が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる +# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。 +# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。 +# API key方式(旧): AWS_BEARER_TOKEN_BEDROCK のみを設定する。 +# 両方設定されている場合はIAM認証(Access Key)が優先される。 +AWS_ACCESS_KEY_ID: str | None = os.getenv("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY: str | None = os.getenv("AWS_SECRET_ACCESS_KEY") +AWS_SESSION_TOKEN: str | None = os.getenv("AWS_SESSION_TOKEN") # 一時的なIAMクレデンシャルを使う場合のみ AWS_BEARER_TOKEN_BEDROCK: str | None = os.getenv("AWS_BEARER_TOKEN_BEDROCK") AWS_REGION_NAME: str = os.getenv("AWS_REGION_NAME", "us-east-1") diff --git a/backend/app/core/discord_bot.py b/backend/app/core/discord_bot.py index 39bfe81..b4cbe8a 100644 --- a/backend/app/core/discord_bot.py +++ b/backend/app/core/discord_bot.py @@ -11,7 +11,9 @@ from app.core.config import ( WEATHER_NOTIFY_MODEL, ) from app.core.llm import complete +from app.core.scheduler import send_useless_tip from app.core.weather import fetch_tomorrow_weather, judge_laundry +from app.core.weather_cache import set_latest logger = logging.getLogger(__name__) @@ -21,6 +23,7 @@ intents.message_content = True client = discord.Client(intents=intents) WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水") +USELESS_TALK_KEYWORDS = ("どうでもいい", "雑談", "暇", "つまんない", "退屈") async def _build_weather_context() -> str: @@ -29,6 +32,7 @@ async def _build_weather_context() -> str: weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD) judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"]) pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明" + set_latest(weather_data["weather"], weather_data["pop"], judgement, reason) return ( "【明日の天気予報データ(気象庁発表)】\n" f"天気: {weather_data['weather']}\n" @@ -59,6 +63,12 @@ async def on_message(message: discord.Message): async with message.channel.typing(): try: + if any(keyword in content for keyword in USELESS_TALK_KEYWORDS): + redirect = await _generate_useless_talk_redirect(content) + await message.reply(redirect) + await send_useless_tip() + return + messages = [{"role": "system", "content": DISCORD_BOT_SYSTEM_PROMPT}] if any(keyword in content for keyword in WEATHER_KEYWORDS): @@ -74,6 +84,27 @@ async def on_message(message: discord.Message): await message.reply("⚠️ 応答の生成に失敗しました") +async def _generate_useless_talk_redirect(content: str) -> str: + """どうでもいい話を振られたとき、『どうでもいいTips』チャンネルへ誘導する一言を生成する。""" + fallback = "🤷 雑談なら『どうでもいいTips』チャンネルの出番です!今ちょうど一つ送りますね〜" + + prompt = ( + "あなたは「お天気アプリ」というDiscord Botです。" + "ユーザーがどうでもいい雑談を振ってきました。" + "あなたは天気専門なので、雑談は『どうでもいいTips』という別のBot/チャンネルにお任せしたいです。" + "そこへ話題を振るような、軽いノリの一言を1〜2文で返してください。" + "絵文字を1つ程度使い、出力は本文のみにしてください。\n\n" + f"ユーザーの発言: {content}" + ) + + try: + result = await complete([{"role": "user", "content": prompt}], model=WEATHER_NOTIFY_MODEL) + return result.strip() or fallback + except Exception: + logger.exception("Failed to generate useless talk redirect message") + return fallback + + async def start_discord_bot() -> None: if not DISCORD_BOT_TOKEN: logger.warning("DISCORD_BOT_TOKEN is not set; Discord bot will not start") diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index 0d880b3..f20a533 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -22,6 +22,7 @@ from app.core.config import ( from app.core.llm import complete from app.core.notify import send_discord_message from app.core.weather import fetch_tomorrow_weather, judge_laundry +from app.core.weather_cache import set_latest logger = logging.getLogger(__name__) @@ -37,6 +38,7 @@ async def send_weather_laundry_notification() -> None: weather_data = await fetch_tomorrow_weather(WEATHER_AREA_CODE, WEATHER_AREA_KEYWORD) judgement, reason = judge_laundry(weather_data["weather"], weather_data["pop"]) pop_text = f"{weather_data['pop']}%" if weather_data["pop"] is not None else "不明" + set_latest(weather_data["weather"], weather_data["pop"], judgement, reason) content = await _generate_message(weather_data["weather"], pop_text, judgement, reason) await send_discord_message(DISCORD_WEBHOOK_URL, content) diff --git a/backend/app/core/weather_cache.py b/backend/app/core/weather_cache.py new file mode 100644 index 0000000..44fd12f --- /dev/null +++ b/backend/app/core/weather_cache.py @@ -0,0 +1,19 @@ +"""直近で取得した「明日の天気」データをメモリ上にキャッシュする。""" +from datetime import datetime + +_latest: dict | None = None + + +def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> None: + global _latest + _latest = { + "weather": weather, + "pop": pop, + "judgement": judgement, + "reason": reason, + "fetched_at": datetime.now().isoformat(), + } + + +def get_latest() -> dict | None: + return _latest diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d6fb1b5..7c3a5d8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,7 @@ import ModelSelector from './components/ModelSelector.vue' import LogViewer from './components/LogViewer.vue' import ToastList from './components/ToastList.vue' import MemoryMonitor from './components/MemoryMonitor.vue' +import Clock from './components/Clock.vue' const showLogs = ref(false) @@ -21,6 +22,7 @@ const showLogs = ref(false)
PlayChat + diff --git a/frontend/src/components/Clock.vue b/frontend/src/components/Clock.vue new file mode 100644 index 0000000..f10b4c1 --- /dev/null +++ b/frontend/src/components/Clock.vue @@ -0,0 +1,87 @@ + + + + +