Reorganize core into subpackages, add local KB, ruff/pyright tooling

- Split backend/app/core into weather/, discord/, memory_kb/, search/
  subpackages by responsibility, keeping config.py and llm.py as the
  shared foundation; updated all import sites accordingly.
- Add local Markdown knowledge base support (local_knowledge.py,
  knowledge_base/fgo.md, LOCAL_KB_* settings) that embeds and injects
  relevant docs into the system prompt by topic similarity.
- Add ruff and pyright as backend dev dependencies with basic config
  in pyproject.toml for linting and type checking.
- Document local machine quirks in CLAUDE.md (Postgres on port 8080,
  SSLKEYLOGFILE breaking litellm/pyright via OPENSSL_Uplink crashes).
- Add backend/run_dev.ps1 dev launcher and docs/discord-bot.md.
This commit is contained in:
2026-06-21 11:44:11 +09:00
parent 234ebba103
commit 347230e3c8
29 changed files with 825 additions and 31 deletions
+125
View File
@@ -0,0 +1,125 @@
"""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.discord.scheduler import send_useless_tip
from app.core.memory_kb.tagging import find_related_tag, generate_tags
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
from app.core.weather.weather_cache import set_latest
from app.core.memory_kb.word_memory import record_message
logger = logging.getLogger(__name__)
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
WEATHER_KEYWORDS = ("天気", "天候", "洗濯", "雨", "気温", "降水")
USELESS_TALK_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 "不明"
set_latest(weather_data["weather"], weather_data["pop"], judgement, reason)
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 = "こんにちは"
record_message(content)
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)
# 振られた話題のタグを生成し、それに近しい話題からTipsのネタを作る
seed_tags = await generate_tags(content)
related_tag = find_related_tag(seed_tags)
await send_useless_tip(topic_override=related_tag)
return
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)
if reply:
record_message(reply)
await message.reply(reply or "(応答が空でした)")
except Exception:
logger.exception("Failed to generate reply for Discord mention")
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")
return
try:
await client.start(DISCORD_BOT_TOKEN)
except Exception:
logger.exception("Discord bot failed to start")
+13
View File
@@ -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)
+147
View File
@@ -0,0 +1,147 @@
"""毎日決まった時刻に天気予報と洗濯指数を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.discord.notify import send_discord_message
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
from app.core.weather.weather_cache import set_latest
from app.core.memory_kb.word_memory import pick_random_word
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 "不明"
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)
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(topic_override: str | None = None) -> None:
"""どうでもいいtipsをDiscordへ送信する。
topic_overrideを渡すと、ワードプールのランダム単語より優先してそのトピックでネタを作る。
雑談Botがタグベースで『近しい話題』を渡したいときに使う。
"""
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
topic_word = topic_override or pick_random_word()
prompt = (
"あなたは『どうでもいいTips』という名前のDiscord Botです。"
"あなた自身は、これから話す雑学が誰かの役に立つ、有益な豆知識だと心の底から信じ切っています。"
"本人にその自覚は一切なく、本気の善意と謎の使命感で「これは知っておいた方がいい」と思って共有しています。\n\n"
"実際の内容は、誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識にしてください。\n"
"- 全体で60文字以内(絵文字含む)に必ず収める。1文の短いつぶやきでよい\n"
"- 「これは役に立ちますよ」「知っておくと得しますよ」というような、善意・自信たっぷりの前置きや一言を添える\n"
"- ただし中身は本当にどうでもいい内容にする(このギャップが面白さのポイント)\n"
"- 「どや顔」「これ豆な」的な、ちょっと得意げなノリにする\n"
"- 絵文字を1つくらい添えてもよい\n"
"- 出力は本文のみ(メタな説明やこの指示の繰り返しは不要)\n"
"- 過去に何度も言われていそうな定番ネタは避け、できるだけ意外性のある内容にする"
)
if topic_word:
prompt += (
f"\n- 雑談Botでの会話に出てきた「{topic_word}」という話題に、こじつけでもいいので"
"何らかの形でネタを絡めてください(無関係でも強引に繋げてOK)"
)
try:
messages = [{"role": "user", "content": prompt}]
tip = await complete(messages, model=USELESS_TIPS_MODEL)
content = f"💡 **今日のお役立ち情報**\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()