Add web search knowledge base, message tagging, and word-memory tip topics

Introduces websearch.py (Tavily) + knowledge.py to ground chat responses
in search results, tagging.py to tag user and assistant messages via LLM
and pick related topics for the Discord useless-tips job, and
word_memory.py to track recent conversation nouns (surfaced in the new
WordPool.vue sidebar) used to pick tip topics close to a related tag.
This commit is contained in:
2026-06-20 18:06:06 +09:00
parent 4e837d1419
commit 234ebba103
23 changed files with 946 additions and 39 deletions
+5
View File
@@ -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
+45 -3
View File
@@ -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)
+18 -1
View File
@@ -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}
+8
View File
@@ -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 を設定する。
+11 -1
View File
@@ -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")
+37
View File
@@ -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)
+70
View File
@@ -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
+13 -3
View File
@@ -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)
+24 -6
View File
@@ -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")
+204
View File
@@ -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
+56
View File
@@ -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
+113
View File
@@ -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)
]
+47
View File
@@ -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):
+19 -1
View File
@@ -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
+1
View File
@@ -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]
+11
View File
@@ -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" },
+3 -1
View File
@@ -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)
</script>
@@ -17,6 +18,7 @@ const showLogs = ref(false)
<div class="sidebar">
<SessionList />
<MemoryMonitor />
<WordPool />
</div>
<div class="main">
<header>
@@ -49,7 +51,7 @@ body { background: #111; color: #eee; font-family: system-ui, sans-serif; }
<style scoped>
.app { display: flex; height: 100vh; }
.sidebar { display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
.sidebar { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid #333; overflow: hidden; }
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
header {
display: flex; align-items: center; justify-content: space-between;
+16 -2
View File
@@ -16,9 +16,14 @@ export interface SessionInfo {
updated_at: string
}
export interface Source {
title: string
url: string
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error'
value?: string
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources'
value?: string | Source[]
}
const BASE = ''
@@ -45,6 +50,7 @@ export interface MessageRecord {
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
tags: string[]
}
export async function fetchMessages(sessionId: string): Promise<MessageRecord[]> {
@@ -95,6 +101,7 @@ export function streamChat(
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let finished = false
while (true) {
const { done, value } = await reader.read()
@@ -106,6 +113,7 @@ export function streamChat(
if (line.startsWith('data: ')) {
try {
const parsed: SSEChunk = JSON.parse(line.slice(6))
if (parsed.type === 'done' || parsed.type === 'error') finished = true
onChunk(parsed)
} catch (e) {
console.error('[SSE] JSON parse error:', e, '| raw:', line)
@@ -113,6 +121,12 @@ export function streamChat(
}
}
}
// サーバーが done/error を送らずに接続を終了した場合(再起動・切断など)はストリーミング状態が
// 固定されたままになるので、ここで明示的にエラーとして扱い入力をロック解除する
if (!finished) {
onError(new Error('接続が予期せず終了しました。もう一度お試しください。'))
}
})
.catch((err) => {
if (err.name !== 'AbortError') onError(err)
+90 -2
View File
@@ -2,10 +2,20 @@
import { ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue'
import type { Source } from '../api/client'
const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null)
function uniqueSources(sources: Source[]): Source[] {
const seen = new Set<string>()
return sources.filter((s) => {
if (seen.has(s.url)) return false
seen.add(s.url)
return true
})
}
watch(
() => store.messages.length,
async () => {
@@ -23,8 +33,9 @@ watch(
<div
v-for="(msg, i) in store.messages"
:key="i"
:class="['message', msg.role, { error: msg.error }]"
:class="['message-wrap', msg.role]"
>
<div :class="['message', msg.role, { error: msg.error }]">
<div class="bubble">
<!-- ユーザーメッセージはプレーンテキスト -->
<template v-if="msg.role === 'user'">{{ msg.content }}</template>
@@ -33,9 +44,27 @@ watch(
<template v-else>
<MarkdownContent :content="msg.content" :pending="msg.pending" />
<span v-if="msg.pending && !msg.content" class="cursor">▌</span>
<button v-if="msg.error" class="retry-btn" :disabled="store.isStreaming" @click="store.retryMessage(i)">
🔄 再送信
</button>
</template>
</div>
</div>
<div v-if="msg.tags && msg.tags.length > 0" class="tags">
<span v-for="tag in msg.tags" :key="tag" class="tag">#{{ tag }}</span>
</div>
<div v-if="msg.sources && msg.sources.length > 0" class="sources">
<span class="sources-label">🔍 参照元:</span>
<a
v-for="src in uniqueSources(msg.sources)"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener noreferrer"
class="source-link"
>{{ src.title || src.url }}</a>
</div>
</div>
</div>
</template>
@@ -53,9 +82,50 @@ watch(
color: #555;
font-size: 14px;
}
.message { display: flex; }
.message-wrap { display: flex; flex-direction: column; gap: 4px; }
.message-wrap.user { align-items: flex-end; }
.message-wrap.assistant { align-items: flex-start; }
.message { display: flex; width: 100%; }
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-width: 72%;
}
.tag {
font-size: 11px;
color: #93c5fd;
background: #1d3557;
border-radius: 10px;
padding: 2px 8px;
}
.sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
max-width: 72%;
font-size: 11px;
}
.sources-label {
color: #888;
}
.source-link {
color: #6ee7b7;
background: #103027;
border-radius: 10px;
padding: 2px 8px;
text-decoration: none;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-link:hover {
text-decoration: underline;
}
.bubble {
max-width: 72%;
padding: 10px 14px;
@@ -84,5 +154,23 @@ watch(
display: inline-block;
animation: blink 0.8s step-end infinite;
}
.retry-btn {
display: block;
margin-top: 8px;
background: #2a1414;
color: #f87171;
border: 1px solid #7f1d1d;
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.retry-btn:hover:not(:disabled) {
background: #3b1a1a;
}
.retry-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes blink { 50% { opacity: 0; } }
</style>
@@ -74,7 +74,6 @@ onUnmounted(() => {
.memory-monitor {
background: #1a1a1a;
border-top: 1px solid #333;
border-bottom: 1px solid #333;
padding: 12px;
font-size: 11px;
font-family: monospace;
+3 -3
View File
@@ -44,13 +44,13 @@ onMounted(load)
<style scoped>
.session-list {
width: 220px;
min-width: 220px;
width: 100%;
background: #1a1a1a;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0;
}
.header {
display: flex;
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
interface WordEntry {
word: string
count: number
}
const words = ref<WordEntry[]>([])
const expanded = ref(false)
let intervalId: number | null = null
const maxCount = computed(() => Math.max(1, ...words.value.map((w) => w.count)))
const fontSize = (count: number) => {
const ratio = count / maxCount.value
return `${12 + ratio * 14}px`
}
const opacity = (count: number) => {
const ratio = count / maxCount.value
return 0.5 + ratio * 0.5
}
const fetchWordPool = async () => {
try {
const response = await fetch('/api/system/word-pool')
words.value = await response.json()
} catch (error) {
console.error('Failed to fetch word pool:', error)
}
}
onMounted(() => {
fetchWordPool()
intervalId = window.setInterval(fetchWordPool, 30000)
})
onUnmounted(() => {
if (intervalId) clearInterval(intervalId)
})
</script>
<template>
<div class="word-pool">
<button class="header" @click="expanded = !expanded">
<span>🗣️ ワードプール(どうでもいいTips用)</span>
<span class="chevron">{{ expanded ? '▲' : '▼' }}</span>
</button>
<template v-if="expanded">
<div v-if="words.length === 0" class="empty">まだ単語が記録されていません</div>
<div v-else class="cloud">
<span
v-for="entry in words"
:key="entry.word"
class="word"
:style="{ fontSize: fontSize(entry.count), opacity: opacity(entry.count) }"
:title="`${entry.count}回`"
>
{{ entry.word }}
</span>
</div>
</template>
</div>
</template>
<style scoped>
.word-pool {
background: #1a1a1a;
border-top: 1px solid #333;
padding: 12px;
font-size: 11px;
flex-shrink: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
box-sizing: border-box;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
color: #888;
font-family: monospace;
background: none;
border: none;
padding: 4px 0;
cursor: pointer;
font-size: 11px;
}
.chevron {
color: #666;
font-size: 9px;
}
.empty {
color: #666;
font-style: italic;
margin-top: 8px;
}
.cloud {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
margin-top: 8px;
max-width: 100%;
}
.word {
color: #93c5fd;
font-family: monospace;
white-space: nowrap;
cursor: default;
overflow-wrap: anywhere;
}
</style>
+27 -7
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo } from '../api/client'
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo, type Source } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
content: string
pending?: boolean
error?: boolean
tags?: string[]
sources?: Source[]
}
export interface Toast {
@@ -21,7 +23,7 @@ export const useChatStore = defineStore('chat', () => {
const currentSessionId = ref<string | null>(null)
const messages = ref<Message[]>([])
const isStreaming = ref(false)
const selectedModel = ref('gpt-5.4')
const selectedModel = ref('bedrock/jp.anthropic.claude-sonnet-4-6')
const toasts = ref<Toast[]>([])
const currentSession = computed(() =>
@@ -52,7 +54,7 @@ export const useChatStore = defineStore('chat', () => {
const records = await fetchMessages(sessionId)
messages.value = records
.filter((r) => r.role === 'user' || r.role === 'assistant')
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content }))
.map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags }))
} catch (e) {
pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`)
}
@@ -81,23 +83,27 @@ export const useChatStore = defineStore('chat', () => {
selectedModel.value,
(chunk) => {
if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value
currentSessionId.value = chunk.value as string
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'chunk' && chunk.value) {
messages.value[msgIdx].content += chunk.value
messages.value[msgIdx].content += chunk.value as string
} else if (chunk.type === 'sources' && chunk.value) {
messages.value[msgIdx].sources = chunk.value as Source[]
} else if (chunk.type === 'done') {
messages.value[msgIdx].pending = false
isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') {
messages.value[msgIdx].content = chunk.value ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
}
},
(err) => {
messages.value[msgIdx].content = err.message
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
const partial = messages.value[msgIdx].content
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
@@ -105,6 +111,19 @@ export const useChatStore = defineStore('chat', () => {
)
}
/** エラーになったアシスタント応答のひとつ前にあるユーザーメッセージを再送信する。 */
function retryMessage(assistantIdx: number): void {
if (isStreaming.value) return
const userIdx = assistantIdx - 1
const userMsg = messages.value[userIdx]
if (!userMsg || userMsg.role !== 'user') return
const text = userMsg.content
// 失敗したユーザーメッセージとエラー応答を取り除いてから送信し直す
messages.value.splice(userIdx, 2)
sendMessage(text)
}
return {
sessions,
currentSessionId,
@@ -119,5 +138,6 @@ export const useChatStore = defineStore('chat', () => {
selectSession,
removeSession,
sendMessage,
retryMessage,
}
})