From be3af44891e43c912b4822764225b26d66c4c76e Mon Sep 17 00:00:00 2001 From: zukki259 Date: Thu, 20 Aug 2026 14:22:59 +0900 Subject: [PATCH 1/3] Store timestamps as UTC-aware and slim the knowledge context to headlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タイムゾーン: SQLite の DATETIME は tzinfo を保存できず、DateTime(timezone=True) を 指定してもバインド時に捨てられるため、API のレスポンスがオフセット無しの ISO 文字列になりフロントがローカル時刻として誤解していた。UtcDateTime (TypeDecorator) を追加し、書き込み時は naive な UTC に正規化、読み出し時は UTC の aware として返すようにする。格納フォーマットは変わらないので既存 データはそのまま正しく解釈でき、マイグレーションは不要。 コンテナは TZ 未設定で UTC だったため、天気通知の cron 時刻・深夜スキップ 判定・「明日」の日付計算が9時間ずれていた。TZ=Asia/Tokyo を指定し、slim イメージに無い tzdata を導入する。 蓄積知識: 層1が最新15件の要約全文を毎リクエスト無条件に注入しており、同じ内容が ローカルKB(層3)にも保存されているため重複していた。層1を「クエリ+ 一行要約」の目次に変え、本文は read_knowledge ツールで読み出す形にする。 一行要約は要約生成時の既存 LLM 呼び出しに相乗りするので呼び出し回数は 増えない。headline を持たない既存エントリは本文の先頭行から導出する。 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 5 +- backend/Dockerfile | 5 + backend/app/api/chat.py | 4 +- backend/app/core/config.py | 3 + backend/app/core/llm.py | 14 +- backend/app/core/memory_kb/knowledge.py | 152 +++++++++++++++-- backend/app/core/weather/weather_cache.py | 5 +- backend/app/db/database.py | 16 +- backend/app/db/models.py | 32 ++-- backend/app/db/types.py | 38 +++++ docker-compose.yml | 4 + docs/architecture.md | 9 +- docs/features.md | 189 ++++++++++++++++++++++ 13 files changed, 427 insertions(+), 49 deletions(-) create mode 100644 backend/app/db/types.py create mode 100644 docs/features.md diff --git a/CLAUDE.md b/CLAUDE.md index 25d555f..4b735e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_context_messages()` が毎リクエスト組み立てる。 以下の順に system メッセージを積み、最後に会話履歴を連結する(詳細は `docs/architecture.md`)。 -1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の要約全文+出典。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する +1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する 2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ 3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い 4. **長期メモリ**: `memories` の要約を全件連結。上限なし @@ -82,7 +82,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte **要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは物理削除される**(復元不可)。 -**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。 +**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。 **付随機能**: `memory_kb/tagging.py` が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。`memory_kb/word_memory.py` は Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。 @@ -144,5 +144,6 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte | `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | | `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 | +| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 | | `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | | `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | diff --git a/backend/Dockerfile b/backend/Dockerfile index 7454e57..91dd100 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,10 @@ FROM python:3.12-slim +# slim イメージには /usr/share/zoneinfo が無く、TZ を設定してもUTCのままになるため入れる。 +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata \ + && rm -rf /var/lib/apt/lists/* + RUN pip install --no-cache-dir uv WORKDIR /app diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 83829ec..3462adc 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -224,9 +224,9 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)): ] yield sse({"type": "sources", "value": sources}) for s in searches: - summary = await knowledge.summarize_results(s["query"], s["results"]) + headline, summary = await knowledge.summarize_results(s["query"], s["results"]) if summary: - knowledge.save_knowledge(db, s["query"], summary, s["results"]) + knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline) local_knowledge.save_search_result(s["query"], summary, s["results"]) # 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある diff --git a/backend/app/core/config.py b/backend/app/core/config.py index eeb6a79..5e9e903 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,6 +41,9 @@ LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75")) # read_note ツールが1回に返す本文の最大文字数 NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000")) +# read_knowledge ツールが1回に返す蓄積知識の本文の最大文字数 +KNOWLEDGE_TOOL_CONTENT_LIMIT: int = int(os.getenv("KNOWLEDGE_TOOL_CONTENT_LIMIT", "4000")) + # Amazon Bedrock # 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。 # IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。 diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 3852ba9..2818a87 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -116,17 +116,21 @@ async def resolve_tool_calls( ) -> tuple[list[dict], list[dict], bool]: """必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。 - 扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が - 渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず - 元のmessagesをそのまま返す。 + 扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。 + DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は + 何もせず元のmessagesをそのまま返す。 戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか) """ + # app.core.memory_kb.knowledge は app.core.llm を import しているため、 + # モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。 + from app.core.memory_kb.knowledge import KNOWLEDGE_TOOLS, execute_knowledge_tool + used_model = model or DEFAULT_MODEL working_messages = list(messages) searches: list[dict] = [] notes_changed = False - tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else []) + tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS + KNOWLEDGE_TOOLS if db is not None else []) for _ in range(max_rounds): try: @@ -168,6 +172,8 @@ async def resolve_tool_calls( if name in ("read_note", "write_note") and db is not None: tool_text, changed = execute_note_tool(db, name, args) notes_changed = notes_changed or changed + elif name == "read_knowledge" and db is not None: + tool_text = execute_knowledge_tool(db, name, args) else: query = args.get("query", "") results = await search_web(query) diff --git a/backend/app/core/memory_kb/knowledge.py b/backend/app/core/memory_kb/knowledge.py index 5ae6b89..c643770 100644 --- a/backend/app/core/memory_kb/knowledge.py +++ b/backend/app/core/memory_kb/knowledge.py @@ -7,7 +7,11 @@ import trafilatura from sqlalchemy.orm import Session as DBSession from app.core import llm -from app.core.config import KNOWLEDGE_CONTEXT_LIMIT, SEARCH_SUMMARY_MODEL +from app.core.config import ( + KNOWLEDGE_CONTEXT_LIMIT, + KNOWLEDGE_TOOL_CONTENT_LIMIT, + SEARCH_SUMMARY_MODEL, +) from app.core.memory_kb import local_knowledge from app.db.models import KnowledgeEntry, KnowledgeSource @@ -19,6 +23,9 @@ _HTML_TAG_RE = re.compile(r"<[^>]+>") _SUMMARY_PROMPT = ( "以下はWeb検索「{query}」で得られた複数の検索結果です。" "これらの内容を整理し、Markdown形式で構造的にまとめてください。\n" + "- 1行目は「要約: 」で始まる60文字以内の一文にする" + "(何についての知識かが一目で分かる見出し。システムプロンプトにはこの行だけを載せる)\n" + "- 2行目以降を本文とし、以下の要領でまとめる\n" "- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n" "- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n" "- 各項目の末尾に (出典: サイト名) を付ける\n" @@ -27,15 +34,46 @@ _SUMMARY_PROMPT = ( ) -async def summarize_results(query: str, results: list[dict], model: str | None = None) -> str: +_HEADLINE_MARKERS = ("要約:", "要約:") + + +def _split_headline(text: str) -> tuple[str, str]: + """LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。""" + lines = text.strip().splitlines() + if not lines: + return "", "" + first = lines[0].strip().lstrip("#").lstrip("-*").strip().strip("*").strip() + for marker in _HEADLINE_MARKERS: + if not first.startswith(marker): + continue + headline = first[len(marker):].strip().strip("*").strip() + body = "\n".join(lines[1:]).strip() + if headline and body: + return headline[:120], body + break + return "", text.strip() + + +def derive_headline(query: str, summary: str) -> str: + """headline を持たないエントリ(この機能より前に保存されたもの)のための代替の見出し。""" + for line in summary.splitlines(): + stripped = line.strip().lstrip("#").lstrip("-*").strip().strip("*").strip() + if stripped: + return stripped[:120] + return query + + +async def summarize_results(query: str, results: list[dict], model: str | None = None) -> tuple[str, str]: """Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。 - 要約に失敗した場合は空文字を返す(生のスクレイピング結果を未加工のままファイルに + 戻り値は (一行要約, 本文)。一行要約はシステムプロンプトに並べる見出しとして使い、 + 本文はDBとローカルナレッジベースに保存する。 + 要約に失敗した場合は ("", "") を返す(生のスクレイピング結果を未加工のままファイルに 書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、 フォールバックとして生テキストを書き出すことはしない)。 """ if not results: - return "" + return "", "" raw = "\n\n".join( f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}" @@ -43,17 +81,20 @@ async def summarize_results(query: str, results: list[dict], model: str | None = if r.get("content") ) if not raw: - return "" + return "", "" prompt = _SUMMARY_PROMPT.format(query=query, results=raw) try: - return await llm.complete( + text = await llm.complete( [{"role": "user", "content": prompt}], model=model or SEARCH_SUMMARY_MODEL, ) except Exception: logger.exception("Failed to summarize search results for query=%r", query) - return "" + return "", "" + + headline, body = _split_headline(text) + return headline or derive_headline(query, body), body def _strip_tags(html: str) -> str: @@ -111,12 +152,13 @@ async def regenerate_from_sources(db: DBSession) -> int: if not results: continue - new_summary = await summarize_results(entry.query, results) + new_headline, new_summary = await summarize_results(entry.query, results) if not new_summary: continue old_summary = entry.summary entry.summary = new_summary + entry.headline = new_headline or derive_headline(entry.query, new_summary) db.commit() local_knowledge.delete_search_result_file(entry.query, old_summary) @@ -129,9 +171,11 @@ async def regenerate_from_sources(db: DBSession) -> int: return count -def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry: +def save_knowledge( + db: DBSession, query: str, summary: str, sources: list[dict], headline: str = "", +) -> KnowledgeEntry: """Web検索で得た知見をDBに保存する。""" - entry = KnowledgeEntry(query=query, summary=summary) + entry = KnowledgeEntry(query=query, summary=summary, headline=headline or derive_headline(query, summary)) db.add(entry) db.flush() for src in sources: @@ -143,8 +187,80 @@ def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) return entry +READ_KNOWLEDGE_TOOL = { + "type": "function", + "function": { + "name": "read_knowledge", + "description": "過去のWeb検索で蓄積した知識の本文を読み出す。システムプロンプトに提示された" + "「調べた事柄の一覧」の中に今の話題に関係しそうな項目があり、その本文がまだ提示されていないときに使う。" + "ここで読めるならWeb検索をやり直す必要はない。", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "読み出す項目のクエリ(一覧の「」内に載っているもの)"}, + }, + "required": ["query"], + }, + }, +} + +KNOWLEDGE_TOOLS = [READ_KNOWLEDGE_TOOL] + + +def _find_entry(db: DBSession, query: str) -> KnowledgeEntry | None: + """クエリで蓄積知識を探す。完全一致 → 大文字小文字・前後空白を無視した一致 → 部分一致 の順に試す。 + + 同じクエリで複数回検索されている場合は、最も新しいものを返す。 + """ + entries = db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).all() + for entry in entries: + if entry.query == query: + return entry + normalized = query.strip().casefold() + for entry in entries: + if entry.query.strip().casefold() == normalized: + return entry + for entry in entries: + if normalized and normalized in entry.query.strip().casefold(): + return entry + return None + + +def read_knowledge(db: DBSession, query: str) -> str: + """蓄積知識の本文をツール結果用のテキストとして返す。""" + entry = _find_entry(db, query) + if not entry: + available = ", ".join( + e.query + for e in db.query(KnowledgeEntry).order_by(KnowledgeEntry.created_at.desc()).limit(KNOWLEDGE_CONTEXT_LIMIT) + ) + return f"「{query}」に一致する知識はありません。読み出せる項目: {available or '(なし)'}" + + content = entry.summary + if len(content) > KNOWLEDGE_TOOL_CONTENT_LIMIT: + content = content[:KNOWLEDGE_TOOL_CONTENT_LIMIT] + "\n…(以下省略)" + urls = ", ".join(s.url for s in entry.sources) or "出典不明" + return f"「{entry.query}」について調べた内容:\n\n{content}\n\n(出典: {urls})" + + +def execute_knowledge_tool(db: DBSession, name: str, args: dict) -> str: + """蓄積知識のツールを実行する。ノート系と違いDBを変更しないため、戻り値はテキストのみ。""" + try: + if name == "read_knowledge": + return read_knowledge(db, args.get("query", "")) + except Exception as e: + logger.exception("Knowledge tool %s failed", name) + return f"知識の読み出しに失敗しました: {e}" + return f"未知のツールです: {name}" + + def get_knowledge_context(db: DBSession) -> str | None: - """蓄積された知識を、システムプロンプトに注入できる形のテキストにして返す。""" + """蓄積された知識の「見出し一覧」を、システムプロンプトに注入できる形のテキストにして返す。 + + 本文(summary)はここには含めない。同じ内容が knowledge_base/web_search/ 配下にも + Markdownとして保存されており、話題に関連していればローカルナレッジベース側が + 全文を注入するため、ここで全件の本文を積むと同じ情報を二重に渡すことになる。 + """ entries = ( db.query(KnowledgeEntry) .order_by(KnowledgeEntry.created_at.desc()) @@ -154,12 +270,14 @@ def get_knowledge_context(db: DBSession) -> str | None: 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})") + blocks = [ + f"- 「{entry.query}」: {entry.headline or derive_headline(entry.query, entry.summary)}" + for entry in reversed(entries) + ] return ( - "以下はこれまでのWeb検索で得た知識です。関連する話題が出たら活用してください。" - "ただしローカルナレッジベースの資料が別途提示されている場合は、そちらを優先してください:\n\n" + "\n".join(blocks) + "以下はこれまでのWeb検索で調べた事柄の一覧です(見出しのみで、本文は含みません)。\n" + "関連する話題が出たときは、まず本文がローカルナレッジベースの資料として別途提示されていないか確認し、" + "あればそれを本文として使ってください。提示されていない場合は read_knowledge ツールで読み出せます。" + "いずれかで足りるならWeb検索をやり直す必要はありません:\n\n" + "\n".join(blocks) ) diff --git a/backend/app/core/weather/weather_cache.py b/backend/app/core/weather/weather_cache.py index 44fd12f..2769041 100644 --- a/backend/app/core/weather/weather_cache.py +++ b/backend/app/core/weather/weather_cache.py @@ -1,5 +1,5 @@ """直近で取得した「明日の天気」データをメモリ上にキャッシュする。""" -from datetime import datetime +from datetime import datetime, timezone _latest: dict | None = None @@ -11,7 +11,8 @@ def set_latest(weather: str, pop: int | None, judgement: str, reason: str) -> No "pop": pop, "judgement": judgement, "reason": reason, - "fetched_at": datetime.now().isoformat(), + # オフセット付きで返す(フロントの new Date() がローカル時刻と誤解しないようにするため) + "fetched_at": datetime.now(timezone.utc).isoformat(), } diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 73e685d..12bc486 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -18,14 +18,22 @@ def get_db(): db.close() +# 既存DBに後から追加されたカラム: (テーブル名, カラム名, 型) +_ADDED_COLUMNS = [ + ("note_pages", "section", "VARCHAR"), + ("knowledge_entries", "headline", "VARCHAR"), +] + + def _add_missing_columns() -> None: """マイグレーションフレームワークを使っていないため、既存DBに後から追加されたカラムを ここで簡易的に補う。create_all() は既存テーブルへのカラム追加は行わないので必要。""" with engine.connect() as conn: - existing = {row[1] for row in conn.execute(text("PRAGMA table_info(note_pages)"))} - if existing and "section" not in existing: - conn.execute(text("ALTER TABLE note_pages ADD COLUMN section VARCHAR")) - conn.commit() + for table, column, column_type in _ADDED_COLUMNS: + existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))} + if existing and column not in existing: + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")) + conn.commit() def init_db(): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 9693385..24d044e 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,7 +1,8 @@ from datetime import datetime, timezone -from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float +from sqlalchemy import String, Text, ForeignKey, Integer, Float from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.database import Base +from app.db.types import UtcDateTime class Session(Base): @@ -10,8 +11,8 @@ class Session(Base): id: Mapped[str] = mapped_column(String, primary_key=True) title: Mapped[str] = mapped_column(String, default="New Chat") model: Mapped[str] = mapped_column(String, default="") - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) messages: Mapped[list["Message"]] = relationship(back_populates="session", cascade="all, delete-orphan", order_by="Message.created_at") memories: Mapped[list["Memory"]] = relationship(back_populates="session", cascade="all, delete-orphan") @@ -24,7 +25,7 @@ class Message(Base): session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system" content: Mapped[str] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, 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") @@ -36,7 +37,7 @@ class MessageTag(Base): 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)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) message: Mapped["Message"] = relationship(back_populates="tags") @@ -48,7 +49,7 @@ class Memory(Base): session_id: Mapped[str] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE")) summary: Mapped[str] = mapped_column(Text) embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True) # 将来のベクトル検索用 - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, 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") @@ -60,7 +61,7 @@ class MemoryTag(Base): 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)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) memory: Mapped["Memory"] = relationship(back_populates="tags") @@ -72,7 +73,10 @@ class KnowledgeEntry(Base): 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)) + # システムプロンプトには本文ではなくこの一行要約だけを並べる(app.core.memory_kb.knowledge を参照)。 + # このカラム追加より前に作られた行では None になるため、表示側で summary から導出する。 + headline: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) sources: Mapped[list["KnowledgeSource"]] = relationship(back_populates="entry", cascade="all, delete-orphan") @@ -101,7 +105,7 @@ class LocalKnowledgeDoc(Base): 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)) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) class NotePage(Base): @@ -117,8 +121,8 @@ class NotePage(Base): content: Mapped[str] = mapped_column(Text, default="") position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順(セクションでまとめる際、同じセクションのページは連続させる) section: Mapped[str | None] = mapped_column(String, nullable=True) # タブをグループ化するセクション名。未設定なら未分類 - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) class AppSetting(Base): @@ -127,7 +131,7 @@ class AppSetting(Base): key: Mapped[str] = mapped_column(String, primary_key=True) value: Mapped[str] = mapped_column(Text) - updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) class ModelPreset(Base): @@ -137,7 +141,7 @@ class ModelPreset(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) label: Mapped[str] = mapped_column(String) value: Mapped[str] = mapped_column(String, unique=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) class ApiLog(Base): @@ -150,4 +154,4 @@ class ApiLog(Base): response_text: Mapped[str | None] = mapped_column(Text, nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True) latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc)) + created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/db/types.py b/backend/app/db/types.py new file mode 100644 index 0000000..dda7b91 --- /dev/null +++ b/backend/app/db/types.py @@ -0,0 +1,38 @@ +"""タイムゾーンを保ったまま日時を扱うためのカラム型。""" +from datetime import datetime, timezone + +from sqlalchemy import DateTime +from sqlalchemy.types import TypeDecorator + + +class UtcDateTime(TypeDecorator): + """常に UTC の aware な datetime として読み書きする DateTime。 + + SQLite の DATETIME は tzinfo を保存できず、`DateTime(timezone=True)` を指定しても + バインド時に黙って捨てられる(=naive な値が返ってくる)。そのままだと API のレスポンスが + オフセット無しの ISO 文字列になり、フロントの `new Date()` がローカル時刻として + 解釈してしまうため、この型で入口と出口を揃える。 + + - 書き込み: aware な値は UTC に変換し、naive な値は UTC とみなす。いずれも naive な UTC として格納する + - 読み出し: 格納値を UTC の aware な datetime として返す + + 格納フォーマットは素の DateTime と同じ(naive な UTC)なので、 + 既存DBのデータはそのまま正しく解釈でき、マイグレーションは不要。 + """ + + impl = DateTime + cache_ok = True + + def process_bind_param(self, value: datetime | None, dialect) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value + return value.astimezone(timezone.utc).replace(tzinfo=None) + + def process_result_value(self, value: datetime | None, dialect) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/docker-compose.yml b/docker-compose.yml index 7d3ae3a..02340c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,10 @@ services: environment: # SQLite ファイルと知識ベースを永続ボリュームに置く(.env の値を上書き) DATABASE_URL: sqlite:////app/data/playchat.db + # コンテナのタイムゾーン。未設定だとUTCになり、天気通知のcron時刻・深夜スキップ判定・ + # 「明日」の日付計算がすべて9時間ずれる。プロセス起動前に効かせる必要があるため + # .env ではなくここで指定する(後から os.environ に入れても libc には反映されない)。 + TZ: Asia/Tokyo volumes: - backend_data:/app/data - backend_knowledge_base:/app/knowledge_base diff --git a/docs/architecture.md b/docs/architecture.md index 7cc6551..dce5a87 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,7 +77,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag | # | 層 | 中身 | 実装 | 量の制御 | |---|---|---|---|---| -| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**要約全文**+出典 URL | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 | +| 1 | 蓄積知識 | 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(本文・出典は含めない) | `knowledge.get_knowledge_context()` | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 | | 2 | 共有ノート | ページの**タイトル一覧+本文先頭 40 文字**のみ | `notes.get_notes_context()` | 全ページ(本文は入れない) | | 3 | ローカル KB | `knowledge_base/**.md` から選ばれた**ファイル全文** | `local_knowledge.get_local_kb_context()` | 直近ユーザー発言との cos 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件、かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 | | 4 | 長期メモリ | `memories` の要約を全件連結 | `memory.get_context_messages()` | 上限なし | @@ -100,6 +100,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag `llm.resolve_tool_calls()` が本番のストリーミング生成の**前に**別途 LLM を呼んでツール呼び出しを解決する(最大 2 ラウンド)。 - `read_note` / `write_note` … 共有ノート本文の読み書き(`db` を渡したときだけモデルに提示) +- `read_knowledge` … 層 1 の見出し一覧に出ている知識の本文を読む(同上)。層 3 として本文が既に注入されていれば呼ぶ必要はない - `web_search` … Tavily 検索。結果は `knowledge.summarize_results()` で構造化要約にされ、 `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存される。 → 次回以降の層 1・層 3 に自動的に効いてくる @@ -113,8 +114,8 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag #### 設計上の注意点 -- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 1(知識 15 件の要約全文)と層 3(Markdown 全文 ×3)は - 会話が短くてもコンテキストを大きく押し上げる +- トークン数ベースの制御は無く、すべて件数・文字数ベース。層 3(Markdown 全文 ×3)は会話が短くても + コンテキストを大きく押し上げる(層 1 は見出しのみなので件数を増やしても軽い) - ツールを使わない会話でも `resolve_tool_calls()` が必ず 1 回フルコンテキストを投げるため、**LLM 呼び出しは最低 2 回**になる - 層 4(長期メモリ)に上限が無く、長いセッションでは要約が積み上がる(要約の再要約はしない) - `get_local_kb_context()` が毎リクエストで `sync_local_kb()`(ディレクトリスキャン)と埋め込み API 呼び出しを 1 回行う @@ -122,7 +123,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag ### LLM 呼び出し(`core/llm.py`) - `stream_chat()` / `complete()` / `embed()` が LiteLLM を薄くラップ -- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` を解決し、結果を組み込んだメッセージ列を返す +- `resolve_tool_calls()` が `web_search` / `read_note` / `write_note` / `read_knowledge` を解決し、結果を組み込んだメッセージ列を返す - `_log_context` で呼び出しを囲み、成功・失敗どちらも `api_logs` テーブルに記録(`embed()` は対象外) - `DEBUG=true` で `litellm._turn_on_debug()` が有効になり詳細ログが出力される diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..64ad8af --- /dev/null +++ b/docs/features.md @@ -0,0 +1,189 @@ +# 機能一覧 + +このリポジトリにあるアプリ(PlayChat)が「何ができるか」を機能単位でまとめたもの。 +内部設計は [architecture.md](architecture.md)、エンドポイント仕様は [api.md](api.md)、 +Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は [notes-guide.md](notes-guide.md)、 +デプロイ手順は [deploy-lightsail.md](deploy-lightsail.md) を参照。 + +## これは何か + +個人用の LLM チャットアプリ。FastAPI(Python)+ Vue 3(TypeScript)で、会話・記憶・ノートを +すべて手元の SQLite に持つ。LLM は LiteLLM 経由なので OpenAI / Anthropic / Ollama / Amazon Bedrock を +同じ画面のプルダウンで切り替えられる。単なるチャット UI ではなく、 + +- 会話が長くなったら勝手に要約して長期記憶に落とす +- 知らないことは自分で Web 検索して、その結果を次回以降も使える知識として溜める +- ユーザーと AI が同じノートを共同編集する +- Discord 側にも住んでいて、天気を知らせたり雑談に乗ったりする + +といった「動き続ける」部分を持つのが特徴。 + +## 機能サマリ + +| # | 機能 | 概要 | 主な実装 | +|---|---|---|---| +| 1 | チャット(SSE ストリーミング) | 逐次表示・Markdown 描画・セッション管理 | `api/chat.py` / `ChatWindow.vue` | +| 2 | セッションの書き出し / 取り込み | 会話を JSON でダウンロード・復元 | `api/chat.py` / `SessionList.vue` | +| 3 | 5 層コンテキスト管理 | 蓄積知識・ノート・ローカル KB・長期/短期メモリを毎回合成 | `core/memory_kb/memory.py` | +| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` | +| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` | +| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` | +| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` | +| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` | +| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` | +| 10 | Discord Bot | メンションに返信。天気・雑談キーワードに反応 | `core/discord/discord_bot.py` | +| 11 | 定期通知 | 毎日の洗濯天気通知と、定期的な「どうでもいい豆知識」 | `core/discord/scheduler.py` | +| 12 | 天気予報(気象庁 API) | 明日の天気・降水確率から洗濯指数を判定 | `core/weather/` / `Clock.vue` | +| 13 | モデル切替とプリセット管理 | 選択肢を DB で管理し画面から追加・編集・削除 | `api/models.py` / `SettingsPanel.vue` | +| 14 | GUI 設定画面 | API キー等を再起動なしで変更(許可リスト制・マスク表示) | `core/settings_store.py` | +| 15 | API ログビューア | LLM 呼び出しの成功/失敗・レイテンシ・リクエスト全文 | `api/logs.py` / `LogViewer.vue` | +| 16 | システムモニタ | CPU・メモリ・GPU 使用率を 10 秒ごとに表示 | `api/system.py` / `MemoryMonitor.vue` | +| 17 | サイト認証 | 共有パスワード 1 つでサイト全体を保護 | `core/site_auth.py` / `LoginGate.vue` | +| 18 | Docker 一式 | Caddy + nginx + FastAPI で HTTPS 公開 | `docker-compose.yml` / `Caddyfile` | + +--- + +## 1. チャット + +- `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。 + イベントは `session_id` / `chunk` / `sources` / `notes_updated` / `done` / `error` の 6 種。 +- アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは + 自動で補完してから描画するので、生成中でもレイアウトが崩れない。 +- セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。 +- Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。 +- 失敗時は赤いバブルとトースト通知の両方で知らせる。 + +## 2. セッションの書き出し / 取り込み + +- `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。 +- `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。 + サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。 +- 注意: 要約済みの会話は元メッセージが物理削除されているため、エクスポートしても生ログは戻らない。 + +## 3. コンテキスト管理(5 層) + +毎リクエスト `get_context_messages()` が以下の順に system メッセージを積み、最後に会話履歴を連ねる。 + +| 層 | 中身 | 量の制御 | +|---|---|---| +| 1 蓄積知識 | 過去の Web 検索結果の**見出し一覧のみ**(本文は `read_knowledge` で読む) | 最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件 | +| 2 共有ノート | ページのタイトル一覧+本文先頭 40 文字 | 全ページ(本文は入れない) | +| 3 ローカル KB | 選ばれた Markdown の**全文** | 類似度上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上 | +| 4 長期メモリ | 要約を全件連結 | 上限なし | +| 5 短期メモリ | セッション内の全メッセージ | 上限なし(要約で間接的に制御) | + +重い情報(ノート本文・蓄積知識の本文・Web 検索)は目次だけを渡し、本体はツール(`read_note` / `write_note` / `read_knowledge` / `web_search`)で +取りに行かせる遅延読み込み方式。`llm.resolve_tool_calls()` が本番生成の前に最大 2 ラウンドでこれを解決する。 + +## 4. 自動要約による長期記憶 + +- メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して + `memories` に 1 行として保存する。 +- **要約した元メッセージは DB から物理削除される**(復元不可)。 +- 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。 +- 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。 + +## 5. Web 検索と知識の蓄積 + +- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。 +- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。 + - `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く + - `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる +- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。 + +## 6. ローカル Markdown ナレッジベース + +- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。 +- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に + 内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。 +- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。 + +## 7. 共有ノート + +- 1 ページ=1 タブ。作成・改名・削除・本文編集(入力が止まると自動保存)ができる。 +- **セクション**でページをグループ化でき、同じセクションのタブは常に隣り合って表示される。 +- タブは**ドラッグ&ドロップ**で並べ替えでき、他セクションのタブにドロップすればそのまま移動になる。 +- AI 側はタイトル一覧だけを常に見ており、`read_note` で本文を読み、`write_note` で追記/全置換/新規作成する。 + AI が書き換えると SSE の `notes_updated` が飛び、開いているノートパネルが自動で最新化される。 +- 自分が編集中(未保存)のページは AI の更新で上書きされない。 + +## 8-9. 自動タグ付けとワードプール + +- 各メッセージと各要約に、バックグラウンドでタグが最大 3 つ付く(応答ストリームはブロックしない)。 + タグはコンテキストには注入せず、Discord Bot が「近い話題」を選ぶために使う。 +- `POST /api/system/tags/backfill` で、過去の未タグ分をまとめて処理できる。 +- ワードプールは Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持するもの。 + **DB に永続化されず再起動で消える**が、起動時に直近 200 件のメッセージから再シードされる。 + サイドバーに出現回数に応じた大きさのタグクラウドとして表示される。 + +## 10-11. Discord Bot と定期通知 + +- Bot はメンションされたときだけ反応する。本文に天気系キーワード(天気・洗濯・雨など)が含まれれば + 気象庁データを添えて回答し、雑談系キーワード(どうでもいい・暇など)なら発言からタグを起こして + 関連する話題の豆知識を即座に投げ返す。 +- APScheduler による定期ジョブが 2 つ。 + - **洗濯天気通知**: 毎日 `WEATHER_NOTIFY_HOUR` 時(既定 22 時)に、翌日の天気と洗濯指数を配信 + - **どうでもいい豆知識**: `USELESS_TIPS_ENABLED=true` のときだけ登録され、`USELESS_TIPS_INTERVAL_HOURS`(既定 3 時間)ごとに実行。深夜 3 時台はスキップ +- どちらも `POST /api/system/weather-notify/test` / `POST /api/system/useless-tip/test` で即時実行して確認できる。 + +## 12. 天気予報 + +- 気象庁の無料 JSON API から明日の予報を取り、天気文言と降水確率から洗濯指数を判定する + (雨雪なら室内干し、降水確率 30% 以下なら外干し OK、それ以上は室内干し推奨)。 +- 取得した最新データはメモリ上にキャッシュされ、画面右上の時計の横にも表示される(`GET /api/system/weather/latest`)。 +- 対象地域は `WEATHER_AREA_CODE`(既定 120000=千葉県)と `WEATHER_AREA_KEYWORD`(既定「北西部」)で指定する。 + +## 13-14. モデル選択と設定画面 + +- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。 + 設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。 +- 設定画面では API キー類を**再起動なしで**変更できる。 + - 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 / + `OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`) + - 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い + - 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る + +## 15-16. ログビューアとシステムモニタ + +- LLM 呼び出しは成功・失敗どちらも `api_logs` に記録される(モデル・リクエスト全文・応答・ + レイテンシ・エラー)。ログパネルからセッション別/成功失敗別に絞り込み、個別削除ができる。 +- サイドバーの監視パネルは CPU 使用率・メモリ使用量を 10 秒ごとに更新する。 + `pynvml` が使える環境なら GPU メモリも表示する(無ければその欄が出ないだけ)。 + +## 17. 認証 + +パスワードが 2 種類あり、役割が違う。 + +| 変数 | 守る対象 | 方式 | +|---|---|---| +| `SITE_PASSWORD` | チャット含むサイト全体 | ログイン画面 → HMAC 署名付きセッション Cookie(既定 30 日) | +| `SETTINGS_PASSWORD` | 設定画面と モデルプリセットの更新系のみ | HTTP Basic(リクエストごとに送る) | + +- どちらも未設定時の挙動が逆なので注意。`SITE_PASSWORD` 未設定=**誰でも使える**、 + `SETTINGS_PASSWORD` 未設定=**設定画面は常に 403**(空パスワードでの突破を防ぐため)。 +- `SESSION_SECRET_KEY` を設定しないと起動ごとに鍵が作り直され、再起動のたびに全員ログインし直しになる。 +- ローカルの http 環境でログインが効かない場合のみ `SESSION_COOKIE_SECURE=false` にする。 + +## 18. デプロイ + +- `docker-compose.yml` に backend(FastAPI)/ frontend(nginx で静的配信+API リバースプロキシ)/ + caddy(Let's Encrypt 自動取得と HTTPS 終端)の 3 コンテナが定義済み。 +- SQLite と `knowledge_base` は Docker ボリュームに永続化される。 +- フロントは API をすべて相対パスで呼ぶため、同一オリジン配信なら CORS 設定は実質不要。 + +--- + +## 制約・注意点 + +このアプリを触るときに引っかかりやすい点。 + +- **1 応答あたり LLM 呼び出しは最低 2 回**。ツールを使わない会話でも `resolve_tool_calls()` が + フルコンテキストを 1 回投げてから本番生成に入る。 +- **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は + 会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。 +- **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。 +- **要約は不可逆**。要約対象になった元メッセージは DB から消える。 +- **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。 +- **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に + `PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。 +- **自動テストは無い**。`pytest` は dev 依存に入っているが `backend/tests/` は存在しない。 -- 2.54.0 From 2ee7238afbd622ff8c20e0b2c97b64f1e87b55c1 Mon Sep 17 00:00:00 2001 From: zukki259 Date: Thu, 20 Aug 2026 14:33:18 +0900 Subject: [PATCH 2/3] Archive summarized messages instead of deleting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要約で長期メモリに移したメッセージをDBから物理削除していたため、 一度要約されると人間も内容を確認できなくなっていた。削除をやめ、 messages に archived_at(要約に取り込まれた時刻)と memory_id (どの要約に取り込まれたか)を持たせる。 モデルからは従来どおり見えない: - get_context_messages() がアーカイブ済みを除外する - maybe_summarize() の閾値判定も現役メッセージだけを数える (ここを除外し忘れると件数が減らず再要約が繰り返される) - backfill_tags() もアーカイブ済みは対象外にする 人間からは確認できる: - GET /chat/sessions/{id}/messages がアーカイブ済みも archived_at 付きで返す。export/import もアーカイブ状態を保持する - チャット画面では半透明で表示し、最後のアーカイブ済みメッセージの 直後に「ここから先がモデルに渡る」区切り線を出す Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- backend/app/api/chat.py | 7 ++++- backend/app/core/memory_kb/memory.py | 30 ++++++++++++++++++--- backend/app/core/memory_kb/tagging.py | 8 +++++- backend/app/db/database.py | 2 ++ backend/app/db/models.py | 7 +++++ docs/api.md | 8 +++--- docs/architecture.md | 2 +- docs/features.md | 6 ++--- frontend/src/api/client.ts | 3 +++ frontend/src/components/ChatWindow.vue | 37 +++++++++++++++++++++++--- frontend/src/stores/chat.ts | 8 +++++- 12 files changed, 101 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b735e9..1317f5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte 4. **長期メモリ**: `memories` の要約を全件連結。上限なし 5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く) -**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは物理削除される**(復元不可)。 +**要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。 **遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。 diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 3462adc..0c63980 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -42,6 +42,8 @@ class MessageResponse(BaseModel): role: str content: str created_at: datetime + # 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。 + archived_at: datetime | None = None tags: list[str] = [] class Config: @@ -52,6 +54,7 @@ class ImportMessage(BaseModel): role: str content: str created_at: datetime | None = None + archived_at: datetime | None = None tags: list[str] = [] @@ -83,6 +86,7 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)): role=m.role, content=m.content, created_at=m.created_at, + archived_at=m.archived_at, tags=[t.tag for t in m.tags], ) for m in session.messages @@ -110,6 +114,7 @@ def export_session(session_id: str, db: DBSession = Depends(get_db)): "role": m.role, "content": m.content, "created_at": m.created_at.isoformat(), + "archived_at": m.archived_at.isoformat() if m.archived_at else None, "tags": [t.tag for t in m.tags], } for m in session.messages @@ -143,7 +148,7 @@ def import_session(req: ImportRequest, db: DBSession = Depends(get_db)): db.flush() for m in req.messages: - msg = Message(session_id=session.id, role=m.role, content=m.content) + msg = Message(session_id=session.id, role=m.role, content=m.content, archived_at=m.archived_at) if m.created_at: msg.created_at = m.created_at db.add(msg) diff --git a/backend/app/core/memory_kb/memory.py b/backend/app/core/memory_kb/memory.py index fca300a..25c8b25 100644 --- a/backend/app/core/memory_kb/memory.py +++ b/backend/app/core/memory_kb/memory.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from sqlalchemy.orm import Session as DBSession from app.db.models import Memory, Message, Session from app.core.config import MEMORY_SUMMARIZE_THRESHOLD @@ -11,7 +13,13 @@ from app.core import notes 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() + # アーカイブ済み(要約に取り込まれた)メッセージは、その内容が長期メモリ側に入っているため除外する。 + messages = ( + db.query(Message) + .filter(Message.session_id == session_id, Message.archived_at.is_(None)) + .order_by(Message.created_at) + .all() + ) context: list[dict] = [] @@ -42,8 +50,18 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]: 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() + """メッセージ数が閾値を超えたら古いメッセージを要約してMemoryに保存し、元メッセージをアーカイブする。 + + アーカイブしたメッセージはLLMに渡すコンテキストからは外れるが、人間が後から履歴を + 確認できるようDBには残す(物理削除はしない)。閾値の判定もアーカイブ済みを除いた + 件数で行うため、一度要約した分が再び対象になることはない。 + """ + messages = ( + db.query(Message) + .filter(Message.session_id == session.id, Message.archived_at.is_(None)) + .order_by(Message.created_at) + .all() + ) if len(messages) < MEMORY_SUMMARIZE_THRESHOLD: return @@ -60,8 +78,12 @@ async def maybe_summarize(db: DBSession, session: Session) -> None: memory = Memory(session_id=session.id, summary=summary_text) db.add(memory) + db.flush() # どの要約に取り込まれたかをメッセージ側に持たせるためIDを確定させる + + archived_at = datetime.now(timezone.utc) for msg in to_summarize: - db.delete(msg) + msg.archived_at = archived_at + msg.memory_id = memory.id db.commit() db.refresh(memory) diff --git a/backend/app/core/memory_kb/tagging.py b/backend/app/core/memory_kb/tagging.py index 022b207..1277821 100644 --- a/backend/app/core/memory_kb/tagging.py +++ b/backend/app/core/memory_kb/tagging.py @@ -185,10 +185,16 @@ async def backfill_tags(db: DBSession, model: str | None = None, limit: int | No 単体スクリプト・APIエンドポイントの両方から呼べるようにここに切り出している。 戻り値: タグ付けしたメッセージ数 """ + # アーカイブ済み(要約に取り込まれた)メッセージは対象外。話題選びには要約側のタグを使うため、 + # 過去ログ全体にLLMを走らせる意味が薄い。 query = ( db.query(Message) .outerjoin(MessageTag, MessageTag.message_id == Message.id) - .filter(Message.role.in_(["user", "assistant"]), MessageTag.id.is_(None)) + .filter( + Message.role.in_(["user", "assistant"]), + MessageTag.id.is_(None), + Message.archived_at.is_(None), + ) .order_by(Message.created_at.asc()) ) if limit: diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 12bc486..cb4b127 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -22,6 +22,8 @@ def get_db(): _ADDED_COLUMNS = [ ("note_pages", "section", "VARCHAR"), ("knowledge_entries", "headline", "VARCHAR"), + ("messages", "archived_at", "DATETIME"), + ("messages", "memory_id", "INTEGER"), ] diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 24d044e..451b7b1 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -26,6 +26,13 @@ class Message(Base): role: Mapped[str] = mapped_column(String) # "user" | "assistant" | "system" content: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc)) + # 要約されて長期メモリに移ったメッセージ。LLMに渡すコンテキストからは外れるが、 + # 人間が後から履歴を確認できるようDBには残す(app.core.memory_kb.memory.maybe_summarize を参照)。 + archived_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True) + # どの要約に取り込まれたか。要約を削除してもメッセージ自体は残す(SET NULL)。 + memory_id: Mapped[int | None] = mapped_column( + ForeignKey("memories.id", ondelete="SET NULL"), nullable=True, + ) session: Mapped["Session"] = relationship(back_populates="messages") tags: Mapped[list["MessageTag"]] = relationship(back_populates="message", cascade="all, delete-orphan") diff --git a/docs/api.md b/docs/api.md index 386df5b..ffcb827 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,13 +48,15 @@ SSE でストリーミング応答を返す。 --- ### GET /chat/sessions/{session_id}/messages -セッションのメッセージ一覧を `created_at` 昇順で返す。 +セッションのメッセージ一覧を `created_at` 昇順で返す。要約に取り込まれた(アーカイブ済みの) +メッセージも含めて返し、`archived_at` で区別する。アーカイブ済みのものは LLM に渡す +コンテキストには入らないが、履歴としては残っている。 **レスポンス** ```json [ - { "id": 1, "role": "user", "content": "...", "created_at": "..." }, - { "id": 2, "role": "assistant", "content": "...", "created_at": "..." } + { "id": 1, "role": "user", "content": "...", "created_at": "...", "archived_at": "2026-08-20T04:00:00+00:00", "tags": [] }, + { "id": 2, "role": "assistant", "content": "...", "created_at": "...", "archived_at": null, "tags": [] } ] ``` diff --git a/docs/architecture.md b/docs/architecture.md index dce5a87..4407178 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag 1. メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になったら発火 2. 古い側の半分(既定 10 件)を LLM で要約し `memories` に 1 行保存 -3. **要約した元メッセージは DB から物理削除**する(復元不可。エクスポートしても生ログは残らない) +3. **要約した元メッセージは `archived_at` / `memory_id` を立ててアーカイブ**する(物理削除はしない)。LLM に渡すコンテキストからも、次回以降の要約対象からも外れるが、DB には残るため履歴表示・エクスポートからは読める 4. 保存した要約に `tagging.tag_memory()` でタグを付ける #### ツール経由の遅延読み込み diff --git a/docs/features.md b/docs/features.md index 64ad8af..bd7030d 100644 --- a/docs/features.md +++ b/docs/features.md @@ -58,7 +58,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - `GET /chat/sessions/{id}/export` がセッションのメタ情報と全メッセージ(タグ込み)を JSON で返す。 - `POST /chat/sessions/import` は同じ形式の JSON から**新しいセッションを作成**する(上書きではない)。 サイドバーの ⭱ ボタンからファイルを選ぶだけで取り込める。 -- 注意: 要約済みの会話は元メッセージが物理削除されているため、エクスポートしても生ログは戻らない。 +- 要約済み(アーカイブ済み)のメッセージも `archived_at` 付きでそのまま書き出され、取り込み時もアーカイブ状態を保つ。 ## 3. コンテキスト管理(5 層) @@ -79,7 +79,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)を超えると、古い側の半分を LLM が要約して `memories` に 1 行として保存する。 -- **要約した元メッセージは DB から物理削除される**(復元不可)。 +- **要約した元メッセージはアーカイブされる**(物理削除はしない)。LLM に渡るコンテキストからは外れるが、チャット画面では薄く表示され、区切り線から先だけがモデルに渡ることが分かるようになっている。 - 保存した要約にもタグが付き、Discord Bot の話題選びの材料になる。 - 溜まった要約は `GET /memory/{session_id}` で確認、`DELETE /memory/{id}` で個別削除できる(API のみ。専用の画面は無い)。 @@ -182,7 +182,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は - **トークン数ベースの制御が一切ない**。すべて件数・文字数ベースなので、層 3(Markdown 全文 ×3)は 会話が短くてもコンテキストを大きく押し上げる(層 1 は見出しのみなので軽い)。 - **長期メモリに上限がない**。長いセッションでは要約が積み上がり続ける(要約の再要約はしない)。 -- **要約は不可逆**。要約対象になった元メッセージは DB から消える。 +- **要約自体は不可逆**。アーカイブされた元メッセージは残るが、要約をやり直したり元の状態に戻す手段は無い。 - **ワードプールは揮発性**。再起動でリセットされ、直近 200 件から作り直される。 - **DB マイグレーションは簡易実装**。Alembic は依存に入っているが使っておらず、起動時に `PRAGMA table_info` を見て不足カラムを `ALTER TABLE` で足すだけ(SQLite 前提)。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3daf3e6..a1bcade 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -51,6 +51,8 @@ export interface MessageRecord { role: 'user' | 'assistant' | 'system' content: string created_at: string + // 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている + archived_at: string | null tags: string[] } @@ -83,6 +85,7 @@ export interface ImportPayload { role: string content: string created_at?: string | null + archived_at?: string | null tags?: string[] }[] } diff --git a/frontend/src/components/ChatWindow.vue b/frontend/src/components/ChatWindow.vue index 93df71f..b71ca52 100644 --- a/frontend/src/components/ChatWindow.vue +++ b/frontend/src/components/ChatWindow.vue @@ -1,5 +1,5 @@