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
+40
View File
@@ -0,0 +1,40 @@
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)
)
@@ -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)
)
+62
View File
@@ -0,0 +1,62 @@
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.memory_kb import knowledge
from app.core.memory_kb import local_knowledge
from app.core.memory_kb.tagging import tag_memory
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()
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 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:
combined_summary = "\n\n".join(m.summary for m in memories)
context.append({
"role": "system",
"content": f"以下はこの会話の過去の要約です:\n\n{combined_summary}",
})
context.extend({"role": m.role, "content": m.content} for m in messages)
return context
async def maybe_summarize(db: DBSession, session: Session) -> None:
"""メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージを削除する。"""
messages = db.query(Message).filter(Message.session_id == session.id).order_by(Message.created_at).all()
if len(messages) < MEMORY_SUMMARIZE_THRESHOLD:
return
# 古いメッセージ(閾値の半分)を要約対象にする
half = MEMORY_SUMMARIZE_THRESHOLD // 2
to_summarize = messages[:half]
conversation_text = "\n".join(f"{m.role}: {m.content}" for m in to_summarize)
summary_prompt = [
{"role": "system", "content": "以下の会話を簡潔に要約してください。重要な事実・決定・文脈を保持してください。"},
{"role": "user", "content": conversation_text},
]
summary_text = await llm.complete(summary_prompt, model=session.model, session_id=session.id)
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)
+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.memory_kb 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
+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)
]