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
+7 -1
View File
@@ -5,7 +5,13 @@
"Bash(ls \"C:\\\\Users\\\\Yuya Suzuki\\\\AppData\\\\Roaming\\\\Claude\\\\\")", "Bash(ls \"C:\\\\Users\\\\Yuya Suzuki\\\\AppData\\\\Roaming\\\\Claude\\\\\")",
"WebSearch", "WebSearch",
"Bash(uv add *)", "Bash(uv add *)",
"Bash(git check-ignore *)" "Bash(git check-ignore *)",
"Bash(uv run python -c ' *)",
"Bash(PYTHONIOENCODING=utf-8 uv run python -c ' *)",
"Bash(curl -s http://localhost:8010/docs -o /dev/null -w \"status:%{http_code}\\\\n\")",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"PowerShell(Get-Process | Where-Object { $_.ProcessName -match \"python|uvicorn\" } | Select-Object Id, ProcessName, Path)"
] ]
} }
} }
+9
View File
@@ -86,6 +86,15 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
ローカル Ollama は `ollama/llama3` 形式、`OLLAMA_API_BASE` でエンドポイントを設定。 ローカル Ollama は `ollama/llama3` 形式、`OLLAMA_API_BASE` でエンドポイントを設定。
`DEBUG=true` で LiteLLM の詳細ログが有効になる。 `DEBUG=true` で LiteLLM の詳細ログが有効になる。
## ローカル開発環境メモ
- このマシンでは PostgreSQL がポート 8080 で起動している(本アプリの `DATABASE_URL` には使用していないが、ポート競合時は確認すること)
- このマシンには TLS 監視プロキシらしき設定があり、環境変数 `SSLKEYLOGFILE` が名前付きパイプ(`nllMonFltProxy`)を指している。これが原因で `litellm`(OpenSSL を直接ロード)や `pyright`(内部で Node.js サブプロセスを起動)が `OPENSSL_Uplink` エラーでクラッシュすることがある。発生した場合は当該コマンド実行時のみ `SSLKEYLOGFILE` を unset すると回避できる
```powershell
Remove-Item Env:\SSLKEYLOGFILE
uv run pyright
```
## 環境変数(backend/.env) ## 環境変数(backend/.env)
| 変数 | デフォルト | 説明 | | 変数 | デフォルト | 説明 |
+7
View File
@@ -21,6 +21,13 @@ TAVILY_API_KEY=
# 蓄積知識をシステムプロンプトに注入する際の最大件数 # 蓄積知識をシステムプロンプトに注入する際の最大件数
KNOWLEDGE_CONTEXT_LIMIT=15 KNOWLEDGE_CONTEXT_LIMIT=15
# ローカルMarkdownナレッジベース(backend/knowledge_base/ 配下のファイルを話題に応じて注入)
LOCAL_KB_DIR=knowledge_base
# Ollamaを使う場合は ollama/nomic-embed-text のように指定(OLLAMA_API_BASEも参照される)
LOCAL_KB_EMBEDDING_MODEL=text-embedding-3-small
LOCAL_KB_TOP_K=3
LOCAL_KB_MIN_SCORE=0.75
# 天気予報 → Discord 通知 # 天気予報 → Discord 通知
DISCORD_WEBHOOK_URL= DISCORD_WEBHOOK_URL=
WEATHER_AREA_CODE=120000 WEATHER_AREA_CODE=120000
+6 -6
View File
@@ -8,13 +8,13 @@ from sqlalchemy.orm import Session as DBSession
from app.db.database import get_db from app.db.database import get_db
from app.db.models import Session, Message from app.db.models import Session, Message
from app.core import memory as mem from app.core.memory_kb import memory as mem
from app.core import llm from app.core import llm
from app.core import knowledge from app.core.memory_kb import knowledge
from app.core.websearch import WEB_SEARCH_TOOL from app.core.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL from app.core.config import DEFAULT_MODEL
from app.core.tagging import tag_message_by_id from app.core.memory_kb.tagging import tag_message_by_id
from app.core.word_memory import record_message from app.core.memory_kb.word_memory import record_message
router = APIRouter(prefix="/chat", tags=["chat"]) router = APIRouter(prefix="/chat", tags=["chat"])
@@ -102,7 +102,7 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
record_message(req.message) record_message(req.message)
asyncio.create_task(tag_message_by_id(user_msg.id)) asyncio.create_task(tag_message_by_id(user_msg.id))
context = mem.get_context_messages(db, session.id) context = await mem.get_context_messages(db, session.id)
async def generate(): async def generate():
import json as _json import json as _json
+4 -4
View File
@@ -3,10 +3,10 @@ import psutil
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.core.scheduler import send_useless_tip, send_weather_laundry_notification from app.core.discord.scheduler import send_useless_tip, send_weather_laundry_notification
from app.core.tagging import backfill_tags from app.core.memory_kb.tagging import backfill_tags
from app.core.weather_cache import get_latest as get_latest_weather from app.core.weather.weather_cache import get_latest as get_latest_weather
from app.core.word_memory import get_word_pool_with_counts from app.core.memory_kb.word_memory import get_word_pool_with_counts
from app.db.database import get_db from app.db.database import get_db
router = APIRouter(prefix="/api/system", tags=["system"]) router = APIRouter(prefix="/api/system", tags=["system"])
+6
View File
@@ -17,6 +17,12 @@ TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
# 蓄積した知識をシステムプロンプトに注入する際の最大件数 # 蓄積した知識をシステムプロンプトに注入する際の最大件数
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15")) KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
LOCAL_KB_DIR: str = os.getenv("LOCAL_KB_DIR", "knowledge_base")
LOCAL_KB_EMBEDDING_MODEL: str = os.getenv("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
LOCAL_KB_TOP_K: int = int(os.getenv("LOCAL_KB_TOP_K", "3"))
LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
# Amazon Bedrock # Amazon Bedrock
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。 # 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。 # IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
@@ -11,11 +11,11 @@ from app.core.config import (
WEATHER_NOTIFY_MODEL, WEATHER_NOTIFY_MODEL,
) )
from app.core.llm import complete from app.core.llm import complete
from app.core.scheduler import send_useless_tip from app.core.discord.scheduler import send_useless_tip
from app.core.tagging import find_related_tag, generate_tags from app.core.memory_kb.tagging import find_related_tag, generate_tags
from app.core.weather import fetch_tomorrow_weather, judge_laundry from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
from app.core.weather_cache import set_latest from app.core.weather.weather_cache import set_latest
from app.core.word_memory import record_message from app.core.memory_kb.word_memory import record_message
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,10 +20,10 @@ from app.core.config import (
WEATHER_NOTIFY_MODEL, WEATHER_NOTIFY_MODEL,
) )
from app.core.llm import complete from app.core.llm import complete
from app.core.notify import send_discord_message from app.core.discord.notify import send_discord_message
from app.core.weather import fetch_tomorrow_weather, judge_laundry from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
from app.core.weather_cache import set_latest from app.core.weather.weather_cache import set_latest
from app.core.word_memory import pick_random_word from app.core.memory_kb.word_memory import pick_random_word
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+7 -1
View File
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
import litellm import litellm
from app.core.config import DEFAULT_MODEL, DEBUG from app.core.config import DEFAULT_MODEL, DEBUG
from app.core.websearch import WEB_SEARCH_TOOL, search_web from app.core.search.websearch import WEB_SEARCH_TOOL, search_web
litellm.drop_params = True litellm.drop_params = True
if DEBUG: if DEBUG:
@@ -97,6 +97,12 @@ async def complete(
return result return result
async def embed(texts: list[str], model: str) -> list[list[float]]:
"""テキスト群を埋め込みベクトルに変換する。"""
response = await litellm.aembedding(model=model, input=texts)
return [item["embedding"] for item in response.data]
async def resolve_web_search( async def resolve_web_search(
messages: list[dict], messages: list[dict],
model: str | None = None, model: str | None = None,
@@ -34,4 +34,7 @@ def get_knowledge_context(db: DBSession) -> str | None:
urls = ", ".join(s.url for s in entry.sources) or "出典不明" urls = ", ".join(s.url for s in entry.sources) or "出典不明"
blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})") blocks.append(f"- 「{entry.query}」について: {entry.summary}\n (出典: {urls})")
return "以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください:\n\n" + "\n".join(blocks) return (
"以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください。"
"ただしローカルナレッジベースの資料が別途提示されている場合は、そちらを優先してください:\n\n" + "\n".join(blocks)
)
@@ -0,0 +1,131 @@
"""backend/knowledge_base/ 配下のMarkdownファイルから、話題に応じて関連知識をシステムプロンプトに注入する機能。
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
"""
import hashlib
import json
import logging
import math
from pathlib import Path
from sqlalchemy.orm import Session as DBSession
from app.core import llm
from app.core.config import LOCAL_KB_DIR, LOCAL_KB_EMBEDDING_MODEL, LOCAL_KB_MIN_SCORE, LOCAL_KB_TOP_K
from app.db.models import LocalKnowledgeDoc
logger = logging.getLogger(__name__)
def _kb_dir() -> Path:
return Path(LOCAL_KB_DIR)
def _hash(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _title_from_content(content: str, fallback: str) -> str:
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
return stripped.lstrip("#").strip() or fallback
return fallback
def _cosine_similarity(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
async def sync_local_kb(db: DBSession) -> None:
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
削除されたファイルに対応するレコードも削除する。
"""
kb_dir = _kb_dir()
if not kb_dir.is_dir():
return
md_files = sorted(kb_dir.rglob("*.md"))
seen_paths: set[str] = set()
for file_path in md_files:
rel_path = str(file_path.relative_to(kb_dir))
seen_paths.add(rel_path)
content = file_path.read_text(encoding="utf-8")
content_hash = _hash(content)
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
if existing and existing.content_hash == content_hash:
continue
try:
[embedding] = await llm.embed([content], model=LOCAL_KB_EMBEDDING_MODEL)
except Exception:
logger.exception("Failed to embed local knowledge file: %s", rel_path)
continue
title = _title_from_content(content, fallback=file_path.stem)
if existing:
existing.content_hash = content_hash
existing.title = title
existing.embedding_json = json.dumps(embedding)
else:
db.add(LocalKnowledgeDoc(
path=rel_path,
title=title,
content_hash=content_hash,
embedding_json=json.dumps(embedding),
))
db.commit()
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
for doc in stale:
db.delete(doc)
if stale:
db.commit()
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
if not query_text.strip():
return None
await sync_local_kb(db)
docs = db.query(LocalKnowledgeDoc).all()
if not docs:
return None
try:
[query_embedding] = await llm.embed([query_text], model=LOCAL_KB_EMBEDDING_MODEL)
except Exception:
logger.exception("Failed to embed query for local knowledge search")
return None
scored = [
(doc, _cosine_similarity(query_embedding, json.loads(doc.embedding_json)))
for doc in docs
]
scored.sort(key=lambda pair: pair[1], reverse=True)
top = [(doc, score) for doc, score in scored[:LOCAL_KB_TOP_K] if score >= LOCAL_KB_MIN_SCORE]
if not top:
return None
kb_dir = _kb_dir()
blocks = []
for doc, _ in top:
content = (kb_dir / doc.path).read_text(encoding="utf-8")
blocks.append(f"### {doc.title}\n{content}")
return (
"以下はローカルのナレッジベースから話題に関連すると判断された資料です。"
"この資料はWeb検索結果より優先度の高い、信頼できる情報源として扱ってください。"
"ここに必要な情報が含まれている場合はWeb検索を行わず、この内容のみで回答してください。"
"情報が不足する場合に限り、補足としてWeb検索を使ってください:\n\n" + "\n\n".join(blocks)
)
@@ -2,11 +2,12 @@ from sqlalchemy.orm import Session as DBSession
from app.db.models import Memory, Message, Session from app.db.models import Memory, Message, Session
from app.core.config import MEMORY_SUMMARIZE_THRESHOLD from app.core.config import MEMORY_SUMMARIZE_THRESHOLD
from app.core import llm from app.core import llm
from app.core import knowledge from app.core.memory_kb import knowledge
from app.core.tagging import tag_memory from app.core.memory_kb import local_knowledge
from app.core.memory_kb.tagging import tag_memory
def get_context_messages(db: DBSession, session_id: str) -> list[dict]: async 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() 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() messages = db.query(Message).filter(Message.session_id == session_id).order_by(Message.created_at).all()
@@ -17,6 +18,12 @@ def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
if knowledge_context: if knowledge_context:
context.append({"role": "system", "content": knowledge_context}) context.append({"role": "system", "content": knowledge_context})
if messages:
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
if local_kb_context:
context.append({"role": "system", "content": local_kb_context})
if memories: if memories:
combined_summary = "\n\n".join(m.summary for m in memories) combined_summary = "\n\n".join(m.summary for m in memories)
context.append({ context.append({
@@ -38,9 +45,10 @@ async def maybe_summarize(db: DBSession, session: Session) -> None:
half = MEMORY_SUMMARIZE_THRESHOLD // 2 half = MEMORY_SUMMARIZE_THRESHOLD // 2
to_summarize = messages[:half] to_summarize = messages[:half]
conversation_text = "\n".join(f"{m.role}: {m.content}" for m in to_summarize)
summary_prompt = [ summary_prompt = [
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"}, {"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
*[{"role": m.role, "content": m.content} for m in to_summarize], {"role": "user", "content": conversation_text},
] ]
summary_text = await llm.complete(summary_prompt, model=session.model, session_id=session.id) summary_text = await llm.complete(summary_prompt, model=session.model, session_id=session.id)
@@ -11,7 +11,7 @@ import random
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from app.core import word_memory from app.core.memory_kb import word_memory
from app.core.config import TAGGING_MODEL from app.core.config import TAGGING_MODEL
from app.core.llm import complete from app.core.llm import complete
from app.db.database import SessionLocal from app.db.database import SessionLocal
View File
@@ -12,7 +12,8 @@ WEB_SEARCH_TOOL = {
"type": "function", "type": "function",
"function": { "function": {
"name": "web_search", "name": "web_search",
"description": "最新情報・事実確認・知らない固有名詞など、自分の知識だけでは答えられない可能性があるときにWebを検索する。", "description": "最新情報・事実確認・知らない固有名詞など、自分の知識だけでは答えられない可能性があるときにWebを検索する。"
"ただし、システムプロンプトに提示されたローカルナレッジベースの資料で回答できる場合は使わないこと。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
+16
View File
@@ -88,6 +88,22 @@ class KnowledgeSource(Base):
entry: Mapped["KnowledgeEntry"] = relationship(back_populates="sources") entry: Mapped["KnowledgeEntry"] = relationship(back_populates="sources")
class LocalKnowledgeDoc(Base):
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。
"""
__tablename__ = "local_knowledge_docs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
path: Mapped[str] = mapped_column(String, unique=True)
title: Mapped[str] = mapped_column(String)
content_hash: Mapped[str] = mapped_column(String)
embedding_json: Mapped[str] = mapped_column(Text)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class ApiLog(Base): class ApiLog(Base):
__tablename__ = "api_logs" __tablename__ = "api_logs"
+3 -3
View File
@@ -5,9 +5,9 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.config import CORS_ORIGINS from app.core.config import CORS_ORIGINS
from app.core.discord_bot import start_discord_bot from app.core.discord.discord_bot import start_discord_bot
from app.core.scheduler import start_scheduler from app.core.discord.scheduler import start_scheduler
from app.core.word_memory import seed_from_history from app.core.memory_kb.word_memory import seed_from_history
from app.db.database import SessionLocal, init_db from app.db.database import SessionLocal, init_db
from app.db.models import Message from app.db.models import Message
from app.api import chat, memory, logs, system from app.api import chat, memory, logs, system
+403
View File
@@ -0,0 +1,403 @@
# Fate / Grand Order
## 登場サーヴァント
### クー・フーリン〔キャスター〕
クラス: キャスター
性別: 男性
属性: 秩序・中庸・天
初登場: メインストーリー 特異点F (炎上汚染都市 冬木)
宝具: 灼き尽くす炎の檻 (ウィッカーマン)
ケルト神話の大英雄クー・フーリンが、ルーン魔術を扱うキャスターとして現界した姿。槍兵時よりも落ち着いた兄貴分だが、根っこの豪胆さは変わらない。
### 清姫
クラス: バーサーカー
性別: 女性
属性: 混沌・悪・地
初登場: メインストーリー 第一部1章 (邪竜百年戦争 オルレアン)
宝具: 転身火生三昧 (てんしんかしょうざんまい)
愛称は「きよひー」。マスターの事を「安珍」だと思い込んでいる、恋と執着の炎がすさまじい少女。
### ブーディカ
クラス: ライダー
性別: 女性
属性: 中立・善・人
初登場: メインストーリー 第一部2章 (永続狂気帝国 セプテム)
宝具: 約束されざる守護の車輪 (チャリオット・オブ・ブーディカ)
慈愛と母性にあふれる「勝利の女王」。ローマへの複雑な感情を抱えつつも、面倒見のよい姉のような存在。
### ダビデ
クラス: アーチャー
性別: 男性
属性: 秩序・中庸・天
初登場: メインストーリー 第一部3章 (封鎖終局四海 オケアノス)
宝具: 五つの石 (ハメシュ・アヴァニム)
旧約聖書に登場するイスラエルの王。羊飼いから王となった人物で、飄々としているが、王としての器も持つ。
### ヘンリー・ジキル&ハイド
クラス: アサシン
性別: 男性
属性: 秩序・善 / 混沌・悪・地
初登場: メインストーリー 第一部4章 (死界魔霧都市 ロンドン)
宝具: 密やかなる罪の遊戯 (デンジャラス・ゲーム)
善良な紳士ジキルと、内なる悪性ハイドが同居する二重人格のサーヴァント。宝具によってハイドへ変貌する特殊な存在。
### ジェロニモ
クラス: キャスター
性別: 男性
属性: 中立・善・人
初登場: メインストーリー 第一部5章 (北米神話大戦 イ・プルーリバス・ウナム)
宝具: 大地を創りし者 (ツァゴ・デジ・ナレヤ)
アパッチ族の戦士であり、シャーマンとしての側面も持つ人物。冷静で理性的だが、誇りと怒りを内に秘めている。
### ベディヴィエール
クラス: セイバー
性別: 男性
属性: 秩序・善・星
初登場: メインストーリー 第一部6章 (神聖円卓領域 キャメロット)
宝具: 剣を摂れ、銀色の腕 (スイッチオン・アガートラム)
アーサー王に仕えた円卓の騎士の一人。忠義深く穏やかだが、第一部6章では物語の核心に関わる重要人物。
### ジャガーマン
クラス: ランサー
性別: 女性
属性: 混沌・中庸・地
初登場: メインストーリー 第一部7章 (絶対魔獣戦線 バビロニア)
宝具: 逃れ得ぬ死の鉤爪 (グレート・デス・クロー)
中南米のジャガー神霊が人間を依代にして現界した疑似サーヴァント。ノリは完全にギャグ寄りだが、神霊らしい強さも備えている。
### 沖田総司
クラス: セイバー
性別: 女性
属性: 中立・中庸・人
初登場: ぐだぐだ本能寺
宝具: 無明三段突き (むみょうさんだんづき)
新選組一番隊隊長。通称「桜セイバー」。病弱ネタでよく倒れるが、剣の腕は本物。
### 織田信長
クラス: アーチャー
性別: 女性
属性: 秩序・中庸・人
初登場: ぐだぐだ本能寺
宝具: 三千世界 (さんだんうち)
ぐだぐだシリーズの顔役の一人。ノリは軽いが、革新者・魔王としてのカリスマも持つ「ノッブ」。
### 土方歳三
クラス: バーサーカー
性別: 男性
属性: 秩序・悪・人
初登場: ぐだぐだ明治維新
宝具: 不滅の誠 (しんせんぐみ)
新選組副長。「まだ終わっちゃいねぇ」が似合いすぎる男。執念と戦意で最後まで戦い続ける。
### 茶々
クラス: バーサーカー
性別: 女性
属性: 混沌・中庸・人
初登場: ぐだぐだ明治維新
宝具: 絢爛魔界日輪城 (けんらんまかいにちりんじょう)
豊臣秀吉の側室・淀殿。幼い姿で現界しており、わがまま姫ムーブと豊臣の業をあわせ持つ。
### 織田信長〔バーサーカー〕
クラス: バーサーカー
性別: 女性
属性: 混沌・夏・人
初登場: デッドヒート・サマーレース!
宝具: 第六天魔王波旬〜夏盛〜 (ノブナガ・THE・ロックンロール)
水着ノッブ。ロックと火縄銃と第六天魔王が混ざった、だいたいいつも通りのカオスな信長。
### 沖田総司〔オルタ〕
クラス: アルターエゴ
性別: 女性
属性: 中立・中庸・人
初登場: ぐだぐだ帝都聖杯奇譚
宝具: 絶剱・無穹三段 (ぜっけん・むきゅうさんだん)
沖田総司の「もしも」の姿。通称「魔神さん」。ぼんやりしているが、世界を断つための剣を持つ。
### 岡田以蔵
クラス: アサシン
性別: 男性
属性: 中立・悪・人
初登場: ぐだぐだ帝都聖杯奇譚
宝具: 始末剣 (しまつけん)
幕末四大人斬りの一人。粗暴で拗ねやすいが、剣の才は天才的。「人斬り以蔵」としての業が重い。
### 坂本龍馬
クラス: ライダー
性別: 男性
属性: 中立・中庸・人
初登場: ぐだぐだ帝都聖杯奇譚
宝具: 天駆ける竜が如く (あまかけるりゅうがごとく)
幕末の志士。相棒のお竜さんと共に戦う、柔らかい物腰の交渉役。ぐだぐだ屈指の良心枠。
### 李書文〔アサシン〕
クラス: アサシン
性別: 男性
属性: 中立・悪・人
初登場: 雀のお宿の活動日誌 / 帝都聖杯奇譚系統
宝具: 无二打 (にのうちいらず)
老境に至った八極拳の達人。ぐだぐだ系では帝都聖杯奇譚側の人物として扱われる、静かな達人枠。
### 魔王信長
クラス: アヴェンジャー
性別: 女性
属性: 混沌・中庸・天
初登場: ぐだぐだファイナル本能寺
宝具: 波旬変生・三千大千天魔王 (はじゅんへんじょう・さんぜんだいせんてんまおう)
複数の信長概念が統合された存在。いつものノッブより魔王度が高く、スケールもやたらデカい。
### 森長可
クラス: バーサーカー
性別: 男性
属性: 混沌・狂・人
初登場: ぐだぐだファイナル本能寺
宝具: 人間無骨 (にんげんむこつ)
森蘭丸の兄で、戦国の暴れ武者。粗暴で危険だが、主君への忠義は本物。実にバーサーカー。
### 長尾景虎
クラス: ランサー
性別: 女性
属性: 秩序・善・人
初登場: ぐだぐだファイナル本能寺
宝具: 毘天八相車懸りの陣 (びてんはっそうくるまがかりのじん)
後の上杉謙信。戦の天才であり、笑顔のまま人間離れした価値観を見せる「軍神」。
### 沖田・J・総司
クラス: アサシン
性別: 女性
属性: 中立・夏・人
初登場: 見参!ラスベガス御前試合
宝具: ジェット三段突き (ジェットさんだんづき)
水着沖田さん。病弱をジェットで克服しようとした結果、別方向に大変なことになった。
### 卑弥呼
クラス: ルーラー
性別: 女性
属性: 混沌・善・天
初登場: ぐだぐだ邪馬台国
宝具: 星辰象る久遠鏡 (せいしんかたどるくおんきょう)
邪馬台国の女王。明るく豪快な姉御肌だが、神託を受ける巫女王としての神秘も強い。
### 斎藤一
クラス: セイバー
性別: 男性
属性: 中立・中庸・人
初登場: ぐだぐだ邪馬台国
宝具: 無形 (むぎょう)
新選組三番隊隊長。飄々としたサラリーマン風だが、抜けば怖い実戦派の剣士。
### 織田信勝
クラス: アーチャー
性別: 男性
属性: 混沌・善・人
初登場: ぐだぐだ邪馬台国
宝具: 魔王回天・曼殊沙華 (まおうかいてん・ひがんばな)
信長の弟。姉上ガチ勢。戦闘面でも設定面でも「姉上を輝かせる」ことに全振りしている。
### 沖田総司〔オルタ・セイバー〕
クラス: セイバー
性別: 女性
属性: 中立・中庸・人
初登場: カルデア・サマーアドベンチャー!
宝具: 絶剱・無穹一閃 (ぜっけん・むきゅういっせん)
水着の魔神さん。アルターエゴ時より少し自由で、煉獄とのコンビ感がさらに強い。
### 出雲阿国
クラス: キャスター
性別: 女性
属性: 混沌・善・人
初登場: ぐだぐだ龍馬危機一髪!
宝具: 阿国十八番・出雲荒神歌舞伎 (おくにはこばん・いずもあらがみかぶき)
歌舞伎の祖とされる芸能者。相棒の斬ザブローと共に、舞台映え全開で戦う。
### 謎の蘭丸X
クラス: アヴェンジャー
性別: 不明
属性: 秩序・善・星
初登場: ぐだぐだ龍馬危機一髪!
宝具: 蘭丸・X (らんまる・エックス)
森蘭丸……のようでいて、宇宙から来た蘭丸。忠義と謎と勢いで押し切る、ぐだぐだ宇宙枠。
### 坂本龍馬〔ランサー〕
クラス: ランサー
性別: 男性
属性: 中立・中庸・人
初登場: ぐだぐだ龍馬危機一髪!
宝具: 君よ、綿津見の原を征け (きみよ、わだつみのはらをゆけ)
龍馬とお竜さんの別霊基。ライダー時より神秘性が強く、お竜さんの正体にも深く踏み込む姿。
### 千利休
クラス: バーサーカー
性別: 女性
属性: 混沌・悪・人
初登場: ぶっちぎり茶の湯バトル ぐだぐだ新邪馬台国
宝具: 一期一会 (いちごいちえ)
茶聖・千利休と駒姫の要素を併せ持つ存在。静かな所作の奥に、黒く深い執念を抱える。
### 山南敬助
クラス: セイバー
性別: 男性
属性: 秩序・中庸・人
初登場: ぶっちぎり茶の湯バトル ぐだぐだ新邪馬台国
宝具: 山桜 (やまざくら)
新選組総長。穏やかで理知的だが、隊との関係や己の選択に深い影を持つ人物。
### 壱与
クラス: ルーラー
性別: 女性
属性: 秩序・善・地
初登場: ぶっちぎり茶の湯バトル ぐだぐだ新邪馬台国
宝具: 宵闇翳る月御珠 (よいやみかげるつきのみたま)
卑弥呼の後継者とされる巫女。真面目で一生懸命な後輩タイプだが、邪馬台国を背負う覚悟を持つ。
### 高杉晋作
クラス: アーチャー
性別: 男性
属性: 混沌・善・人
初登場: CBC2023 カルデア重工物語
宝具: 超級維新・奇兵隊 (ちょうきゅういしん・きへいたい)
長州の風雲児。面白いことを求めて突っ走る革命家気質で、奇兵隊の創設者。
### 武田信玄
クラス: ライダー
性別: 男性
属性: 秩序・悪・人
初登場: ぐだぐだ超五稜郭
宝具: 風林火山 (ふうりんかざん)
甲斐の虎。豪快な武将としての圧と、戦略家としての冷静さを併せ持つ大物。
### 永倉新八
クラス: セイバー
性別: 男性
属性: 中立・中庸・人
初登場: ぐだぐだ超五稜郭
宝具: 龍飛剣 (りゅうひけん)
新選組二番隊隊長。老いてなお剣気衰えぬ、実戦経験の塊のような剣士。
### 雑賀孫一
クラス: アーチャー
性別: 女性
属性: 中立・善・人
初登場: ぐだぐだ超五稜郭
宝具: 銃神・八咫烏 (じゅうしん・やたがらす)
雑賀衆の名を継ぐ鉄砲使い。クールな仕事人風だが、内面はわりと素直でまっすぐ。
### 上杉謙信
クラス: ルーラー
性別: 女性
属性: 秩序・善・人
初登場: ぐだぐだ超五稜郭
宝具: 毘天八相・不知火 (びてんはっそう・しらぬい)
長尾景虎がさらに軍神として完成した姿。戦場の化身めいた強さと、人外じみた透明感を持つ。
### 河上彦斎
クラス: アサシン
性別: 女性
属性: 混沌・悪・人
初登場: ぐだぐだ新選組ジ・エンド
宝具: 抜刀・神威 (ばっとう・かむい)
幕末四大人斬りの一人。小柄で静かな雰囲気だが、剣を抜けば神域の抜刀術を見せる危険人物。
### 藤堂平助
クラス: アヴェンジャー
性別: 男性
属性: 混沌・中庸・人
初登場: ぐだぐだ新選組ジ・エンド
宝具: 未詳
新選組八番隊隊長。若さと複雑な立場を背負った、新選組サイドの新たなぐだぐだ鯖。
### 原田左之助
クラス: ランサー
性別: 男性
属性: 中立・善・人
初登場: ぐだぐだ新選組ジ・エンド
宝具: 未詳
新選組十番隊隊長。槍の名手で、豪快さと仲間想いな気質を併せ持つ配布ランサー。
### 近藤勇
クラス: セイバー
性別: 男性
属性: 混沌・善・人
初登場: ぐだぐだ新選組ジ・エンド
宝具: 誠の旗 (まことのはた)
新選組局長。隊士たちの中心に立つ人物で、「誠」の一字を背負う新選組の象徴。
+20
View File
@@ -33,3 +33,23 @@ packages = ["app"]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pyright>=1.1.410",
"ruff>=0.15.18",
]
[tool.ruff]
line-length = 119
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.pyright]
include = ["app"]
venvPath = "."
venv = ".venv"
typeCheckingMode = "basic"
pythonVersion = "3.12"
+8
View File
@@ -0,0 +1,8 @@
# 環境によっては SSLKEYLOGFILE 環境変数(ネットワーク監視ツール等が設定する場合がある)が
# litellm/aiohttp 経由のOpenSSL初期化と衝突し、
# "OPENSSL_Uplink ... no OPENSSL_Applink" でプロセスがクラッシュすることがある。
# このスクリプト経由で起動したプロセスだけ当該環境変数を外して起動する。
Remove-Item Env:\SSLKEYLOGFILE -ErrorAction SilentlyContinue
uv run uvicorn app.main:app --reload
+59
View File
@@ -1074,6 +1074,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
] ]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]] [[package]]
name = "nvidia-ml-py" name = "nvidia-ml-py"
version = "13.610.43" version = "13.610.43"
@@ -1140,6 +1149,12 @@ dev = [
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" }, { name = "alembic", specifier = ">=1.13.0" },
@@ -1163,6 +1178,12 @@ requires-dist = [
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1.410" },
{ name = "ruff", specifier = ">=0.15.18" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -1393,6 +1414,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pyright"
version = "1.1.410"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/53/e4d8ea1391bd4355231be6f91bf239479aa0014260ed3fb5526eeb12a1f2/pyright-1.1.410.tar.gz", hash = "sha256:07a073b8ba6749826773c1269773efa11b93440d9a6aa60419d9a3172d6dc488", size = 4062013, upload-time = "2026-06-01T17:35:48.894Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/33/288b5868fa00846dacf249633719d747893e54aebd196b9968ac1878a5d3/pyright-1.1.410-py3-none-any.whl", hash = "sha256:5e961bed37cacf96b3f7cd7b1da39b350a9239aa2e69138d0e88f728cfaf296c", size = 6082448, upload-time = "2026-06-01T17:35:46.387Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" version = "9.0.3"
@@ -1729,6 +1763,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" },
] ]
[[package]]
name = "ruff"
version = "0.15.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" },
{ url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" },
{ url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" },
{ url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" },
{ url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" },
{ url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" },
{ url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" },
{ url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" },
{ url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" },
{ url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" },
{ url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" },
]
[[package]] [[package]]
name = "s3transfer" name = "s3transfer"
version = "0.19.0" version = "0.19.0"
+111
View File
@@ -0,0 +1,111 @@
# 雑談Bot(Discord連携)仕様
`docs/architecture.md` のチャットアプリ本体に対する拡張機能群。Discord上での雑談応答・天気案内・定期的などうでもいい豆知識配信、およびそれらを支えるタグ付け/ワードプール/知識ベース機構をまとめる。
## 全体像
```
Discord ⇄ discord_bot.py ⇄ chat.py (REST/SSE) ⇄ LiteLLM
│ │
├─ scheduler.py ├─ tagging.py(メッセージへタグ付け)
│ (定期実行) ├─ word_memory.py(話題語彙プール)
│ └─ knowledge.py / websearch.py(Web検索→知識ベース)
└─ JMA天気API
```
## 1. Discord bot(`backend/app/core/discord_bot.py`)
- Bot宛てメンション(`on_message`)に応答。メンション以外は無視
- **天気キーワード**検出時: 気象庁(JMA) APIと連携し天気・洗濯指数を案内
- **雑談キーワード**検出時:
1. ユーザー発言をチャット応答へリダイレクト(`_generate_useless_talk_redirect`)
2. `tagging.generate_tags()` で発言からタグを3つ生成
3. `tagging.find_related_tag()` で関連トピックを1つ選定
4. `scheduler.send_useless_tip(topic_override=related_tag)` を即時実行し、その話題に絡めた豆知識を配信
- 受信メッセージは `word_memory.record_message()` で語彙プールにも記録される
## 2. スケジューラ(`backend/app/core/scheduler.py`)
APSchedulerによる定期ジョブ2種:
| ジョブ | 頻度 | 内容 |
|---|---|---|
| 洗濯天気通知 | 毎日22時 | JMA天気を基に翌日の洗濯指数を配信 |
| どうでもいい豆知識 (`send_useless_tip`) | `USELESS_TIPS_INTERVAL_HOURS`時間ごと(既定3h、`USELESS_TIPS_ENABLED`で有効化) | ランダム/指定トピックでLLMに60文字以内の「どうでもいい豆知識」を生成させ配信 |
`send_useless_tip(topic_override=None)`:
- AM3時台は配信スキップ(静音時間)
- `topic_word = topic_override or word_memory.pick_random_word()` でトピックを決定
- `topic_word`が決まれば、プロンプトに「〇〇という話題にこじつけてネタを絡めて」という指示を追加
- 生成文を `💡 今日のお役立ち情報` として Discord Webhook へ送信
## 3. メッセージタグ付け(`backend/app/core/tagging.py`)
- `generate_tags(content)`: LLMでメッセージ本文から最大3タグを生成(JSON出力を期待し、崩れていればカンマ区切りにフォールバック)
- `tag_message(db, message)`: 1メッセージにタグを生成し `MessageTag` テーブルへ保存。**ロール非依存**で、ユーザー発言・アシスタント応答の両方に使われる
- `tag_message_by_id(message_id)`: バックグラウンドタスク用。`chat.py` のSSE応答処理内で、ユーザーメッセージ保存後・アシスタント応答保存後の両方で `asyncio.create_task()` 経由で呼ばれる(応答ストリームをブロックしない)
- `tag_memory(db, memory)`: 要約(`Memory`)にもタグ付け可能(`MemoryTag`テーブル)
- `backfill_tags(db, limit=None)`: 未タグのuser/assistantメッセージを一括タグ付けする保守用関数
### `find_related_tag(seed_tags)` — 関連トピック選定とフォールバックチェーン
雑談リダイレクト時に呼ばれ、最終的に「豆知識のネタ」となる文字列を返す。
1. `seed_tags`が空 → `None` を返す(呼び出し元の`send_useless_tip`側で`word_memory.pick_random_word()`にフォールバック)
2. `seed_tags`と同じメッセージ/要約に付いている他のタグ(=関連タグ)を探す。見つかればその中からランダムに1つ選択。見つからなければ`seed_tags`自体からランダムに1つ選択 → `chosen_tag`
3. `_pick_word_for_tag(db, chosen_tag)` で、`chosen_tag`が付いた `Message.content` / `Memory.summary` の本文から名詞を抽出(`word_memory.extract_words`)し、現在のワードプールと重なる単語を優先選択(`word_memory.pick_word_near`)。重なりが無ければ抽出語からランダム選択
4. 本文から単語が1つも抽出できなければ `chosen_tag`(タグ文字列そのもの)を返す
→ 「関連タグが見つかった場合でも、ただタグ文字列を使うのではなく、その話題の実際の発言内容とワードプールの今ホットな語彙を突き合わせて、より自然な単語をネタに選ぶ」という多段フォールバック構成。
## 4. ワードプール(`backend/app/core/word_memory.py`)
- Janomeで形態素解析し、名詞(一般・固有名詞・サ変接続)のみを抽出。2文字未満・ストップワードは除外
- Markdownの記法(コードブロック・見出し・リンク等)は事前に除去
- `_recent_words: deque(maxlen=200)` がインメモリの揮発性プール(DB非永続、再起動でリセット。起動時は `word_memory.seed_from_history()` で直近メッセージから再構築)
- 主な関数:
- `record_message(content)`: 本文を解析しプールに追記
- `extract_words(content)`: プールに追記せず単語リストのみ返す(タグ紐づけ用本文の解析に利用)
- `pick_random_word()`: プールからランダムに1語
- `pick_word_near(words)`: 渡した単語群とプールの積集合からランダムに1語(無ければNone)
- `get_word_pool_with_counts()`: 出現回数つきでプール全体を返す(`WordPool.vue`表示用)
- `chat.py`のSSE処理・`discord_bot.py`のメッセージ受信、双方で`record_message()`が呼ばれ語彙が蓄積される
## 5. 知識ベース/Web検索(`websearch.py` / `knowledge.py`)
- `websearch.py`: Tavily APIで検索を実行
- `knowledge.py`: 検索結果(クエリ・要約・出典URL)を `KnowledgeEntry` / `KnowledgeSource` テーブルへ永続化し、以後の会話コンテキストへ再注入できる知識ベースとして機能
- `chat.py` の `POST /chat/stream` 内で、LLMが追加情報を必要とした場合(`llm.resolve_web_search`)に検索が走り、結果は `sources` イベントとしてフロントへ、要約は `knowledge.save_knowledge()` でDBへ保存される
## 6. DBスキーマ(拡張分)
`backend/app/db/models.py` で追加されたテーブル:
| テーブル | 主なカラム | 用途 |
|---|---|---|
| `message_tags` | id, message_id (FK), tag, created_at | メッセージ単位のタグ |
| `memory_tags` | id, memory_id (FK), tag, created_at | 要約(長期メモリ)単位のタグ |
| `knowledge_entries` | id, query, summary, created_at | Web検索結果の要約 |
| `knowledge_sources` | id, entry_id (FK), title, url | 知識エントリの出典 |
## 7. 環境変数(追加分、`backend/.env.example`)
| 変数 | 説明 |
|---|---|
| `USELESS_TIPS_ENABLED` | どうでもいい豆知識ジョブの有効化フラグ |
| `USELESS_TIPS_INTERVAL_HOURS` | 豆知識配信の間隔(時間、既定3) |
| `USELESS_TIPS_MODEL` | 豆知識生成に使うLLMモデル(未指定時は`DEFAULT_MODEL`) |
| `TAGGING_MODEL` | タグ生成に使うLLMモデル |
| Discord Bot Token / Webhook URL 系 | Discord連携用 |
| 天気API関連 | JMA連携用 |
| Tavily APIキー | Web検索用 |
| Bedrock関連 | LiteLLM経由でのAWS Bedrock利用設定 |
## 8. フロントエンド: WordPool(`frontend/src/components/WordPool.vue`)
- `word_memory.get_word_pool_with_counts()` を参照し、現在の話題語彙をタグクラウド的に表示
- `App.vue` のサイドバーに `SessionList` / `MemoryMonitor` と並んで配置
---
最終更新: 2026-06-20(タグ付けの双方向化・`find_related_tag`へのword_memory連携を反映、コミット `234ebba`)