Add system memory monitoring, Bedrock support, and Discord integrations
- Add CPU/GPU memory monitoring endpoint and frontend widget - Add Amazon Bedrock model support (API key / inference profiles) plus model selector entries - Add Discord weather/laundry notification job (JMA forecast + LLM-generated message) - Add Discord bot that replies to mentions via the configured LLM, with weather-aware context - Add scheduled "useless tips" job with separate webhook and quiet-hours skip - Switch FastAPI startup to lifespan handler, log viewer, markdown rendering, and toast UI additions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
from . import chat, memory, logs, system
|
||||
|
||||
+48
-14
@@ -30,11 +30,29 @@ class SessionResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: int
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
def list_sessions(db: DBSession = Depends(get_db)):
|
||||
return db.query(Session).order_by(Session.updated_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", response_model=list[MessageResponse])
|
||||
def get_messages(session_id: str, db: DBSession = Depends(get_db)):
|
||||
session = db.get(Session, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session.messages
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=204)
|
||||
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||
session = db.get(Session, session_id)
|
||||
@@ -68,24 +86,40 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
context = mem.get_context_messages(db, session.id)
|
||||
|
||||
async def generate():
|
||||
import json as _json
|
||||
|
||||
def sse(payload: dict) -> str:
|
||||
return f"data: {_json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
# セッションIDをまず返す(新規セッション時にフロントへ通知)
|
||||
yield f"data: {{\"type\":\"session_id\",\"value\":\"{session.id}\"}}\n\n"
|
||||
yield sse({"type": "session_id", "value": session.id})
|
||||
|
||||
full_response = []
|
||||
async for chunk in llm.stream_chat(context, model=req.model or session.model):
|
||||
full_response.append(chunk)
|
||||
escaped = chunk.replace('"', '\\"').replace('\n', '\\n')
|
||||
yield f"data: {{\"type\":\"chunk\",\"value\":\"{escaped}\"}}\n\n"
|
||||
try:
|
||||
async for chunk in llm.stream_chat(context, model=req.model or session.model, session_id=session.id):
|
||||
full_response.append(chunk)
|
||||
yield sse({"type": "chunk", "value": chunk})
|
||||
|
||||
# アシスタントメッセージを保存
|
||||
assistant_content = "".join(full_response)
|
||||
db.add(Message(session_id=session.id, role="assistant", content=assistant_content))
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
# アシスタントメッセージを保存
|
||||
assistant_content = "".join(full_response)
|
||||
db.add(Message(session_id=session.id, role="assistant", content=assistant_content))
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
# メモリ要約チェック
|
||||
await mem.maybe_summarize(db, session)
|
||||
# メモリ要約チェック
|
||||
await mem.maybe_summarize(db, session)
|
||||
|
||||
yield "data: {\"type\":\"done\"}\n\n"
|
||||
yield sse({"type": "done"})
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
yield sse({"type": "error", "value": str(e)})
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.db.models import ApiLog
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
class LogResponse(BaseModel):
|
||||
id: int
|
||||
session_id: str | None
|
||||
model: str
|
||||
request_json: str
|
||||
response_text: str | None
|
||||
error: str | None
|
||||
latency_ms: float | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("", response_model=list[LogResponse])
|
||||
def list_logs(
|
||||
session_id: str | None = Query(None),
|
||||
status: str | None = Query(None, description="'ok' または 'error'"),
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0),
|
||||
db: DBSession = Depends(get_db),
|
||||
):
|
||||
q = db.query(ApiLog)
|
||||
if session_id:
|
||||
q = q.filter(ApiLog.session_id == session_id)
|
||||
if status == "ok":
|
||||
q = q.filter(ApiLog.error.is_(None))
|
||||
elif status == "error":
|
||||
q = q.filter(ApiLog.error.isnot(None))
|
||||
return q.order_by(desc(ApiLog.created_at)).offset(offset).limit(limit).all()
|
||||
|
||||
|
||||
@router.delete("/{log_id}", status_code=204)
|
||||
def delete_log(log_id: int, db: DBSession = Depends(get_db)):
|
||||
log = db.get(ApiLog, log_id)
|
||||
if log:
|
||||
db.delete(log)
|
||||
db.commit()
|
||||
@@ -0,0 +1,63 @@
|
||||
from fastapi import APIRouter
|
||||
import psutil
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.scheduler import send_useless_tip, send_weather_laundry_notification
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
|
||||
class MemoryStats(BaseModel):
|
||||
cpu_percent: float
|
||||
memory_percent: float
|
||||
memory_used_mb: float
|
||||
memory_total_mb: float
|
||||
gpu_memory_used_mb: float | None = None
|
||||
gpu_memory_total_mb: float | None = None
|
||||
gpu_memory_percent: float | None = None
|
||||
|
||||
|
||||
@router.get("/memory", response_model=MemoryStats)
|
||||
async def get_memory_stats():
|
||||
"""CPU と GPU のメモリ使用状況を返す"""
|
||||
# CPU/メモリ情報
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
stats = {
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_percent": memory.percent,
|
||||
"memory_used_mb": memory.used / (1024 * 1024),
|
||||
"memory_total_mb": memory.total / (1024 * 1024),
|
||||
}
|
||||
|
||||
# GPU 情報(オプション)
|
||||
try:
|
||||
import pynvml
|
||||
pynvml.nvmlInit()
|
||||
device_count = pynvml.nvmlDeviceGetCount()
|
||||
if device_count > 0:
|
||||
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
|
||||
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||
stats["gpu_memory_used_mb"] = mem_info.used / (1024 * 1024)
|
||||
stats["gpu_memory_total_mb"] = mem_info.total / (1024 * 1024)
|
||||
stats["gpu_memory_percent"] = (mem_info.used / mem_info.total) * 100
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return MemoryStats(**stats)
|
||||
|
||||
|
||||
@router.post("/weather-notify/test")
|
||||
async def test_weather_notify():
|
||||
"""天気予報→Discord通知を手動で即時実行する(動作確認用)"""
|
||||
await send_weather_laundry_notification()
|
||||
return {"status": "sent"}
|
||||
|
||||
|
||||
@router.post("/useless-tip/test")
|
||||
async def test_useless_tip():
|
||||
"""どうでもいいtips→Discord通知を手動で即時実行する(動作確認用)"""
|
||||
await send_useless_tip()
|
||||
return {"status": "sent"}
|
||||
@@ -7,3 +7,35 @@ DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./playchat.db")
|
||||
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
|
||||
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
|
||||
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||
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 に書くだけで自動的に有効になる
|
||||
AWS_BEARER_TOKEN_BEDROCK: str | None = os.getenv("AWS_BEARER_TOKEN_BEDROCK")
|
||||
AWS_REGION_NAME: str = os.getenv("AWS_REGION_NAME", "us-east-1")
|
||||
|
||||
# 天気予報 → Discord 通知
|
||||
DISCORD_WEBHOOK_URL: str | None = os.getenv("DISCORD_WEBHOOK_URL")
|
||||
WEATHER_AREA_CODE: str = os.getenv("WEATHER_AREA_CODE", "120000") # 千葉県
|
||||
WEATHER_AREA_KEYWORD: str = os.getenv("WEATHER_AREA_KEYWORD", "北西部") # 市川市は千葉県北西部
|
||||
WEATHER_NOTIFY_HOUR: int = int(os.getenv("WEATHER_NOTIFY_HOUR", "22"))
|
||||
WEATHER_NOTIFY_MINUTE: int = int(os.getenv("WEATHER_NOTIFY_MINUTE", "0"))
|
||||
# 通知メッセージをLLMで生成する場合に使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||
WEATHER_NOTIFY_MODEL: str | None = os.getenv("WEATHER_NOTIFY_MODEL")
|
||||
|
||||
# Discord Bot(メンションへの自動返信用)
|
||||
DISCORD_BOT_TOKEN: str | None = os.getenv("DISCORD_BOT_TOKEN")
|
||||
DISCORD_BOT_SYSTEM_PROMPT: str = os.getenv(
|
||||
"DISCORD_BOT_SYSTEM_PROMPT",
|
||||
"あなたは「お天気アプリ」という名前のDiscord Botです。"
|
||||
"天気や洗濯指数の質問には親しみやすく簡潔に答え、"
|
||||
"雑談にも気さくに応じてください。絵文字を適度に使って親しみやすい口調で話します。",
|
||||
)
|
||||
|
||||
# どうでもいいTipsを定期的にDiscordへ投稿する機能
|
||||
USELESS_TIPS_ENABLED: bool = os.getenv("USELESS_TIPS_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
USELESS_TIPS_INTERVAL_HOURS: int = int(os.getenv("USELESS_TIPS_INTERVAL_HOURS", "3"))
|
||||
USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
|
||||
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
|
||||
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL")
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""DiscordでメンションされたらplaychatのLLMが返信するBot。"""
|
||||
import logging
|
||||
|
||||
import discord
|
||||
|
||||
from app.core.config import (
|
||||
DISCORD_BOT_SYSTEM_PROMPT,
|
||||
DISCORD_BOT_TOKEN,
|
||||
WEATHER_AREA_CODE,
|
||||
WEATHER_AREA_KEYWORD,
|
||||
WEATHER_NOTIFY_MODEL,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.weather import fetch_tomorrow_weather, judge_laundry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
|
||||
client = discord.Client(intents=intents)
|
||||
|
||||
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
|
||||
|
||||
|
||||
async def _build_weather_context() -> str:
|
||||
"""明日の天気データを取得し、LLMに渡すコンテキスト文字列を作る。失敗時は取得不可の旨を返す。"""
|
||||
try:
|
||||
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 "不明"
|
||||
return (
|
||||
"【明日の天気予報データ(気象庁発表)】\n"
|
||||
f"天気: {weather_data['weather']}\n"
|
||||
f"降水確率: {pop_text}\n"
|
||||
f"洗濯指数: {judgement}({reason})\n"
|
||||
"このデータをもとにユーザーへ回答してください。"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch weather data for Discord reply")
|
||||
return "(明日の天気データの取得に失敗しました。一般的な回答をしてください。)"
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_ready():
|
||||
logger.info("Discord bot logged in as %s", client.user)
|
||||
|
||||
|
||||
@client.event
|
||||
async def on_message(message: discord.Message):
|
||||
if message.author.bot:
|
||||
return
|
||||
if client.user not in message.mentions:
|
||||
return
|
||||
|
||||
content = message.content.replace(f"<@{client.user.id}>", "").strip()
|
||||
if not content:
|
||||
content = "こんにちは"
|
||||
|
||||
async with message.channel.typing():
|
||||
try:
|
||||
messages = [{"role": "system", "content": DISCORD_BOT_SYSTEM_PROMPT}]
|
||||
|
||||
if any(keyword in content for keyword in WEATHER_KEYWORDS):
|
||||
weather_context = await _build_weather_context()
|
||||
messages.append({"role": "system", "content": weather_context})
|
||||
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
reply = await complete(messages, model=WEATHER_NOTIFY_MODEL)
|
||||
await message.reply(reply or "(応答が空でした)")
|
||||
except Exception:
|
||||
logger.exception("Failed to generate reply for Discord mention")
|
||||
await message.reply("⚠️ 応答の生成に失敗しました")
|
||||
|
||||
|
||||
async def start_discord_bot() -> None:
|
||||
if not DISCORD_BOT_TOKEN:
|
||||
logger.warning("DISCORD_BOT_TOKEN is not set; Discord bot will not start")
|
||||
return
|
||||
try:
|
||||
await client.start(DISCORD_BOT_TOKEN)
|
||||
except Exception:
|
||||
logger.exception("Discord bot failed to start")
|
||||
+83
-18
@@ -1,29 +1,94 @@
|
||||
from typing import AsyncIterator
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from typing import AsyncIterator, AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import litellm
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
from app.core.config import DEFAULT_MODEL, DEBUG
|
||||
|
||||
litellm.drop_params = True
|
||||
if DEBUG:
|
||||
litellm._turn_on_debug()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _log_context(
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
session_id: str | None = None,
|
||||
) -> AsyncGenerator[dict, None]:
|
||||
"""LLM呼び出しの前後でログデータを収集するコンテキストマネージャー。"""
|
||||
log: dict = {
|
||||
"session_id": session_id,
|
||||
"model": model,
|
||||
"request_json": json.dumps(messages, ensure_ascii=False),
|
||||
"response_text": None,
|
||||
"error": None,
|
||||
"latency_ms": None,
|
||||
}
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
yield log
|
||||
except Exception as e:
|
||||
log["error"] = str(e)
|
||||
log["latency_ms"] = round((time.perf_counter() - start) * 1000, 1)
|
||||
logger.error("LLM call failed [model=%s]: %s\n%s", log["model"], e, traceback.format_exc())
|
||||
raise
|
||||
finally:
|
||||
if log["latency_ms"] is None:
|
||||
log["latency_ms"] = round((time.perf_counter() - start) * 1000, 1)
|
||||
|
||||
|
||||
def _save_log(log: dict) -> None:
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import ApiLog
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(ApiLog(**log))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
response = await litellm.acompletion(
|
||||
model=model or DEFAULT_MODEL,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
yield delta
|
||||
used_model = model or DEFAULT_MODEL
|
||||
async with _log_context(used_model, messages, session_id) as log:
|
||||
response = await litellm.acompletion(
|
||||
model=used_model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
chunks: list[str] = []
|
||||
async for chunk in response:
|
||||
delta = chunk.choices[0].delta.content
|
||||
if delta:
|
||||
chunks.append(delta)
|
||||
yield delta
|
||||
log["response_text"] = "".join(chunks)
|
||||
_save_log(log)
|
||||
|
||||
|
||||
async def complete(messages: list[dict], model: str | None = None) -> str:
|
||||
response = await litellm.acompletion(
|
||||
model=model or DEFAULT_MODEL,
|
||||
messages=messages,
|
||||
stream=False,
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
async def complete(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
used_model = model or DEFAULT_MODEL
|
||||
async with _log_context(used_model, messages, session_id) as log:
|
||||
response = await litellm.acompletion(
|
||||
model=used_model,
|
||||
messages=messages,
|
||||
stream=False,
|
||||
)
|
||||
result = response.choices[0].message.content or ""
|
||||
log["response_text"] = result
|
||||
_save_log(log)
|
||||
return result
|
||||
|
||||
@@ -36,7 +36,7 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
|
||||
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
|
||||
*[{"role": m.role, "content": m.content} for m in to_summarize],
|
||||
]
|
||||
summary_text = await llm.complete(summary_prompt)
|
||||
summary_text = await llm.complete(summary_prompt, session_id=session.id)
|
||||
|
||||
db.add(Memory(session_id=session.id, summary=summary_text))
|
||||
for msg in to_summarize:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Discord Webhook への通知送信。"""
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_discord_message(webhook_url: str, content: str) -> None:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(webhook_url, json={"content": content})
|
||||
response.raise_for_status()
|
||||
logger.info("Discord message sent (status=%s)", response.status_code)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""毎日決まった時刻に天気予報と洗濯指数をDiscordへ送信するジョブ。"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from app.core.config import (
|
||||
DISCORD_WEBHOOK_URL,
|
||||
DISCORD_WEBHOOK_URL_TIPS,
|
||||
USELESS_TIPS_ENABLED,
|
||||
USELESS_TIPS_INTERVAL_HOURS,
|
||||
USELESS_TIPS_MODEL,
|
||||
WEATHER_AREA_CODE,
|
||||
WEATHER_AREA_KEYWORD,
|
||||
WEATHER_NOTIFY_HOUR,
|
||||
WEATHER_NOTIFY_MINUTE,
|
||||
WEATHER_NOTIFY_MODEL,
|
||||
)
|
||||
from app.core.llm import complete
|
||||
from app.core.notify import send_discord_message
|
||||
from app.core.weather import fetch_tomorrow_weather, judge_laundry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def send_weather_laundry_notification() -> None:
|
||||
if not DISCORD_WEBHOOK_URL:
|
||||
logger.warning("DISCORD_WEBHOOK_URL is not set; skipping weather notification")
|
||||
return
|
||||
|
||||
try:
|
||||
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 "不明"
|
||||
|
||||
content = await _generate_message(weather_data["weather"], pop_text, judgement, reason)
|
||||
await send_discord_message(DISCORD_WEBHOOK_URL, content)
|
||||
except Exception:
|
||||
logger.exception("Failed to send weather/laundry notification")
|
||||
|
||||
|
||||
async def _generate_message(weather: str, pop_text: str, judgement: str, reason: str) -> str:
|
||||
"""LLMに天気データを渡し、Discord向けのメッセージ文を生成させる。失敗時は固定テンプレートにフォールバックする。"""
|
||||
fallback = (
|
||||
"🌤️ **明日の天気予報**\n"
|
||||
f"天気: {weather}\n"
|
||||
f"降水確率: {pop_text}\n\n"
|
||||
f"👕 **洗濯物**: {judgement}\n"
|
||||
f"({reason})"
|
||||
)
|
||||
|
||||
prompt = (
|
||||
"以下の天気データをもとに、Discordに送る通知メッセージを作成してください。\n"
|
||||
"- 絵文字を使って読みやすく\n"
|
||||
"- 「明日の天気」と「洗濯物を外に干すべきか」の2点を必ず含める\n"
|
||||
"- 簡潔に3〜5行程度\n"
|
||||
"- Markdown装飾(**太字**など)はDiscordの記法に合わせて可\n"
|
||||
"- 出力はメッセージ本文のみ(前置きや説明文は不要)\n\n"
|
||||
f"天気: {weather}\n"
|
||||
f"降水確率: {pop_text}\n"
|
||||
f"洗濯判定: {judgement}(理由: {reason})"
|
||||
)
|
||||
|
||||
try:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
result = await complete(messages, model=WEATHER_NOTIFY_MODEL)
|
||||
return result.strip() or fallback
|
||||
except Exception:
|
||||
logger.exception("LLM message generation failed; falling back to template")
|
||||
return fallback
|
||||
|
||||
|
||||
async def send_useless_tip() -> None:
|
||||
if not DISCORD_WEBHOOK_URL_TIPS:
|
||||
logger.warning("DISCORD_WEBHOOK_URL_TIPS is not set; skipping useless tip")
|
||||
return
|
||||
|
||||
if datetime.now().hour == 3:
|
||||
logger.info("Skipping useless tip during 3am quiet hour")
|
||||
return
|
||||
|
||||
prompt = (
|
||||
"誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識を一つ教えてください。\n"
|
||||
"- 1〜3文程度で簡潔に\n"
|
||||
"- 「どうでもいい」感が伝わるようなノリで\n"
|
||||
"- 絵文字を1つくらい添えてもよい\n"
|
||||
"- 出力は本文のみ(前置き・説明不要)\n"
|
||||
"- 過去に何度も言われていそうな定番ネタは避け、できるだけ意外性のある内容にする"
|
||||
)
|
||||
|
||||
try:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
tip = await complete(messages, model=USELESS_TIPS_MODEL)
|
||||
content = f"🤷 **どうでもいいtips**\n{tip.strip()}"
|
||||
await send_discord_message(DISCORD_WEBHOOK_URL_TIPS, content)
|
||||
except Exception:
|
||||
logger.exception("Failed to send useless tip")
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
scheduler.add_job(
|
||||
send_weather_laundry_notification,
|
||||
trigger=CronTrigger(hour=WEATHER_NOTIFY_HOUR, minute=WEATHER_NOTIFY_MINUTE),
|
||||
id="weather_laundry_notification",
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info(
|
||||
"Scheduler started: weather notification at %02d:%02d",
|
||||
WEATHER_NOTIFY_HOUR,
|
||||
WEATHER_NOTIFY_MINUTE,
|
||||
)
|
||||
|
||||
if USELESS_TIPS_ENABLED:
|
||||
scheduler.add_job(
|
||||
send_useless_tip,
|
||||
trigger=IntervalTrigger(hours=USELESS_TIPS_INTERVAL_HOURS),
|
||||
id="useless_tips",
|
||||
replace_existing=True,
|
||||
)
|
||||
logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS)
|
||||
|
||||
scheduler.start()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""気象庁の無料JSON APIから天気予報を取得し、洗濯指数を判定する。"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
JMA_FORECAST_URL = "https://www.jma.go.jp/bosai/forecast/data/forecast/{area_code}.json"
|
||||
|
||||
|
||||
async def fetch_tomorrow_weather(area_code: str, area_name_keyword: str) -> dict:
|
||||
"""指定エリアの明日の天気予報を取得する。
|
||||
|
||||
area_code: 気象庁の府県予報区コード(例: 千葉県 = 120000)
|
||||
area_name_keyword: 府県内の地域区分名の一部(例: 市川市が属する「北西部」)
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(JMA_FORECAST_URL.format(area_code=area_code))
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# data[0] が短期予報(今日・明日・明後日)
|
||||
short_term = data[0]
|
||||
time_series = short_term["timeSeries"]
|
||||
|
||||
weather_series = time_series[0]
|
||||
pop_series = time_series[1] if len(time_series) > 1 else None
|
||||
|
||||
tomorrow_index = _find_tomorrow_index(weather_series["timeDefines"])
|
||||
|
||||
weather_area = _find_area(weather_series["areas"], area_name_keyword)
|
||||
weather_text = weather_area["weathers"][tomorrow_index] if weather_area else "不明"
|
||||
|
||||
pop_value = None
|
||||
if pop_series:
|
||||
pop_tomorrow_index = _find_tomorrow_index(pop_series["timeDefines"])
|
||||
pop_area = _find_area(pop_series["areas"], area_name_keyword)
|
||||
if pop_area and pop_area.get("pops"):
|
||||
pops = pop_area["pops"]
|
||||
# pops は3時間ごとの配列のことが多いため、明日に該当する値の最大値を取る
|
||||
relevant_pops = [
|
||||
int(p) for p, t in zip(pops, pop_series["timeDefines"])
|
||||
if _is_same_date(t, pop_series["timeDefines"][pop_tomorrow_index]) and p
|
||||
]
|
||||
if relevant_pops:
|
||||
pop_value = max(relevant_pops)
|
||||
|
||||
return {
|
||||
"weather": weather_text.strip(),
|
||||
"pop": pop_value,
|
||||
}
|
||||
|
||||
|
||||
def _find_tomorrow_index(time_defines: list[str]) -> int:
|
||||
tomorrow = (datetime.now() + timedelta(days=1)).date()
|
||||
for i, t in enumerate(time_defines):
|
||||
dt = datetime.fromisoformat(t)
|
||||
if dt.date() == tomorrow:
|
||||
return i
|
||||
return min(1, len(time_defines) - 1)
|
||||
|
||||
|
||||
def _is_same_date(t1: str, t2: str) -> bool:
|
||||
return datetime.fromisoformat(t1).date() == datetime.fromisoformat(t2).date()
|
||||
|
||||
|
||||
def _find_area(areas: list[dict], keyword: str) -> dict | None:
|
||||
for area in areas:
|
||||
if keyword in area["area"]["name"]:
|
||||
return area
|
||||
return areas[0] if areas else None
|
||||
|
||||
|
||||
def judge_laundry(weather: str, pop: int | None) -> tuple[str, str]:
|
||||
"""天気と降水確率から洗濯指数を判定する。戻り値: (判定, 理由)"""
|
||||
is_rainy = "雨" in weather or "雪" in weather
|
||||
|
||||
if is_rainy:
|
||||
return "室内干し推奨", f"明日は「{weather}」のため"
|
||||
if pop is None:
|
||||
return "外干しOK(降水確率不明)", "降水確率データが取得できませんでした"
|
||||
if pop <= 30:
|
||||
return "外干しOK", f"降水確率{pop}%と低めです"
|
||||
if pop <= 50:
|
||||
return "室内干し推奨", f"降水確率{pop}%とやや高めです"
|
||||
return "室内干し推奨", f"降水確率{pop}%と高いです"
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from app.db.database import Base
|
||||
|
||||
@@ -39,3 +39,16 @@ class Memory(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="memories")
|
||||
|
||||
|
||||
class ApiLog(Base):
|
||||
__tablename__ = "api_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[str | None] = mapped_column(ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True)
|
||||
model: Mapped[str] = mapped_column(String)
|
||||
request_json: Mapped[str] = mapped_column(Text) # JSON: messages array
|
||||
response_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
+18
-7
@@ -1,11 +1,25 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.config import CORS_ORIGINS
|
||||
from app.core.discord_bot import start_discord_bot
|
||||
from app.core.scheduler import start_scheduler
|
||||
from app.db.database import init_db
|
||||
from app.api import chat, memory
|
||||
from app.api import chat, memory, logs, system
|
||||
|
||||
app = FastAPI(title="PlayChat API", version="0.1.0")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
start_scheduler()
|
||||
asyncio.create_task(start_discord_bot())
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="PlayChat API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -16,13 +30,10 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
init_db()
|
||||
|
||||
|
||||
app.include_router(chat.router)
|
||||
app.include_router(memory.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(system.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
Reference in New Issue
Block a user