diff --git a/backend/.env.example b/backend/.env.example index 93093bd..a92474d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,6 +16,11 @@ AWS_BEARER_TOKEN_BEDROCK= # デバッグ (true にすると LiteLLM の詳細ログが出力される) DEBUG=false +# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする) +TAVILY_API_KEY= +# 蓄積知識をシステムプロンプトに注入する際の最大件数 +KNOWLEDGE_CONTEXT_LIMIT=15 + # 天気予報 → Discord 通知 DISCORD_WEBHOOK_URL= WEATHER_AREA_CODE=120000 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index f3b03ff..6862d7f 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -1,3 +1,4 @@ +import asyncio import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException @@ -9,7 +10,11 @@ from app.db.database import get_db from app.db.models import Session, Message from app.core import memory as mem from app.core import llm +from app.core import knowledge +from app.core.websearch import WEB_SEARCH_TOOL from app.core.config import DEFAULT_MODEL +from app.core.tagging import tag_message_by_id +from app.core.word_memory import record_message router = APIRouter(prefix="/chat", tags=["chat"]) @@ -35,6 +40,7 @@ class MessageResponse(BaseModel): role: str content: str created_at: datetime + tags: list[str] = [] class Config: from_attributes = True @@ -50,7 +56,16 @@ 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 + return [ + MessageResponse( + id=m.id, + role=m.role, + content=m.content, + created_at=m.created_at, + tags=[t.tag for t in m.tags], + ) + for m in session.messages + ] @router.delete("/sessions/{session_id}", status_code=204) @@ -82,6 +97,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): user_msg = Message(session_id=session.id, role="user", content=req.message) db.add(user_msg) db.commit() + db.refresh(user_msg) + + record_message(req.message) + asyncio.create_task(tag_message_by_id(user_msg.id)) context = mem.get_context_messages(db, session.id) @@ -94,17 +113,40 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): # セッションIDをまず返す(新規セッション時にフロントへ通知) yield sse({"type": "session_id", "value": session.id}) + used_model = req.model or session.model full_response = [] try: - async for chunk in llm.stream_chat(context, model=req.model or session.model, session_id=session.id): + resolved_context, searches = await llm.resolve_web_search( + context, model=used_model, session_id=session.id, + ) + if searches: + sources = [ + {"title": r["title"], "url": r["url"]} + for s in searches for r in s["results"] + ] + yield sse({"type": "sources", "value": sources}) + for s in searches: + summary = "\n".join(r["content"][:300] for r in s["results"] if r["content"]) + if summary: + knowledge.save_knowledge(db, s["query"], summary, s["results"]) + + search_tools = [WEB_SEARCH_TOOL] if len(resolved_context) > len(context) else None + async for chunk in llm.stream_chat( + resolved_context, model=used_model, session_id=session.id, tools=search_tools, + ): 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)) + assistant_msg = Message(session_id=session.id, role="assistant", content=assistant_content) + db.add(assistant_msg) session.updated_at = datetime.now(timezone.utc) db.commit() + db.refresh(assistant_msg) + + record_message(assistant_content) + asyncio.create_task(tag_message_by_id(assistant_msg.id)) # メモリ要約チェック await mem.maybe_summarize(db, session) diff --git a/backend/app/api/system.py b/backend/app/api/system.py index fc2b07c..ff1e334 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -1,9 +1,13 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends import psutil from pydantic import BaseModel +from sqlalchemy.orm import Session as DBSession from app.core.scheduler import send_useless_tip, send_weather_laundry_notification +from app.core.tagging import backfill_tags from app.core.weather_cache import get_latest as get_latest_weather +from app.core.word_memory import get_word_pool_with_counts +from app.db.database import get_db router = APIRouter(prefix="/api/system", tags=["system"]) @@ -68,3 +72,16 @@ async def test_useless_tip(): async def get_latest_weather_data(): """直近で取得した明日の天気データを返す(未取得ならnull)""" return get_latest_weather() + + +@router.get("/word-pool") +async def get_word_pool(): + """どうでもいいtips生成に使われているワードプールを出現回数順で返す""" + return get_word_pool_with_counts() + + +@router.post("/tags/backfill") +async def run_tags_backfill(limit: int | None = None, db: DBSession = Depends(get_db)): + """過去のユーザーメッセージのうち、タグが未生成のものにまとめてタグを付ける""" + count = await backfill_tags(db, limit=limit) + return {"tagged_count": count} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6b90eec..8e81dcd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -9,6 +9,14 @@ MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "2 CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",") DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes") +# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用) +TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL") + +# Tavily Web検索 +TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY") +# 蓄積した知識をシステムプロンプトに注入する際の最大件数 +KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15")) + # Amazon Bedrock # 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。 # IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。 diff --git a/backend/app/core/discord_bot.py b/backend/app/core/discord_bot.py index b4cbe8a..a69a1e5 100644 --- a/backend/app/core/discord_bot.py +++ b/backend/app/core/discord_bot.py @@ -12,8 +12,10 @@ from app.core.config import ( ) from app.core.llm import complete from app.core.scheduler import send_useless_tip +from app.core.tagging import find_related_tag, generate_tags from app.core.weather import fetch_tomorrow_weather, judge_laundry from app.core.weather_cache import set_latest +from app.core.word_memory import record_message logger = logging.getLogger(__name__) @@ -61,12 +63,18 @@ async def on_message(message: discord.Message): 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) - await send_useless_tip() + + # 振られた話題のタグを生成し、それに近しい話題から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}] @@ -78,6 +86,8 @@ async def on_message(message: discord.Message): 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") diff --git a/backend/app/core/knowledge.py b/backend/app/core/knowledge.py new file mode 100644 index 0000000..8cb7b7c --- /dev/null +++ b/backend/app/core/knowledge.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Session as DBSession + +from app.core.config import KNOWLEDGE_CONTEXT_LIMIT +from app.db.models import KnowledgeEntry, KnowledgeSource + + +def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry: + """Web検索で得た知見をDBに保存する。""" + entry = KnowledgeEntry(query=query, summary=summary) + db.add(entry) + db.flush() + for src in sources: + if not src.get("url"): + continue + db.add(KnowledgeSource(entry_id=entry.id, title=src.get("title", ""), url=src["url"])) + db.commit() + db.refresh(entry) + return entry + + +def get_knowledge_context(db: DBSession) -> str | None: + """蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。""" + entries = ( + db.query(KnowledgeEntry) + .order_by(KnowledgeEntry.created_at.desc()) + .limit(KNOWLEDGE_CONTEXT_LIMIT) + .all() + ) + if not entries: + return None + + blocks = [] + for entry in reversed(entries): + urls = ", ".join(s.url for s in entry.sources) or "出典不明" + blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})") + + return "以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください:\n\n" + "\n".join(blocks) diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index c866c6e..aa6c342 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager import litellm from app.core.config import DEFAULT_MODEL, DEBUG +from app.core.websearch import WEB_SEARCH_TOOL, search_web litellm.drop_params = True if DEBUG: @@ -58,6 +59,7 @@ async def stream_chat( messages: list[dict], model: str | None = None, session_id: str | None = None, + tools: list[dict] | None = None, ) -> AsyncIterator[str]: used_model = model or DEFAULT_MODEL async with _log_context(used_model, messages, session_id) as log: @@ -65,6 +67,7 @@ async def stream_chat( model=used_model, messages=messages, stream=True, + **({"tools": tools} if tools else {}), ) chunks: list[str] = [] async for chunk in response: @@ -92,3 +95,70 @@ async def complete( log["response_text"] = result _save_log(log) return result + + +async def resolve_web_search( + messages: list[dict], + model: str | None = None, + session_id: str | None = None, + max_rounds: int = 2, +) -> tuple[list[dict], list[dict]]: + """必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。 + + モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。 + 戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト) + """ + used_model = model or DEFAULT_MODEL + working_messages = list(messages) + searches: list[dict] = [] + + for _ in range(max_rounds): + try: + response = await litellm.acompletion( + model=used_model, + messages=working_messages, + tools=[WEB_SEARCH_TOOL], + tool_choice="auto", + ) + except Exception: + logger.info("Model %s does not support tool calling; skipping web search", used_model) + return messages, searches + + choice_message = response.choices[0].message + tool_calls = getattr(choice_message, "tool_calls", None) + if not tool_calls: + break + + working_messages.append({ + "role": "assistant", + "content": choice_message.content or "", + "tool_calls": [ + { + "id": tc.id, + "type": "function", + "function": {"name": tc.function.name, "arguments": tc.function.arguments}, + } + for tc in tool_calls + ], + }) + + for tc in tool_calls: + try: + args = json.loads(tc.function.arguments or "{}") + except json.JSONDecodeError: + args = {} + query = args.get("query", "") + results = await search_web(query) + if results: + tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results) + searches.append({"query": query, "results": results}) + else: + tool_text = "検索結果が見つかりませんでした。" + + working_messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": tool_text, + }) + + return working_messages, searches diff --git a/backend/app/core/memory.py b/backend/app/core/memory.py index 53d3ecb..c7d21d8 100644 --- a/backend/app/core/memory.py +++ b/backend/app/core/memory.py @@ -2,15 +2,21 @@ from sqlalchemy.orm import Session as DBSession from app.db.models import Memory, Message, Session from app.core.config import MEMORY_SUMMARIZE_THRESHOLD from app.core import llm +from app.core import knowledge +from app.core.tagging import tag_memory def get_context_messages(db: DBSession, session_id: str) -> list[dict]: - """短期メモリ(最近のメッセージ)と長期メモリ(要約)を結合してコンテキストを返す。""" + """蓄積知識・長期メモリ(要約)・短期メモリ(最近のメッセージ)を結合してコンテキストを返す。""" memories = db.query(Memory).filter(Memory.session_id == session_id).order_by(Memory.created_at).all() messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all() context: list[dict] = [] + knowledge_context = knowledge.get_knowledge_context(db) + if knowledge_context: + context.append({"role": "system", "content": knowledge_context}) + if memories: combined_summary = "\n\n".join(m.summary for m in memories) context.append({ @@ -36,9 +42,13 @@ 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, session_id=session.id) + summary_text = await llm.complete(summary_prompt, model=session.model, session_id=session.id) - db.add(Memory(session_id=session.id, summary=summary_text)) + memory = Memory(session_id=session.id, summary=summary_text) + db.add(memory) for msg in to_summarize: db.delete(msg) db.commit() + db.refresh(memory) + + await tag_memory(db, memory) diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index f20a533..a04d789 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -23,6 +23,7 @@ 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 +from app.core.word_memory import pick_random_word logger = logging.getLogger(__name__) @@ -77,7 +78,12 @@ async def _generate_message(weather: str, pop_text: str, judgement: str, reason: return fallback -async def send_useless_tip() -> None: +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 @@ -86,19 +92,31 @@ async def send_useless_tip() -> None: logger.info("Skipping useless tip during 3am quiet hour") return + topic_word = topic_override or pick_random_word() + prompt = ( - "誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識を一つ教えてください。\n" - "- 1〜3文程度で簡潔に\n" - "- 「どうでもいい」感が伝わるようなノリで\n" + "あなたは『どうでもいいTips』という名前のDiscord Botです。" + "あなた自身は、これから話す雑学が誰かの役に立つ、有益な豆知識だと心の底から信じ切っています。" + "本人にその自覚は一切なく、本気の善意と謎の使命感で「これは知っておいた方がいい」と思って共有しています。\n\n" + "実際の内容は、誰の役にも立たない、死ぬほどどうでもいい雑学・豆知識にしてください。\n" + "- 全体で60文字以内(絵文字含む)に必ず収める。1文の短いつぶやきでよい\n" + "- 「これは役に立ちますよ」「知っておくと得しますよ」というような、善意・自信たっぷりの前置きや一言を添える\n" + "- ただし中身は本当にどうでもいい内容にする(このギャップが面白さのポイント)\n" + "- 「どや顔」「これ豆な」的な、ちょっと得意げなノリにする\n" "- 絵文字を1つくらい添えてもよい\n" - "- 出力は本文のみ(前置き・説明不要)\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"🤷 **どうでもいいtips**\n{tip.strip()}" + content = f"💡 **今日のお役立ち情報**\n{tip.strip()}" await send_discord_message(DISCORD_WEBHOOK_URL_TIPS, content) except Exception: logger.exception("Failed to send useless tip") diff --git a/backend/app/core/tagging.py b/backend/app/core/tagging.py new file mode 100644 index 0000000..37cfd4d --- /dev/null +++ b/backend/app/core/tagging.py @@ -0,0 +1,204 @@ +"""ユーザーメッセージにタグを生成してDBに記録する機能。 + +単体でも呼び出せるよう、LLM呼び出しとDB保存ロジックを分離している。 +- generate_tags(): LLMでタグを3つ生成するだけ(DB非依存) +- tag_message(): 1メッセージ分のタグを生成してDBに保存 +- backfill_tags(): 未タグ付けのメッセージをまとめて処理する(過去履歴向け) +""" +import json +import logging +import random + +from sqlalchemy.orm import Session as DBSession + +from app.core import word_memory +from app.core.config import TAGGING_MODEL +from app.core.llm import complete +from app.db.database import SessionLocal +from app.db.models import Memory, MemoryTag, Message, MessageTag + +logger = logging.getLogger(__name__) + +_TAGGING_PROMPT = ( + "以下のメッセージの内容を表す短いタグを3つ生成してください。\n" + "- 各タグは1〜6文字程度の日本語の単語・短いフレーズ\n" + "- メッセージの話題・意図・感情などを要約するようなタグにする\n" + "- 出力は以下のJSON形式の配列のみ。説明文や前置きは一切不要\n" + '例: ["旅行", "質問", "予算相談"]\n\n' + "メッセージ:\n{content}" +) + + +async def generate_tags(content: str, model: str | None = None) -> list[str]: + """メッセージ本文からタグを3つ生成する。失敗時は空リストを返す。""" + if not content.strip(): + return [] + + prompt = _TAGGING_PROMPT.format(content=content) + + try: + result = await complete([{"role": "user", "content": prompt}], model=model or TAGGING_MODEL) + tags = _parse_tags(result) + return tags[:3] + except Exception: + logger.exception("Failed to generate tags for message") + return [] + + +def _parse_tags(raw: str) -> list[str]: + """LLMの出力からタグのリストを取り出す。多少のフォーマット崩れは吸収する。""" + text = raw.strip() + # ```json ... ``` のようなコードフェンスを剥がす + if text.startswith("```"): + text = text.strip("`") + if text.lower().startswith("json"): + text = text[4:] + text = text.strip() + + try: + parsed = json.loads(text) + if isinstance(parsed, list): + return [str(tag).strip() for tag in parsed if str(tag).strip()] + except (json.JSONDecodeError, ValueError): + pass + + # JSON解析に失敗した場合、カンマ・改行区切りとして雑にフォールバック + fallback = [t.strip(" -・\"'[]") for t in text.replace("\n", ",").split(",")] + return [t for t in fallback if t] + + +async def tag_message(db: DBSession, message: Message, model: str | None = None) -> list[str]: + """指定したメッセージに対してタグを生成し、DBに保存する。""" + tags = await generate_tags(message.content, model=model) + for tag in tags: + db.add(MessageTag(message_id=message.id, tag=tag)) + db.commit() + return tags + + +async def tag_memory(db: DBSession, memory: Memory, model: str | None = None) -> list[str]: + """要約(Memory)に対してタグを生成し、DBに保存する。 + + 会話履歴がコンテキストサイズの閾値を超えて要約されたタイミングで呼ばれる。 + """ + tags = await generate_tags(memory.summary, model=model) + for tag in tags: + db.add(MemoryTag(memory_id=memory.id, tag=tag)) + db.commit() + return tags + + +async def tag_message_by_id(message_id: int, model: str | None = None) -> None: + """メッセージIDだけを受け取り、専用のDBセッションでタグ付けする。 + + リクエストを処理中のDBセッションとは別に動かす必要がある場合(バックグラウンドタスクなど)に使う。 + """ + db = SessionLocal() + try: + message = db.get(Message, message_id) + if message is None: + logger.warning("tag_message_by_id: message %s not found", message_id) + return + await tag_message(db, message, model=model) + except Exception: + logger.exception("Failed to tag message %s in background", message_id) + finally: + db.close() + + +def _pick_word_for_tag(db: DBSession, tag: str) -> str | None: + """与えられたタグが付いたメッセージ/要約の本文から単語を抽出し、 + ワードプール(word_memory)と重なる『近しい単語』を1つ選ぶ。 + + 重なりが無ければ、抽出した単語からランダムに1つ選ぶ。それも無ければNone。 + """ + tagged_message_ids = { + mid for (mid,) in db.query(MessageTag.message_id).filter(MessageTag.tag == tag).all() + } + tagged_memory_ids = { + mid for (mid,) in db.query(MemoryTag.memory_id).filter(MemoryTag.tag == tag).all() + } + + texts: list[str] = [] + if tagged_message_ids: + texts += [ + content for (content,) in db.query(Message.content).filter(Message.id.in_(tagged_message_ids)).all() + ] + if tagged_memory_ids: + texts += [ + summary for (summary,) in db.query(Memory.summary).filter(Memory.id.in_(tagged_memory_ids)).all() + ] + + words: list[str] = [] + for text in texts: + words += word_memory.extract_words(text) + if not words: + return None + + return word_memory.pick_word_near(words) or random.choice(words) + + +def find_related_tag(seed_tags: list[str]) -> str | None: + """与えられたタグ群から、同じメッセージ/要約に付いていた『近しい話題』を探し、 + その話題に紐づく本文からword_memoryに近しい単語を1つ選ぶ。 + + 関連タグが見つからない場合はseed_tagsから選んだタグで同様に単語を探す。 + タグに紐づく単語も見つからなければ、タグ自体(文字列)をそのまま返す。 + 雑談Botが話を振られた際、関連知識を生成するためのトピック選びに使う。 + """ + if not seed_tags: + return None + + db = SessionLocal() + try: + message_ids = { + mid for (mid,) in db.query(MessageTag.message_id).filter(MessageTag.tag.in_(seed_tags)).all() + } + memory_ids = { + mid for (mid,) in db.query(MemoryTag.memory_id).filter(MemoryTag.tag.in_(seed_tags)).all() + } + + candidates: list[str] = [] + if message_ids: + candidates += [ + tag for (tag,) in db.query(MessageTag.tag).filter(MessageTag.message_id.in_(message_ids)).all() + ] + if memory_ids: + candidates += [ + tag for (tag,) in db.query(MemoryTag.tag).filter(MemoryTag.memory_id.in_(memory_ids)).all() + ] + + related = [tag for tag in candidates if tag not in seed_tags] + chosen_tag = random.choice(related) if related else random.choice(seed_tags) + + return _pick_word_for_tag(db, chosen_tag) or chosen_tag + except Exception: + logger.exception("Failed to find related tag") + return random.choice(seed_tags) + finally: + db.close() + + +async def backfill_tags(db: DBSession, model: str | None = None, limit: int | None = None) -> int: + """まだタグが付いていないユーザー/アシスタントメッセージにまとめてタグを付ける。 + + 単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。 + 戻り値: タグ付けしたメッセージ数 + """ + query = ( + db.query(Message) + .outerjoin(MessageTag, MessageTag.message_id == Message.id) + .filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None)) + .order_by(Message.created_at.asc()) + ) + if limit: + query = query.limit(limit) + + messages = query.all() + count = 0 + for message in messages: + await tag_message(db, message, model=model) + count += 1 + + logger.info("Backfilled tags for %d message(s)", count) + return count diff --git a/backend/app/core/websearch.py b/backend/app/core/websearch.py new file mode 100644 index 0000000..8ef417b --- /dev/null +++ b/backend/app/core/websearch.py @@ -0,0 +1,56 @@ +import logging + +import httpx + +from app.core.config import TAVILY_API_KEY + +logger = logging.getLogger(__name__) + +TAVILY_SEARCH_URL = "https://api.tavily.com/search" + +WEB_SEARCH_TOOL = { + "type": "function", + "function": { + "name": "web_search", + "description": "最新情報・事実確認・知らない固有名詞など、自分の知識だけでは答えられない可能性があるときにWebを検索する。", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "検索したい内容を表す検索クエリ"}, + }, + "required": ["query"], + }, + }, +} + + +async def search_web(query: str, max_results: int = 5) -> list[dict]: + """Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。""" + if not TAVILY_API_KEY: + logger.warning("TAVILY_API_KEY is not set; skipping web search") + return [] + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + TAVILY_SEARCH_URL, + json={ + "api_key": TAVILY_API_KEY, + "query": query, + "max_results": max_results, + }, + ) + response.raise_for_status() + data = response.json() + except Exception: + logger.exception("Tavily search failed for query=%r", query) + return [] + + results = [] + for item in data.get("results", []): + results.append({ + "title": item.get("title") or "", + "url": item.get("url") or "", + "content": item.get("content") or "", + }) + return results diff --git a/backend/app/core/word_memory.py b/backend/app/core/word_memory.py new file mode 100644 index 0000000..32a28e5 --- /dev/null +++ b/backend/app/core/word_memory.py @@ -0,0 +1,113 @@ +"""雑談Botに送られたメッセージから単語を記録し、あとでランダムに参照できるようにする。 + +形態素解析(Janome)で名詞・固有名詞・カタカナ語などの「ネタになりそうな単語」だけを抽出する。 +""" +import logging +import random +import re +from collections import deque + +from janome.tokenizer import Tokenizer + +logger = logging.getLogger(__name__) + +# コードブロック・インラインコードはまるごと除去(コード片を単語として拾わないため) +_CODE_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL) +_INLINE_CODE_RE = re.compile(r"`[^`]*`") +# 見出し・引用・リスト記号の行頭マーカー +_LINE_MARKER_RE = re.compile(r"^[ \t]*(#{1,6}|[-*+]|\d+\.|>)[ \t]+", re.MULTILINE) +# 強調・打ち消し線などのマーカー(中身のテキストは残す) +_EMPHASIS_RE = re.compile(r"(\*\*\*|\*\*|\*|___|__|_|~~)") +# [テキスト](URL) → テキストのみ残す +_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)") +# 残った記法記号(テーブル区切り・水平線など) +_LEFTOVER_SYMBOLS_RE = re.compile(r"[`#>|]|^[ \t]*[-=]{3,}[ \t]*$", re.MULTILINE) + + +def _strip_markdown(content: str) -> str: + """Markdown記法の記号を取り除き、プレーンテキストに近い形にする。""" + text = _CODE_BLOCK_RE.sub(" ", content) + text = _INLINE_CODE_RE.sub(" ", text) + text = _LINE_MARKER_RE.sub("", text) + text = _LINK_RE.sub(r"\1", text) + text = _EMPHASIS_RE.sub("", text) + text = _LEFTOVER_SYMBOLS_RE.sub(" ", text) + return text + +_MAX_WORDS = 200 +_recent_words: deque[str] = deque(maxlen=_MAX_WORDS) + +_tokenizer = Tokenizer() + +# 一般的すぎて雑学のネタとして面白くない名詞を除外する +_STOPWORDS = { + "こと", "もの", "ところ", "とき", "ため", "よう", "これ", "それ", "あれ", "どこ", + "ここ", "そこ", "あなた", "わたし", "私", "僕", "自分", "今日", "明日", "昨日", + "何", "誰", "皆", "みんな", "一つ", "感じ", +} + +# 名詞のうち、ネタとして拾いたいサブカテゴリ(数詞・代名詞・非自立語などは除く) +_TARGET_NOUN_SUBTYPES = {"一般", "固有名詞", "サ変接続"} + + +def extract_words(content: str) -> list[str]: + """テキストを形態素解析し、ネタになりそうな名詞のリストを返す(ワードプールへの記録はしない)。""" + words: list[str] = [] + try: + plain_text = _strip_markdown(content) + for token in _tokenizer.tokenize(plain_text): + features = token.part_of_speech.split(",") + pos, subtype = features[0], features[1] if len(features) > 1 else "" + + if pos != "名詞" or subtype not in _TARGET_NOUN_SUBTYPES: + continue + + word = token.base_form + if len(word) < 2 or word in _STOPWORDS: + continue + + words.append(word) + except Exception: + logger.exception("Failed to tokenize text for word extraction: %r", content) + return words + + +def record_message(content: str) -> None: + """メッセージ本文を形態素解析し、名詞をワードプールに記録する。""" + _recent_words.extend(extract_words(content)) + + +def pick_random_word() -> str | None: + if not _recent_words: + return None + return random.choice(list(_recent_words)) + + +def pick_word_near(words: list[str]) -> str | None: + """指定した単語群(タグに紐づくメッセージ等から抽出した語)と、現在のワードプールの両方に + 含まれる単語からランダムに1つ選ぶ。共通する単語が無ければNoneを返す。 + """ + if not words: + return None + pool = set(_recent_words) + candidates = [w for w in words if w in pool] + if not candidates: + return None + return random.choice(candidates) + + +def seed_from_history(messages: list[str]) -> None: + """起動時にDBの既存メッセージから単語を読み込んでおく。""" + for content in messages: + record_message(content) + + +def get_word_pool_with_counts() -> list[dict]: + """現在のワードプールを出現回数の多い順で返す。""" + counts: dict[str, int] = {} + for word in _recent_words: + counts[word] = counts.get(word, 0) + 1 + return [ + {"word": word, "count": count} + for word, count in sorted(counts.items(), key=lambda kv: kv[1], reverse=True) + ] diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 0fa4c0b..e20adcb 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -27,6 +27,18 @@ class Message(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) session: Mapped["Session"] = relationship(back_populates="messages") + tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan") + + +class MessageTag(Base): + __tablename__ = "message_tags" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE")) + tag: Mapped[str] = mapped_column(String) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + message: Mapped["Message"] = relationship(back_populates="tags") class Memory(Base): @@ -39,6 +51,41 @@ class Memory(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) session: Mapped["Session"] = relationship(back_populates="memories") + tags: Mapped[list["MemoryTag"]] = relationship(back_populates="memory", cascade="all, delete-orphan") + + +class MemoryTag(Base): + __tablename__ = "memory_tags" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + memory_id: Mapped[int] = mapped_column(ForeignKey("memories.id", ondelete="CASCADE")) + tag: Mapped[str] = mapped_column(String) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + memory: Mapped["Memory"] = relationship(back_populates="tags") + + +class KnowledgeEntry(Base): + """Web検索で得た知識。セッションを横断して蓄積し、以後の会話のシステムプロンプトに注入する。""" + __tablename__ = "knowledge_entries" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + query: Mapped[str] = mapped_column(String) + summary: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + + sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan") + + +class KnowledgeSource(Base): + __tablename__ = "knowledge_sources" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + entry_id: Mapped[int] = mapped_column(ForeignKey("knowledge_entries.id", ondelete="CASCADE")) + title: Mapped[str] = mapped_column(String) + url: Mapped[str] = mapped_column(String) + + entry: Mapped["KnowledgeEntry"] = relationship(back_populates="sources") class ApiLog(Base): diff --git a/backend/app/main.py b/backend/app/main.py index 46fc79a..d77bbb7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,13 +7,31 @@ 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.core.word_memory import seed_from_history +from app.db.database import SessionLocal, init_db +from app.db.models import Message from app.api import chat, memory, logs, system +def _seed_word_memory_from_db() -> None: + db = SessionLocal() + try: + rows = ( + db.query(Message.content) + .filter(Message.role.in_(["user", "assistant"])) + .order_by(Message.created_at.desc()) + .limit(200) + .all() + ) + seed_from_history([content for (content,) in rows]) + finally: + db.close() + + @asynccontextmanager async def lifespan(app: FastAPI): init_db() + _seed_word_memory_from_db() start_scheduler() asyncio.create_task(start_discord_bot()) yield diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d443f53..a05082d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "apscheduler>=3.11.2", "httpx>=0.28.1", "discord-py>=2.7.1", + "janome>=0.5.0", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock index 825d393..2e4ffd5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -727,6 +727,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "janome" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/4e/dc2b1a89a4ffafbf9bf49c8e11f69e28ba8b3ef81d11afe6e9f96caee6cc/Janome-0.5.0.tar.gz", hash = "sha256:ce4a3ed7a4635c2f80139639327d5b1e0381858ad74a3c4a61e8cc83f820400e", size = 18829020, upload-time = "2023-07-01T10:53:09.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/7d/70f4069f4bbf0fca023e82a1fbbade6f5216365d4fe259fee1950723eca5/Janome-0.5.0-py2.py3-none-any.whl", hash = "sha256:d098670394a77881ce2f6b7d696c0ea5ff74c0c8cf74a8a882159ec82c0e6dc7", size = 19654103, upload-time = "2023-07-01T10:52:58.572Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1114,6 +1123,7 @@ dependencies = [ { name = "fastapi" }, { name = "httpx" }, { name = "huggingface-hub" }, + { name = "janome" }, { name = "litellm" }, { name = "nvidia-ml-py" }, { name = "psutil" }, @@ -1140,6 +1150,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "huggingface-hub", specifier = ">=1.17.0" }, + { name = "janome", specifier = ">=0.5.0" }, { name = "litellm", specifier = ">=1.40.0" }, { name = "nvidia-ml-py", specifier = ">=13.610.43" }, { name = "psutil", specifier = ">=7.2.2" }, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7c3a5d8..1b7a6da 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,6 +8,7 @@ import LogViewer from './components/LogViewer.vue' import ToastList from './components/ToastList.vue' import MemoryMonitor from './components/MemoryMonitor.vue' import Clock from './components/Clock.vue' +import WordPool from './components/WordPool.vue' const showLogs = ref(false) @@ -17,6 +18,7 @@ const showLogs = ref(false)