Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae997914c2 | ||
|
|
caeeb5e921 | ||
|
|
c1aba42cf8 | ||
|
|
3b30dab650 | ||
|
|
1b88d23f21 | ||
|
|
904458e7df | ||
|
|
97568ba90e | ||
|
|
52dfc0c349 | ||
|
|
cd38cbf19d | ||
|
|
2ced2e3cd5 |
@@ -77,7 +77,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
|
1. **蓄積知識** (`memory_kb/knowledge.py`): 過去の Web 検索で得た `knowledge_entries` の**見出し一覧のみ**(`- 「クエリ」: 一行要約`)。本文と出典は含めず、必要なら `read_knowledge` ツールで読み出す。最新 `KNOWLEDGE_CONTEXT_LIMIT`(既定 15)件。セッションを横断する
|
||||||
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
|
2. **共有ノート** (`core/notes.py`): タイトル一覧+本文先頭 40 文字のプレビューのみ
|
||||||
2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない
|
2.5. **参照指定ノート** (`core/notes.py`): ユーザーがそのセッションに紐付けた(GUI のタブで 📌 を押した)ページの**本文全文**。`session_note_pages` テーブルで管理し、1 ページ `SESSION_NOTES_CONTENT_LIMIT`(既定 8000)文字で打ち切る。層 2 の一覧側には印が付き、同じ本文を `read_note` で読み直させない
|
||||||
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用)
|
3. **ローカル KB** (`memory_kb/local_knowledge.py`): `backend/knowledge_base/**.md` のうち、直近ユーザー発言と埋め込みコサイン類似度が高い上位 `LOCAL_KB_TOP_K`(既定 3)件かつ `LOCAL_KB_MIN_SCORE`(既定 0.75)以上を**全文**注入。プロンプト上は Web 検索結果より優先する扱い。中身は GUI ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`api/knowledge_base.py` + `KnowledgeBasePanel.vue`、`GET /api/kb/files` と `GET /api/kb/file?path=`)から閲覧できる(読み取り専用)。同パネルで埋め込みの状態確認・接続テスト・再埋め込みもできる(`GET /api/kb/embedding`、`POST /api/kb/embedding/test`、`POST /api/kb/embedding/reindex`)。埋め込みは `local_knowledge_docs.embedding_model` に使用モデルを持ち、モデルを変えた行は次の同期で埋め直される
|
||||||
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
|
4. **長期メモリ**: `memories` の要約を全件連結。上限なし
|
||||||
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
|
5. **短期メモリ**: セッション内の `messages` 全件(要約で削除された分を除く)
|
||||||
|
|
||||||
@@ -103,6 +103,14 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる
|
- **ダウンロード**: `GET /api/notes/{page_id}/export` が 1 ページを Markdown、`GET /api/notes/export` が全ページを ZIP(1 ページ 1 Markdown、セクションはフォルダ、ファイル名の通し番号で `position` 順を保持)で返す。ファイル名はサーバーが決めるのでフロントは `download` 属性を付けず `Content-Disposition`(日本語タイトルのため RFC 5987 を併記)に任せる
|
||||||
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
- **セクションによるグルーピング**: `note_pages.section` にセクション名を持たせ、同じセクションのページは `position` 上で常に連続させる(`core/notes.set_section()` が割り当て時に並べ替える)。タブバーは `position` 順に描画し、隣接して同じ `section` が続く区間を1グループとして表示する。`get_notes_context()` はセクションごとに `## セクション名` の見出しを付けてタイトル一覧を出す。セクション名の一括変更は `POST /api/notes/sections/rename`(`core/notes.rename_section()`)
|
||||||
|
|
||||||
|
### Discord Bot
|
||||||
|
|
||||||
|
`main.py` の lifespan が Bot(メンション応答)と APScheduler(定期ジョブ)を起動する。詳細は `docs/architecture.md`。
|
||||||
|
|
||||||
|
- **メンション応答** (`discord/discord_bot.py`): 本文のキーワードで **リマインド → 雑談 → 天気** の順に分岐し、先にマッチしたものが返信する
|
||||||
|
- **定期ジョブ** (`discord/scheduler.py`): 天気通知(Cron)・どうでもいいTips(Interval)・リマインド配信(Interval)の3つ
|
||||||
|
- **リマインド** (`discord/reminders.py`): メンションで自然言語登録する**一回きり**の通知(繰り返し非対応)。LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の3ツールを渡し、system メッセージに入れた現在時刻を基準に相対表現を絶対日時へ直させる。**tool calling 対応モデルが必須**。配信は Webhook ではなく Bot クライアントから登録元チャンネルへ依頼者をメンションして送るので、専用の Webhook URL 設定は不要。DB (`reminders`) は UTC で持ち、ユーザー・LLM とのやり取りはローカル時刻に直す。一覧・取り消しは依頼者の user_id で絞るため他人のリマインドには触れられない
|
||||||
|
|
||||||
### SSE ストリーミング
|
### SSE ストリーミング
|
||||||
|
|
||||||
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。
|
||||||
@@ -136,16 +144,18 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
|
| `DATABASE_URL` | `sqlite:///./playchat.db` | DB 接続先 |
|
||||||
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
|
| `MEMORY_SUMMARIZE_THRESHOLD` | `20` | 長期メモリ化するメッセージ数の閾値 |
|
||||||
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
|
| `KNOWLEDGE_CONTEXT_LIMIT` | `15` | 蓄積知識をシステムプロンプトに注入する最大件数 |
|
||||||
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す) |
|
| `TAVILY_API_KEY` | — | Tavily Web 検索の API キー(未設定なら `web_search` は空を返す。GUI 設定画面から実行時変更可) |
|
||||||
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`) |
|
| `SEARCH_SUMMARY_MODEL` | — | Web 検索結果の要約に使うモデル(未設定なら `DEFAULT_MODEL`。GUI 設定画面から実行時変更可) |
|
||||||
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
|
| `TAGGING_MODEL` | — | メッセージの自動タグ付けに使うモデル(未設定なら `DEFAULT_MODEL`) |
|
||||||
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
|
| `LOCAL_KB_DIR` | `knowledge_base` | ローカル Markdown ナレッジベースのディレクトリ |
|
||||||
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可) |
|
| `LOCAL_KB_EMBEDDING_MODEL` | `text-embedding-3-small` | ローカル KB の埋め込みモデル(GUI 設定画面から実行時変更可。変更すると全件が自動で埋め直される) |
|
||||||
| `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 |
|
| `LOCAL_KB_TOP_K` | `3` | ローカル KB から注入する最大ファイル数 |
|
||||||
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
|
| `LOCAL_KB_MIN_SCORE` | `0.75` | ローカル KB を注入するコサイン類似度の下限 |
|
||||||
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
|
| `CORS_ORIGINS` | `http://localhost:5173` | 許可する CORS オリジン |
|
||||||
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
|
| `DEBUG` | `false` | LiteLLM デバッグログの有効化 |
|
||||||
|
| `LOG_LEVEL` | `INFO` | アプリ側ロガーの出力レベル。`main.py` の `basicConfig` に渡す |
|
||||||
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
| `OPENAI_API_KEY` | — | OpenAI API キー |
|
||||||
|
| `OPENAI_API_BASE` / `OPENAI_BASE_URL` | — | OpenAI 互換エンドポイントの接続先(ローカルの llama-server / Ollama 用)。設定したままだと `gpt-*` を選んでも本家 OpenAI に繋がらない。GUI 設定画面から実行時変更可 |
|
||||||
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
| `ANTHROPIC_API_KEY` | — | Anthropic API キー |
|
||||||
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
|
||||||
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
| `NOTES_TOOL_CONTENT_LIMIT` | `4000` | `read_note` が 1 回に返す本文の最大文字数 |
|
||||||
@@ -153,5 +163,17 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte
|
|||||||
| `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 |
|
| `NOTES_IMPORT_MAX_PAGES` | `100` | ノート取り込みで一度に作れるページ数の上限 |
|
||||||
| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
|
| `NOTES_IMPORT_MAX_BYTES` | `2000000` | 取り込む 1 ファイル(ZIP 内は 1 エントリ)あたりの最大バイト数 |
|
||||||
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
|
| `KNOWLEDGE_TOOL_CONTENT_LIMIT` | `4000` | `read_knowledge` が 1 回に返す本文の最大文字数 |
|
||||||
|
| `REMINDER_CHECK_INTERVAL_SECONDS` | `30` | 期限切れリマインドを探す間隔(秒) |
|
||||||
|
| `REMINDER_MAX_AHEAD_DAYS` | `365` | これより先の日時のリマインドは登録を拒否する |
|
||||||
|
| `REMINDER_LIST_LIMIT` | `20` | `list_reminders` が一度に返す件数 |
|
||||||
|
| `REMINDER_MODEL` | — | リマインドの日時解釈に使うモデル(未設定なら `WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL`。tool calling 対応が必須) |
|
||||||
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
|
||||||
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
|
||||||
|
|
||||||
|
## AWS Lightsail環境
|
||||||
|
|
||||||
|
以下のコマンドで、lightsail上の環境に入ることができる。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh lightsail-playchat
|
||||||
|
```
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
DEFAULT_MODEL=gpt-5.4
|
DEFAULT_MODEL=gpt-5.4
|
||||||
# OpenAI
|
# OpenAI
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-...
|
||||||
|
# OpenAI互換エンドポイントに向ける場合だけ設定する(ローカルの llama-server / Ollama など)。
|
||||||
|
# 設定したままにすると、gpt-* のモデルを選んでも接続先がそちらのままになり、
|
||||||
|
# 本家OpenAIには繋がらない(litellm は OPENAI_API_BASE、OpenAI SDK は OPENAI_BASE_URL を見る)。
|
||||||
|
# サーバーにデプロイする際はこの2行を消し忘れないこと。GUIの設定画面からも変更できる。
|
||||||
|
# OPENAI_API_BASE=http://localhost:11434/v1
|
||||||
|
# OPENAI_BASE_URL=http://localhost:11434/v1
|
||||||
# Anthropic
|
# Anthropic
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
|
# Ollama (ローカル) — APIキー不要、モデル名は ollama/llama3 形式で指定
|
||||||
@@ -67,7 +73,19 @@ USELESS_TIPS_MODEL=
|
|||||||
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
|
# 未設定ならDISCORD_WEBHOOK_URL(天気通知と同じ)を使う
|
||||||
DISCORD_WEBHOOK_URL_TIPS=
|
DISCORD_WEBHOOK_URL_TIPS=
|
||||||
|
|
||||||
|
# Discord Bot のリマインド機能(メンションで登録し、同じチャンネルへ通知する)
|
||||||
|
# 期限切れのリマインドを探しにいく間隔(秒)
|
||||||
|
REMINDER_CHECK_INTERVAL_SECONDS=30
|
||||||
|
# これより先の日時は登録を拒否する(日数)
|
||||||
|
REMINDER_MAX_AHEAD_DAYS=365
|
||||||
|
REMINDER_LIST_LIMIT=20
|
||||||
|
# 日時の解釈に使うモデル。tool calling 対応モデルを指定すること
|
||||||
|
# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバック)
|
||||||
|
REMINDER_MODEL=
|
||||||
|
|
||||||
# アプリ設定
|
# アプリ設定
|
||||||
DATABASE_URL=sqlite:///./playchat.db
|
DATABASE_URL=sqlite:///./playchat.db
|
||||||
MEMORY_SUMMARIZE_THRESHOLD=20
|
MEMORY_SUMMARIZE_THRESHOLD=20
|
||||||
CORS_ORIGINS=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
# アプリ側ログの出力レベル(DEBUG / INFO / WARNING / ERROR)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
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.memory_kb import local_knowledge
|
||||||
from app.core.memory_kb.local_knowledge import list_kb_files, read_kb_file
|
from app.core.memory_kb.local_knowledge import list_kb_files, read_kb_file
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import LocalKnowledgeDoc
|
from app.db.models import LocalKnowledgeDoc
|
||||||
@@ -41,10 +42,14 @@ class KbFileContent(BaseModel):
|
|||||||
@router.get("/files", response_model=list[KbFileInfo])
|
@router.get("/files", response_model=list[KbFileInfo])
|
||||||
def list_files(db: DBSession = Depends(get_db)):
|
def list_files(db: DBSession = Depends(get_db)):
|
||||||
"""ナレッジベース配下の Markdown を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
|
"""ナレッジベース配下の Markdown を一覧で返す(本文は含めず、先頭のプレビューのみ)。"""
|
||||||
|
# 「埋め込み済み」は現在のモデルで埋め込まれている行だけを指す(別モデルの行は埋め直し待ち)
|
||||||
|
current_model = local_knowledge.current_embedding_model()
|
||||||
indexed_paths = {
|
indexed_paths = {
|
||||||
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
|
# DB 側は OS 依存の区切り文字で保存されているため、比較用に posix 形式へ寄せる
|
||||||
path.replace("\\", "/")
|
path.replace("\\", "/")
|
||||||
for (path,) in db.query(LocalKnowledgeDoc.path).all()
|
for (path,) in db.query(LocalKnowledgeDoc.path)
|
||||||
|
.filter(LocalKnowledgeDoc.embedding_model == current_model)
|
||||||
|
.all()
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
KbFileInfo(**info, indexed=info["path"] in indexed_paths)
|
KbFileInfo(**info, indexed=info["path"] in indexed_paths)
|
||||||
@@ -59,3 +64,49 @@ def get_file(path: str = Query(..., description="ナレッジベースディレ
|
|||||||
if file is None:
|
if file is None:
|
||||||
raise HTTPException(status_code=404, detail="Knowledge base file not found")
|
raise HTTPException(status_code=404, detail="Knowledge base file not found")
|
||||||
return KbFileContent(**file)
|
return KbFileContent(**file)
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingStatus(BaseModel):
|
||||||
|
"""埋め込みモデルの現状。GUI の状態表示・進捗バーに使う。"""
|
||||||
|
model: str
|
||||||
|
# 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ None)
|
||||||
|
dimensions: int | None
|
||||||
|
file_count: int
|
||||||
|
indexed: int
|
||||||
|
# 別のモデルで埋め込まれていて埋め直しが必要な件数
|
||||||
|
stale: int
|
||||||
|
running: bool
|
||||||
|
total: int
|
||||||
|
done: int
|
||||||
|
failed: int
|
||||||
|
started_at: datetime | None
|
||||||
|
finished_at: datetime | None
|
||||||
|
last_error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingProbe(BaseModel):
|
||||||
|
"""短いテキストを実際に埋め込んでみた結果。"""
|
||||||
|
ok: bool
|
||||||
|
model: str
|
||||||
|
dimensions: int | None
|
||||||
|
latency_ms: float
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/embedding", response_model=EmbeddingStatus)
|
||||||
|
def get_embedding_status(db: DBSession = Depends(get_db)):
|
||||||
|
"""現在の埋め込みモデルと、埋め込み済み件数・実行中の進捗を返す。"""
|
||||||
|
return local_knowledge.embedding_status(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/embedding/test", response_model=EmbeddingProbe)
|
||||||
|
async def test_embedding():
|
||||||
|
"""埋め込みモデルに実際に1回リクエストして、応答するかどうかを確かめる。"""
|
||||||
|
return await local_knowledge.probe_embedding()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/embedding/reindex", response_model=EmbeddingStatus)
|
||||||
|
async def reindex_embeddings(db: DBSession = Depends(get_db)):
|
||||||
|
"""全ファイルの再埋め込みをバックグラウンドで開始する。実行中なら何もしない(進捗をそのまま返す)。"""
|
||||||
|
local_knowledge.start_reembed_all()
|
||||||
|
return local_knowledge.embedding_status(db)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
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 import settings_store
|
from app.core import settings_store
|
||||||
from app.core.auth import require_settings_password
|
from app.core.auth import require_settings_password
|
||||||
|
from app.core.memory_kb import local_knowledge
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
|
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_settings_password)])
|
||||||
@@ -12,7 +15,10 @@ router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depe
|
|||||||
class SettingEntry(BaseModel):
|
class SettingEntry(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
is_set: bool
|
is_set: bool
|
||||||
masked: str | None
|
# 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら None
|
||||||
|
display_value: str | None
|
||||||
|
# 入力欄をパスワード扱いにするか(APIキー等は True)
|
||||||
|
secret: bool
|
||||||
overridden: bool
|
overridden: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +32,8 @@ def list_settings(db: DBSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=list[SettingEntry])
|
@router.put("", response_model=list[SettingEntry])
|
||||||
def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
async def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
||||||
|
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
|
||||||
try:
|
try:
|
||||||
for key, value in req.values.items():
|
for key, value in req.values.items():
|
||||||
if not value:
|
if not value:
|
||||||
@@ -34,13 +41,29 @@ def save_settings(req: SaveSettingsRequest, db: DBSession = Depends(get_db)):
|
|||||||
settings_store.save(db, key, value)
|
settings_store.save(db, key, value)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
|
_reembed_if_model_changed(before_embedding_model)
|
||||||
return settings_store.list_masked(db)
|
return settings_store.list_masked(db)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key}", response_model=list[SettingEntry])
|
@router.delete("/{key}", response_model=list[SettingEntry])
|
||||||
def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
async def clear_setting(key: str, db: DBSession = Depends(get_db)):
|
||||||
|
before_embedding_model = os.environ.get("LOCAL_KB_EMBEDDING_MODEL")
|
||||||
try:
|
try:
|
||||||
settings_store.clear(db, key)
|
settings_store.clear(db, key)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
|
_reembed_if_model_changed(before_embedding_model)
|
||||||
return settings_store.list_masked(db)
|
return settings_store.list_masked(db)
|
||||||
|
|
||||||
|
|
||||||
|
def _reembed_if_model_changed(before: str | None) -> None:
|
||||||
|
"""埋め込みモデルが変わったら、ローカルKBの再埋め込みをバックグラウンドで走らせる。
|
||||||
|
|
||||||
|
モデルが違うと次元数も意味空間も変わり、古いベクトルは比較しても無意味なので、
|
||||||
|
設定を変えた時点で全件やり直す(完了を待たずに応答は返す)。
|
||||||
|
"""
|
||||||
|
if os.environ.get("LOCAL_KB_EMBEDDING_MODEL") == before:
|
||||||
|
return
|
||||||
|
local_knowledge.start_reembed_all()
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-4o")
|
|||||||
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
|
MEMORY_SUMMARIZE_THRESHOLD: int = int(os.getenv("MEMORY_SUMMARIZE_THRESHOLD", "20"))
|
||||||
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
CORS_ORIGINS: list[str] = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")
|
||||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
|
DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
|
||||||
|
# アプリ側ロガーの出力レベル。未設定だと logging.lastResort(WARNING以上・書式なし)に
|
||||||
|
# 落ちて logger.info が全部消えるため、main.py で明示的に設定する。
|
||||||
|
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
|
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
|
||||||
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
|
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
|
||||||
@@ -25,10 +28,12 @@ SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
|
|||||||
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
|
||||||
|
|
||||||
# Tavily Web検索
|
# Tavily Web検索
|
||||||
|
# GUI設定画面から実行時に変更できるため、実際の参照は search/websearch._api_key() が os.environ から行う。
|
||||||
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
|
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"))
|
||||||
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
|
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
|
||||||
|
# GUI設定画面から実行時に変更できるため、実際の参照は memory_kb/knowledge._summary_model() が os.environ から行う。
|
||||||
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
|
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
|
||||||
|
|
||||||
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
|
||||||
@@ -86,3 +91,14 @@ USELESS_TIPS_INTERVAL_HOURS: int = int(os.getenv("USELESS_TIPS_INTERVAL_HOURS",
|
|||||||
USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
|
USELESS_TIPS_MODEL: str | None = os.getenv("USELESS_TIPS_MODEL")
|
||||||
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
|
# 未設定なら天気通知と同じ DISCORD_WEBHOOK_URL を使う
|
||||||
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL")
|
DISCORD_WEBHOOK_URL_TIPS: str | None = os.getenv("DISCORD_WEBHOOK_URL_TIPS") or os.getenv("DISCORD_WEBHOOK_URL")
|
||||||
|
|
||||||
|
# Discord Bot のリマインド機能(メンションで登録し、時刻になったら同じチャンネルへ通知する)
|
||||||
|
# 期限切れのリマインドを探しにいく間隔。短いほど通知の遅れが減るがDBを引く回数は増える。
|
||||||
|
REMINDER_CHECK_INTERVAL_SECONDS: int = int(os.getenv("REMINDER_CHECK_INTERVAL_SECONDS", "30"))
|
||||||
|
# これより先の日時を指定されたら登録を拒否する(LLMの年の取り違えを弾くため)
|
||||||
|
REMINDER_MAX_AHEAD_DAYS: int = int(os.getenv("REMINDER_MAX_AHEAD_DAYS", "365"))
|
||||||
|
# list_reminders が一度に返す件数
|
||||||
|
REMINDER_LIST_LIMIT: int = int(os.getenv("REMINDER_LIST_LIMIT", "20"))
|
||||||
|
# リマインドの解釈に使うモデル。tool callingに対応したモデルを指定すること
|
||||||
|
# (未設定なら WEATHER_NOTIFY_MODEL → DEFAULT_MODEL の順にフォールバックする)
|
||||||
|
REMINDER_MODEL: str | None = os.getenv("REMINDER_MODEL")
|
||||||
|
|||||||
@@ -11,6 +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.discord.reminders import (
|
||||||
|
REMINDER_KEYWORDS,
|
||||||
|
ReminderContext,
|
||||||
|
handle_reminder_request,
|
||||||
|
)
|
||||||
from app.core.discord.scheduler import send_useless_tip
|
from app.core.discord.scheduler import send_useless_tip
|
||||||
from app.core.memory_kb.tagging import find_related_tag, generate_tags
|
from app.core.memory_kb.tagging import find_related_tag, generate_tags
|
||||||
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
||||||
@@ -67,6 +72,15 @@ async def on_message(message: discord.Message):
|
|||||||
|
|
||||||
async with message.channel.typing():
|
async with message.channel.typing():
|
||||||
try:
|
try:
|
||||||
|
if any(keyword in content for keyword in REMINDER_KEYWORDS):
|
||||||
|
ctx = ReminderContext(
|
||||||
|
channel_id=str(message.channel.id),
|
||||||
|
user_id=str(message.author.id),
|
||||||
|
user_name=message.author.display_name,
|
||||||
|
)
|
||||||
|
await message.reply(await handle_reminder_request(content, ctx))
|
||||||
|
return
|
||||||
|
|
||||||
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
|
if any(keyword in content for keyword in USELESS_TALK_KEYWORDS):
|
||||||
redirect = await _generate_useless_talk_redirect(content)
|
redirect = await _generate_useless_talk_redirect(content)
|
||||||
await message.reply(redirect)
|
await message.reply(redirect)
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""Discord Bot のリマインド機能。
|
||||||
|
|
||||||
|
メンション本文にリマインド系のキーワードが含まれていたとき、discord_bot.on_message が
|
||||||
|
handle_reminder_request() を呼ぶ。中では LLM にツール(create_reminder / list_reminders /
|
||||||
|
cancel_reminder)を渡して自然言語から日時と本文を取り出させ、reminders テーブルに保存する。
|
||||||
|
|
||||||
|
保存したリマインドは scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに
|
||||||
|
deliver_due_reminders() を呼ぶことで配信される。配信先は登録されたチャンネルで、
|
||||||
|
Webhook ではなく Bot クライアントから送るため追加の URL 設定は要らない。
|
||||||
|
|
||||||
|
日時の扱い: DB は他のテーブルと同じくUTC。ユーザーとLLMとのやり取りはすべて
|
||||||
|
このマシンのローカル時刻(JST想定)で行い、この境界で相互に変換する。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import discord
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
from app.core.config import (
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
REMINDER_LIST_LIMIT,
|
||||||
|
REMINDER_MAX_AHEAD_DAYS,
|
||||||
|
REMINDER_MODEL,
|
||||||
|
WEATHER_NOTIFY_MODEL,
|
||||||
|
)
|
||||||
|
from app.db.database import SessionLocal
|
||||||
|
from app.db.models import Reminder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# メンション本文にこれらが含まれるとリマインド処理に入る(discord_bot.on_message を参照)
|
||||||
|
REMINDER_KEYWORDS = (
|
||||||
|
"リマインド",
|
||||||
|
"リマインダ",
|
||||||
|
"remind",
|
||||||
|
"思い出させ",
|
||||||
|
"忘れないように",
|
||||||
|
"忘れずに",
|
||||||
|
"覚えといて",
|
||||||
|
"覚えておいて",
|
||||||
|
"アラーム",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 同じリマインドの配信にこの回数失敗したら諦める。チャンネル削除などで永久に
|
||||||
|
# 失敗し続けるものが、ポーリングのたびにエラーログを出すのを防ぐ。
|
||||||
|
_MAX_DELIVERY_ATTEMPTS = 5
|
||||||
|
# 配信に失敗した回数(プロセス内のみ。再起動すればまた試す)
|
||||||
|
_delivery_failures: dict[int, int] = {}
|
||||||
|
|
||||||
|
_AT_FORMATS = ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReminderContext:
|
||||||
|
"""リマインドを頼んできた相手。配信先チャンネルと、メンションする相手を決めるのに使う。"""
|
||||||
|
|
||||||
|
channel_id: str
|
||||||
|
user_id: str
|
||||||
|
user_name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _model() -> str:
|
||||||
|
return REMINDER_MODEL or WEATHER_NOTIFY_MODEL or DEFAULT_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
def _local_now() -> datetime:
|
||||||
|
"""このマシンのローカルタイムゾーンでの現在時刻(aware)。"""
|
||||||
|
return datetime.now().astimezone()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_local(value: datetime) -> datetime:
|
||||||
|
"""DBから読んだUTCの日時を、表示用にローカル時刻へ直す。"""
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(_local_now().tzinfo)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_at(text: str) -> datetime:
|
||||||
|
"""LLMが返した "YYYY-MM-DD HH:MM" をローカル時刻の aware な datetime にする。
|
||||||
|
|
||||||
|
解釈できない場合は ValueError を投げ、呼び出し側がその旨をツール結果としてLLMに返す。
|
||||||
|
"""
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
for fmt in _AT_FORMATS:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(cleaned, fmt).replace(tzinfo=_local_now().tzinfo)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"日時 '{text}' を解釈できませんでした")
|
||||||
|
|
||||||
|
|
||||||
|
CREATE_REMINDER_TOOL = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "create_reminder",
|
||||||
|
"description": "指定した日時にDiscordへ通知するリマインドを登録する。"
|
||||||
|
"ユーザーが『あとで教えて』『◯時になったら知らせて』などと頼んできたときに使う。"
|
||||||
|
"一回きりの通知のみで、繰り返しには対応していない。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"at": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "通知する日時。必ず 'YYYY-MM-DD HH:MM' 形式のローカル時刻で指定する。"
|
||||||
|
"『明日の朝9時』『30分後』のような相対表現は、システムメッセージで与えられた"
|
||||||
|
"現在時刻をもとに自分で計算して絶対日時に直すこと。",
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "通知するときに読み上げる用件。ユーザーの言葉をそのまま短くまとめる。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["at", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LIST_REMINDERS_TOOL = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_reminders",
|
||||||
|
"description": "このユーザーの未通知のリマインド一覧を取得する。"
|
||||||
|
"『リマインド何が入ってる?』のように予定を尋ねられたときや、"
|
||||||
|
"取り消し対象のIDを知りたいときに使う。",
|
||||||
|
"parameters": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CANCEL_REMINDER_TOOL = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "cancel_reminder",
|
||||||
|
"description": "登録済みのリマインドを取り消す。IDが分からない場合は先に list_reminders で調べること。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "integer", "description": "取り消すリマインドのID(list_reminders が返す番号)"},
|
||||||
|
},
|
||||||
|
"required": ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
REMINDER_TOOLS = [CREATE_REMINDER_TOOL, LIST_REMINDERS_TOOL, CANCEL_REMINDER_TOOL]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_reminder(db, args: dict, ctx: ReminderContext) -> str:
|
||||||
|
content = (args.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
return "エラー: 通知する用件が空です。ユーザーに何をリマインドすればよいか聞き返してください。"
|
||||||
|
|
||||||
|
try:
|
||||||
|
due_local = _parse_at(args.get("at", ""))
|
||||||
|
except ValueError as e:
|
||||||
|
return f"エラー: {e}。'YYYY-MM-DD HH:MM' の形式で指定し直してください。"
|
||||||
|
|
||||||
|
now_local = _local_now()
|
||||||
|
if due_local <= now_local:
|
||||||
|
return (
|
||||||
|
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は現在時刻 {now_local:%Y-%m-%d %H:%M} より過去です。"
|
||||||
|
"未来の日時を指定し直すか、ユーザーに確認してください。"
|
||||||
|
)
|
||||||
|
if due_local - now_local > timedelta(days=REMINDER_MAX_AHEAD_DAYS):
|
||||||
|
return (
|
||||||
|
f"エラー: 指定された {due_local:%Y-%m-%d %H:%M} は{REMINDER_MAX_AHEAD_DAYS}日以上先です。"
|
||||||
|
"年を取り違えていないか確認してください。"
|
||||||
|
)
|
||||||
|
|
||||||
|
reminder = Reminder(
|
||||||
|
channel_id=ctx.channel_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
user_name=ctx.user_name,
|
||||||
|
content=content,
|
||||||
|
due_at=due_local.astimezone(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(reminder)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(reminder)
|
||||||
|
|
||||||
|
logger.info("Reminder created: id=%s due=%s user=%s", reminder.id, due_local, ctx.user_name)
|
||||||
|
return (
|
||||||
|
f"リマインドを登録しました(ID {reminder.id})。"
|
||||||
|
f"{due_local:%Y-%m-%d %H:%M} に「{content}」を通知します。"
|
||||||
|
"ユーザーへの返事には、いつ何を通知するのかを必ず含めてください。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_reminders(db, ctx: ReminderContext) -> str:
|
||||||
|
rows = (
|
||||||
|
db.query(Reminder)
|
||||||
|
.filter(
|
||||||
|
Reminder.user_id == ctx.user_id,
|
||||||
|
Reminder.notified_at.is_(None),
|
||||||
|
Reminder.cancelled_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Reminder.due_at)
|
||||||
|
.limit(REMINDER_LIST_LIMIT)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return "このユーザーの未通知のリマインドはありません。"
|
||||||
|
|
||||||
|
lines = [f"ID {r.id}: {_to_local(r.due_at):%Y-%m-%d %H:%M} 「{r.content}」" for r in rows]
|
||||||
|
return "未通知のリマインド:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_reminder(db, args: dict, ctx: ReminderContext) -> str:
|
||||||
|
reminder_id = args.get("id")
|
||||||
|
reminder = (
|
||||||
|
db.query(Reminder)
|
||||||
|
.filter(
|
||||||
|
Reminder.id == reminder_id,
|
||||||
|
Reminder.user_id == ctx.user_id,
|
||||||
|
Reminder.notified_at.is_(None),
|
||||||
|
Reminder.cancelled_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if reminder is None:
|
||||||
|
return (
|
||||||
|
f"エラー: ID {reminder_id} の未通知のリマインドは見つかりませんでした。"
|
||||||
|
"list_reminders で確認してください。"
|
||||||
|
)
|
||||||
|
|
||||||
|
reminder.cancelled_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
logger.info("Reminder cancelled: id=%s user=%s", reminder_id, ctx.user_name)
|
||||||
|
return f"ID {reminder_id}「{reminder.content}」のリマインドを取り消しました。"
|
||||||
|
|
||||||
|
|
||||||
|
def execute_reminder_tool(db, name: str, args: dict, ctx: ReminderContext) -> str:
|
||||||
|
"""リマインド系ツールを実行し、LLMに返す結果テキストを作る。
|
||||||
|
|
||||||
|
一覧・取り消しは ctx.user_id で絞るため、他人のリマインドには触れられない。
|
||||||
|
"""
|
||||||
|
if name == "create_reminder":
|
||||||
|
return _create_reminder(db, args, ctx)
|
||||||
|
if name == "list_reminders":
|
||||||
|
return _list_reminders(db, ctx)
|
||||||
|
if name == "cancel_reminder":
|
||||||
|
return _cancel_reminder(db, args, ctx)
|
||||||
|
return f"エラー: 未知のツール {name}"
|
||||||
|
|
||||||
|
|
||||||
|
class _ToolFailure(Exception):
|
||||||
|
"""ツール実行そのものが失敗した(DBエラーなど)。モデルに投げ返さず即座に諦めるための印。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _run_tool(name: str, args: dict, ctx: ReminderContext) -> str:
|
||||||
|
"""ツール1回分だけDBセッションを開いて実行する。
|
||||||
|
|
||||||
|
LLMとの往復のあいだセッションを握り続けると、SQLite のロックを数秒〜数十秒
|
||||||
|
保持することになり、同時に走る API リクエストと競合して "database is locked"
|
||||||
|
を起こしうる。開いて閉じるのをツール単位にすることでそれを避ける。
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
return execute_reminder_tool(db, name, args, ctx)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Reminder tool %s failed (args=%r)", name, args)
|
||||||
|
db.rollback()
|
||||||
|
raise _ToolFailure(name) from e
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _system_prompt() -> str:
|
||||||
|
now = _local_now()
|
||||||
|
weekday = "月火水木金土日"[now.weekday()]
|
||||||
|
return (
|
||||||
|
"あなたはDiscordでリマインドを預かるアシスタントです。"
|
||||||
|
"ユーザーの依頼に応じて create_reminder / list_reminders / cancel_reminder ツールを使ってください。\n"
|
||||||
|
f"現在の日時は {now:%Y-%m-%d %H:%M}({weekday}曜日)です。"
|
||||||
|
"『明日』『30分後』『今週末』などの相対表現は、この現在時刻を基準に絶対日時へ変換すること。\n"
|
||||||
|
"時刻が指定されていない『明日』のような依頼は朝9時と解釈してかまいませんが、"
|
||||||
|
"その解釈を返事の中で明示してください。\n"
|
||||||
|
"ツールを実行したら、その結果をもとに1〜2文の短い返事を書いてください。"
|
||||||
|
"登録した場合は「いつ」「何を」通知するかを必ず含め、絵文字を1つ程度使った親しみやすい口調にします。"
|
||||||
|
"出力は返事の本文のみで、ツール名やIDの内部的な説明は不要です。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_reminder_request(content: str, ctx: ReminderContext, max_rounds: int = 3) -> str:
|
||||||
|
"""リマインド依頼をLLMに解釈させ、ツールを実行し、ユーザーへの返事を返す。
|
||||||
|
|
||||||
|
ツール呼び出しに対応していないモデルや、LLM呼び出し自体が失敗した場合は
|
||||||
|
その旨の固定文を返す(Botが無言になるのを避ける)。
|
||||||
|
"""
|
||||||
|
messages: list[dict] = [
|
||||||
|
{"role": "system", "content": _system_prompt()},
|
||||||
|
{"role": "user", "content": content},
|
||||||
|
]
|
||||||
|
used_model = _model()
|
||||||
|
logger.info("Reminder request from %s [model=%s]: %s", ctx.user_name, used_model, content)
|
||||||
|
|
||||||
|
for round_no in range(1, max_rounds + 1):
|
||||||
|
try:
|
||||||
|
# tool_choice は渡さない。Bedrock が非対応で UnsupportedParamsError になり、
|
||||||
|
# それを握り潰しているのは app.core.llm の litellm.drop_params=True という
|
||||||
|
# 別モジュールの副作用に過ぎない。tools を渡したときの既定が "auto" なので
|
||||||
|
# 省いても挙動は変わらず、その隠れた依存だけが消える。
|
||||||
|
response = await litellm.acompletion(
|
||||||
|
model=used_model,
|
||||||
|
messages=messages,
|
||||||
|
tools=REMINDER_TOOLS,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Reminder LLM call failed [model=%s round=%d]", used_model, round_no)
|
||||||
|
return "⚠️ リマインドの登録に失敗しました。時間を変えてもう一度試してみてください。"
|
||||||
|
|
||||||
|
# tool calling 非対応のエンドポイントは、例外を投げずに choices が空だったり
|
||||||
|
# 形の違う応答を返すことがある。ここで落ちると原因が分からなくなるので個別に捕まえる。
|
||||||
|
try:
|
||||||
|
choice_message = response.choices[0].message
|
||||||
|
except (AttributeError, IndexError, KeyError, TypeError):
|
||||||
|
logger.exception(
|
||||||
|
"Unexpected completion shape [model=%s round=%d]: %r", used_model, round_no, response,
|
||||||
|
)
|
||||||
|
return "⚠️ モデルからの応答を解釈できませんでした。tool calling に対応したモデルか確認してください。"
|
||||||
|
|
||||||
|
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||||
|
if not tool_calls:
|
||||||
|
reply = (choice_message.content or "").strip()
|
||||||
|
logger.info("Reminder request finished without tools [round=%d]", round_no)
|
||||||
|
return reply or "⚠️ リマインドの内容をうまく読み取れませんでした。日時と用件を教えてください。"
|
||||||
|
|
||||||
|
messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": choice_message.content or "",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": tc.id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
||||||
|
}
|
||||||
|
for tc in tool_calls
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
for tc in tool_calls:
|
||||||
|
try:
|
||||||
|
args = json.loads(tc.function.arguments or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
try:
|
||||||
|
result = _run_tool(tc.function.name, args, ctx)
|
||||||
|
except _ToolFailure:
|
||||||
|
return "⚠️ リマインドの保存に失敗しました(データベースエラー)。サーバーのログを確認してください。"
|
||||||
|
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
|
||||||
|
|
||||||
|
# max_rounds 使い切ってもツールを呼び続けている=堂々巡り。登録自体は済んでいる可能性がある。
|
||||||
|
logger.warning("Reminder tool loop exceeded %d rounds [model=%s]", max_rounds, used_model)
|
||||||
|
return "⚠️ うまく処理しきれませんでした。登録できているかは「リマインド一覧」と聞いて確認してください。"
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_due_reminders() -> None:
|
||||||
|
"""期限を過ぎた未通知のリマインドを、登録元のチャンネルへ配信する。
|
||||||
|
|
||||||
|
scheduler が REMINDER_CHECK_INTERVAL_SECONDS ごとに呼ぶ。Bot がまだ起動しきって
|
||||||
|
いない間は何もしないので、バックエンド停止中に期限を迎えたぶんは起動後にまとめて届く。
|
||||||
|
"""
|
||||||
|
# discord_bot が scheduler 経由でこのモジュールを読むため、モジュールレベルで
|
||||||
|
# import すると循環 import になる。
|
||||||
|
from app.core.discord.discord_bot import client
|
||||||
|
|
||||||
|
if not client.is_ready():
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
due = (
|
||||||
|
db.query(Reminder)
|
||||||
|
.filter(
|
||||||
|
Reminder.notified_at.is_(None),
|
||||||
|
Reminder.cancelled_at.is_(None),
|
||||||
|
Reminder.due_at <= datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
.order_by(Reminder.due_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for reminder in due:
|
||||||
|
try:
|
||||||
|
channel = client.get_channel(int(reminder.channel_id))
|
||||||
|
if channel is None:
|
||||||
|
channel = await client.fetch_channel(int(reminder.channel_id))
|
||||||
|
# カテゴリやフォーラムそのものには投稿できない(登録時のチャンネルが
|
||||||
|
# 別種のものに作り替えられた場合に起きうる)ので、送れる相手かを確かめる。
|
||||||
|
if not isinstance(channel, discord.abc.Messageable):
|
||||||
|
raise TypeError(f"channel {reminder.channel_id} is not messageable")
|
||||||
|
await channel.send(f"⏰ <@{reminder.user_id}> リマインドの時間です!\n> {reminder.content}")
|
||||||
|
except Exception:
|
||||||
|
failures = _delivery_failures.get(reminder.id, 0) + 1
|
||||||
|
_delivery_failures[reminder.id] = failures
|
||||||
|
if failures < _MAX_DELIVERY_ATTEMPTS:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to deliver reminder id=%s (attempt %d/%d)",
|
||||||
|
reminder.id, failures, _MAX_DELIVERY_ATTEMPTS,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
logger.error("Giving up on reminder id=%s after %d attempts", reminder.id, failures)
|
||||||
|
|
||||||
|
_delivery_failures.pop(reminder.id, None)
|
||||||
|
reminder.notified_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process due reminders")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|||||||
from app.core.config import (
|
from app.core.config import (
|
||||||
DISCORD_WEBHOOK_URL,
|
DISCORD_WEBHOOK_URL,
|
||||||
DISCORD_WEBHOOK_URL_TIPS,
|
DISCORD_WEBHOOK_URL_TIPS,
|
||||||
|
REMINDER_CHECK_INTERVAL_SECONDS,
|
||||||
USELESS_TIPS_ENABLED,
|
USELESS_TIPS_ENABLED,
|
||||||
USELESS_TIPS_INTERVAL_HOURS,
|
USELESS_TIPS_INTERVAL_HOURS,
|
||||||
USELESS_TIPS_MODEL,
|
USELESS_TIPS_MODEL,
|
||||||
@@ -21,6 +22,7 @@ from app.core.config import (
|
|||||||
)
|
)
|
||||||
from app.core.llm import complete
|
from app.core.llm import complete
|
||||||
from app.core.discord.notify import send_discord_message
|
from app.core.discord.notify import send_discord_message
|
||||||
|
from app.core.discord.reminders import deliver_due_reminders
|
||||||
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
from app.core.weather.weather import fetch_tomorrow_weather, judge_laundry
|
||||||
from app.core.weather.weather_cache import set_latest
|
from app.core.weather.weather_cache import set_latest
|
||||||
from app.core.memory_kb.word_memory import pick_random_word
|
from app.core.memory_kb.word_memory import pick_random_word
|
||||||
@@ -144,4 +146,12 @@ def start_scheduler() -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS)
|
logger.info("Useless tips job started: every %d hour(s)", USELESS_TIPS_INTERVAL_HOURS)
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
deliver_due_reminders,
|
||||||
|
trigger=IntervalTrigger(seconds=REMINDER_CHECK_INTERVAL_SECONDS),
|
||||||
|
id="discord_reminders",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
logger.info("Reminder delivery job started: every %d second(s)", REMINDER_CHECK_INTERVAL_SECONDS)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -10,7 +11,6 @@ from app.core import llm
|
|||||||
from app.core.config import (
|
from app.core.config import (
|
||||||
KNOWLEDGE_CONTEXT_LIMIT,
|
KNOWLEDGE_CONTEXT_LIMIT,
|
||||||
KNOWLEDGE_TOOL_CONTENT_LIMIT,
|
KNOWLEDGE_TOOL_CONTENT_LIMIT,
|
||||||
SEARCH_SUMMARY_MODEL,
|
|
||||||
)
|
)
|
||||||
from app.core.memory_kb import local_knowledge
|
from app.core.memory_kb import local_knowledge
|
||||||
from app.db.models import KnowledgeEntry, KnowledgeSource
|
from app.db.models import KnowledgeEntry, KnowledgeSource
|
||||||
@@ -37,6 +37,14 @@ _SUMMARY_PROMPT = (
|
|||||||
_HEADLINE_MARKERS = ("要約:", "要約:")
|
_HEADLINE_MARKERS = ("要約:", "要約:")
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_model() -> str | None:
|
||||||
|
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
|
||||||
|
|
||||||
|
未設定(空文字含む)なら None を返し、llm.complete() 側で DEFAULT_MODEL にフォールバックさせる。
|
||||||
|
"""
|
||||||
|
return os.environ.get("SEARCH_SUMMARY_MODEL") or None
|
||||||
|
|
||||||
|
|
||||||
def _split_headline(text: str) -> tuple[str, str]:
|
def _split_headline(text: str) -> tuple[str, str]:
|
||||||
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
|
"""LLMの出力を (一行要約, 本文) に分ける。1行目が想定の形式でなければ見出しは空で返す。"""
|
||||||
lines = text.strip().splitlines()
|
lines = text.strip().splitlines()
|
||||||
@@ -87,7 +95,7 @@ async def summarize_results(query: str, results: list[dict], model: str | None =
|
|||||||
try:
|
try:
|
||||||
text = await llm.complete(
|
text = await llm.complete(
|
||||||
[{"role": "user", "content": prompt}],
|
[{"role": "user", "content": prompt}],
|
||||||
model=model or SEARCH_SUMMARY_MODEL,
|
model=model or _summary_model(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to summarize search results for query=%r", query)
|
logger.exception("Failed to summarize search results for query=%r", query)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
|
- sync_local_kb(): ディレクトリをスキャンし、新規/変更されたファイルだけ埋め込みを計算してDBに保存する
|
||||||
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
|
- get_local_kb_context(): 直近のユーザー発言に埋め込み類似度の高いファイルを探し、システムプロンプト用テキストにする
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,6 +21,46 @@ from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 埋め込みの進行状況。GUIに出すためだけのプロセス内の状態で、永続化はしない
|
||||||
|
# (uvicornを複数ワーカーで動かすとワーカーごとに別の値になる点に注意)。
|
||||||
|
_progress: dict = {
|
||||||
|
"running": False,
|
||||||
|
"total": 0,
|
||||||
|
"done": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"started_at": None,
|
||||||
|
"finished_at": None,
|
||||||
|
"last_error": None,
|
||||||
|
"last_model": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# create_task の戻り値。GCで途中終了しないように保持しておく
|
||||||
|
_reembed_task: "asyncio.Task | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def _start_progress(model: str, total: int) -> None:
|
||||||
|
_progress.update(
|
||||||
|
running=True, total=total, done=0, failed=0,
|
||||||
|
started_at=datetime.now(timezone.utc), finished_at=None,
|
||||||
|
last_error=None, last_model=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_progress() -> None:
|
||||||
|
_progress.update(running=False, finished_at=datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_snapshot() -> dict:
|
||||||
|
return {
|
||||||
|
"running": _progress["running"],
|
||||||
|
"total": _progress["total"],
|
||||||
|
"done": _progress["done"],
|
||||||
|
"failed": _progress["failed"],
|
||||||
|
"started_at": _progress["started_at"],
|
||||||
|
"finished_at": _progress["finished_at"],
|
||||||
|
"last_error": _progress["last_error"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _kb_dir() -> Path:
|
def _kb_dir() -> Path:
|
||||||
return Path(LOCAL_KB_DIR)
|
return Path(LOCAL_KB_DIR)
|
||||||
@@ -29,6 +71,11 @@ def _embedding_model() -> str:
|
|||||||
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
|
return os.environ.get("LOCAL_KB_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||||
|
|
||||||
|
|
||||||
|
def current_embedding_model() -> str:
|
||||||
|
"""いま使われている埋め込みモデル名。"""
|
||||||
|
return _embedding_model()
|
||||||
|
|
||||||
|
|
||||||
def _hash(content: str) -> str:
|
def _hash(content: str) -> str:
|
||||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
@@ -89,46 +136,75 @@ def backfill_search_results(db: DBSession) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
async def sync_local_kb(db: DBSession) -> None:
|
async def sync_local_kb(db: DBSession, force: bool = False) -> None:
|
||||||
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
|
"""ナレッジベースディレクトリをスキャンし、埋め込みが古いファイルだけ計算し直す。
|
||||||
削除されたファイルに対応するレコードも削除する。
|
|
||||||
|
埋め込みを取り直すのは、ファイル内容が変わったか、埋め込みモデルが変わったとき
|
||||||
|
(次元数の違うベクトルを比べても意味のあるスコアにならないため)。force=True なら全件やり直す。
|
||||||
|
削除されたファイルに対応するレコードも消す。
|
||||||
|
進捗は _progress に載せてGUIから見えるようにする。
|
||||||
"""
|
"""
|
||||||
kb_dir = _kb_dir()
|
kb_dir = _kb_dir()
|
||||||
if not kb_dir.is_dir():
|
if not kb_dir.is_dir():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 再埋め込みが走っている最中は、チャット毎の同期は譲って何もしない
|
||||||
|
# (同じファイルを二重に埋め込むのを避けるため。ここまでawaitを挟まないので取りこぼしはない)。
|
||||||
|
# force=True で呼ばれるのは start_reembed_all() が実行中フラグを立てた本人だけなので素通しする。
|
||||||
|
if _progress["running"] and not force:
|
||||||
|
return
|
||||||
|
|
||||||
|
model = _embedding_model()
|
||||||
md_files = sorted(kb_dir.rglob("*.md"))
|
md_files = sorted(kb_dir.rglob("*.md"))
|
||||||
seen_paths: set[str] = set()
|
seen_paths: set[str] = set()
|
||||||
|
|
||||||
|
# 先に対象を洗い出す。1件も無ければ進捗を触らない(毎リクエストの同期で履歴が消えないように)
|
||||||
|
pending: list[tuple[Path, str, str, LocalKnowledgeDoc | None]] = []
|
||||||
for file_path in md_files:
|
for file_path in md_files:
|
||||||
rel_path = str(file_path.relative_to(kb_dir))
|
rel_path = str(file_path.relative_to(kb_dir))
|
||||||
seen_paths.add(rel_path)
|
seen_paths.add(rel_path)
|
||||||
content = file_path.read_text(encoding="utf-8")
|
content = file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
content_hash = _hash(content)
|
content_hash = _hash(content)
|
||||||
|
|
||||||
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
|
existing = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path == rel_path).first()
|
||||||
if existing and existing.content_hash == content_hash:
|
up_to_date = (
|
||||||
|
existing is not None
|
||||||
|
and existing.content_hash == content_hash
|
||||||
|
and existing.embedding_model == model
|
||||||
|
)
|
||||||
|
if up_to_date and not force:
|
||||||
continue
|
continue
|
||||||
|
pending.append((file_path, rel_path, content, existing))
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
_start_progress(model, len(pending))
|
||||||
try:
|
try:
|
||||||
[embedding] = await llm.embed([content], model=_embedding_model())
|
for file_path, rel_path, content, existing in pending:
|
||||||
except Exception:
|
try:
|
||||||
|
[embedding] = await llm.embed([content], model=model)
|
||||||
|
except Exception as e:
|
||||||
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
logger.exception("Failed to embed local knowledge file: %s", rel_path)
|
||||||
|
_progress["failed"] += 1
|
||||||
|
_progress["last_error"] = f"{rel_path}: {e}"[:500]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
title = _title_from_content(content, fallback=file_path.stem)
|
title = _title_from_content(content, fallback=file_path.stem)
|
||||||
if existing:
|
if existing:
|
||||||
existing.content_hash = content_hash
|
existing.content_hash = _hash(content)
|
||||||
existing.title = title
|
existing.title = title
|
||||||
existing.embedding_json = json.dumps(embedding)
|
existing.embedding_json = json.dumps(embedding)
|
||||||
|
existing.embedding_model = model
|
||||||
else:
|
else:
|
||||||
db.add(LocalKnowledgeDoc(
|
db.add(LocalKnowledgeDoc(
|
||||||
path=rel_path,
|
path=rel_path,
|
||||||
title=title,
|
title=title,
|
||||||
content_hash=content_hash,
|
content_hash=_hash(content),
|
||||||
embedding_json=json.dumps(embedding),
|
embedding_json=json.dumps(embedding),
|
||||||
|
embedding_model=model,
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
_progress["done"] += 1
|
||||||
|
finally:
|
||||||
|
_finish_progress()
|
||||||
|
|
||||||
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
|
stale = db.query(LocalKnowledgeDoc).filter(LocalKnowledgeDoc.path.notin_(seen_paths)).all()
|
||||||
for doc in stale:
|
for doc in stale:
|
||||||
@@ -137,6 +213,85 @@ async def sync_local_kb(db: DBSession) -> None:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def reembed_all() -> None:
|
||||||
|
"""全ファイルを現在のモデルで埋め込み直す。バックグラウンドタスクから呼ぶ想定で、DBセッションは自前で持つ。"""
|
||||||
|
from app.db.database import SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
await sync_local_kb(db, force=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Re-embedding of the local knowledge base failed")
|
||||||
|
_progress["last_error"] = str(e)[:500]
|
||||||
|
finally:
|
||||||
|
# 対象0件で sync 側が進捗を触らなかった場合でも、実行中フラグは必ず下ろす
|
||||||
|
_finish_progress()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def start_reembed_all() -> bool:
|
||||||
|
"""再埋め込みをバックグラウンドで開始する。すでに走っていれば False を返して何もしない。"""
|
||||||
|
global _reembed_task
|
||||||
|
if _progress["running"]:
|
||||||
|
return False
|
||||||
|
# GUIは開始リクエストの応答をそのまま状態表示に使うので、タスクが動き出すのを待たず
|
||||||
|
# ここで「実行中」にしておく(総数は実際にスキャンした時点で入る)。
|
||||||
|
_start_progress(_embedding_model(), 0)
|
||||||
|
# create_task の戻り値を保持しないとGCで途中終了することがあるためモジュールに持たせる
|
||||||
|
_reembed_task = asyncio.create_task(reembed_all())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def embedding_status(db: DBSession) -> dict:
|
||||||
|
"""GUIの「埋め込み」表示用。現在のモデルと、そのモデルで埋め込み済みの件数を返す。"""
|
||||||
|
model = _embedding_model()
|
||||||
|
kb_dir = _kb_dir()
|
||||||
|
file_count = len(list(kb_dir.rglob("*.md"))) if kb_dir.is_dir() else 0
|
||||||
|
|
||||||
|
rows = db.query(LocalKnowledgeDoc.embedding_model, LocalKnowledgeDoc.embedding_json).all()
|
||||||
|
indexed = 0
|
||||||
|
dimensions = None
|
||||||
|
for row_model, embedding_json in rows:
|
||||||
|
if row_model != model:
|
||||||
|
continue
|
||||||
|
indexed += 1
|
||||||
|
if dimensions is None:
|
||||||
|
dimensions = len(json.loads(embedding_json))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"dimensions": dimensions,
|
||||||
|
"file_count": file_count,
|
||||||
|
"indexed": indexed,
|
||||||
|
# 別のモデルで埋め込まれた(=埋め直しが要る)行。モデル不明の古い行も含む
|
||||||
|
"stale": len(rows) - indexed,
|
||||||
|
**_progress_snapshot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_embedding() -> dict:
|
||||||
|
"""埋め込みモデルが実際に応答するかを短いテキストで確認する。GUIの「接続テスト」用。"""
|
||||||
|
model = _embedding_model()
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
[vector] = await llm.embed(["これは埋め込みモデルの疎通確認です。"], model=model)
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"model": model,
|
||||||
|
"dimensions": None,
|
||||||
|
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
||||||
|
"error": f"{type(e).__name__}: {e}"[:500],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"model": model,
|
||||||
|
"dimensions": len(vector),
|
||||||
|
"latency_ms": round((time.perf_counter() - start) * 1000, 1),
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
|
async def get_local_kb_context(db: DBSession, query_text: str) -> str | None:
|
||||||
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
|
"""直近のユーザー発言に関連するローカルMarkdown知識をシステムプロンプト用テキストにして返す。"""
|
||||||
if not query_text.strip():
|
if not query_text.strip():
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import TAVILY_API_KEY
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
|
TAVILY_SEARCH_URL = "https://api.tavily.com/search"
|
||||||
|
|
||||||
|
|
||||||
|
def _api_key() -> str | None:
|
||||||
|
"""GUIの設定画面から実行時に変更できるよう、起動時定数ではなく都度 os.environ を見に行く。
|
||||||
|
|
||||||
|
app.core.config の定数は import 時に評価されるため、DBに保存した値を
|
||||||
|
settings_store.load_into_env() が os.environ に入れても反映されない。
|
||||||
|
"""
|
||||||
|
return os.environ.get("TAVILY_API_KEY") or None
|
||||||
|
|
||||||
|
|
||||||
WEB_SEARCH_TOOL = {
|
WEB_SEARCH_TOOL = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@@ -27,7 +36,8 @@ WEB_SEARCH_TOOL = {
|
|||||||
|
|
||||||
async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
||||||
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
|
"""Tavily APIでWeb検索する。結果は [{title, url, content}, ...]。失敗時は空リスト。"""
|
||||||
if not TAVILY_API_KEY:
|
api_key = _api_key()
|
||||||
|
if not api_key:
|
||||||
logger.warning("TAVILY_API_KEY is not set; skipping web search")
|
logger.warning("TAVILY_API_KEY is not set; skipping web search")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -36,7 +46,7 @@ async def search_web(query: str, max_results: int = 5) -> list[dict]:
|
|||||||
response = await client.post(
|
response = await client.post(
|
||||||
TAVILY_SEARCH_URL,
|
TAVILY_SEARCH_URL,
|
||||||
json={
|
json={
|
||||||
"api_key": TAVILY_API_KEY,
|
"api_key": api_key,
|
||||||
"query": query,
|
"query": query,
|
||||||
"max_results": max_results,
|
"max_results": max_results,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from app.db.models import AppSetting
|
|||||||
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
|
# GUIから編集可能な環境変数の許可リスト。ここに無いキーは save()/clear() で拒否する。
|
||||||
CONFIGURABLE_KEYS = [
|
CONFIGURABLE_KEYS = [
|
||||||
"OPENAI_API_KEY",
|
"OPENAI_API_KEY",
|
||||||
|
# OpenAI 互換エンドポイント。ローカルの llama-server / Ollama を向けたまま忘れると、
|
||||||
|
# OpenAI のモデルを選んでも接続先がそちらのままになる(litellm は OPENAI_API_BASE、
|
||||||
|
# OpenAI SDK は OPENAI_BASE_URL を見るので両方を出しておく)。
|
||||||
|
"OPENAI_BASE_URL",
|
||||||
|
"OPENAI_API_BASE",
|
||||||
"ANTHROPIC_API_KEY",
|
"ANTHROPIC_API_KEY",
|
||||||
"AWS_ACCESS_KEY_ID",
|
"AWS_ACCESS_KEY_ID",
|
||||||
"AWS_SECRET_ACCESS_KEY",
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
@@ -16,8 +21,20 @@ CONFIGURABLE_KEYS = [
|
|||||||
"OLLAMA_API_BASE",
|
"OLLAMA_API_BASE",
|
||||||
"TAVILY_API_KEY",
|
"TAVILY_API_KEY",
|
||||||
"LOCAL_KB_EMBEDDING_MODEL",
|
"LOCAL_KB_EMBEDDING_MODEL",
|
||||||
|
"SEARCH_SUMMARY_MODEL",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 秘匿情報ではないキー。値をマスクせずそのまま返し、GUIでも通常のテキスト入力として扱う。
|
||||||
|
# モデル名やURLは「今どれが効いているか」が見えないと設定しづらいため。
|
||||||
|
NON_SECRET_KEYS = {
|
||||||
|
"OPENAI_BASE_URL",
|
||||||
|
"OPENAI_API_BASE",
|
||||||
|
"AWS_REGION_NAME",
|
||||||
|
"OLLAMA_API_BASE",
|
||||||
|
"LOCAL_KB_EMBEDDING_MODEL",
|
||||||
|
"SEARCH_SUMMARY_MODEL",
|
||||||
|
}
|
||||||
|
|
||||||
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
|
# .env由来の起動時の値のスナップショット。clear() で戻す先として使う。
|
||||||
_env_defaults: dict[str, str | None] = {}
|
_env_defaults: dict[str, str | None] = {}
|
||||||
_snapshot_taken = False
|
_snapshot_taken = False
|
||||||
@@ -47,15 +64,18 @@ def load_into_env(db: DBSession) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def list_masked(db: DBSession) -> list[dict]:
|
def list_masked(db: DBSession) -> list[dict]:
|
||||||
|
"""GUI設定画面に出す一覧。秘匿キーの値はマスクし、そうでないキーは現在値をそのまま見せる。"""
|
||||||
_ensure_snapshot()
|
_ensure_snapshot()
|
||||||
saved = {row.key: row.value for row in db.query(AppSetting).all()}
|
saved = {row.key: row.value for row in db.query(AppSetting).all()}
|
||||||
result = []
|
result = []
|
||||||
for key in CONFIGURABLE_KEYS:
|
for key in CONFIGURABLE_KEYS:
|
||||||
current = os.environ.get(key)
|
current = os.environ.get(key)
|
||||||
|
secret = key not in NON_SECRET_KEYS
|
||||||
result.append({
|
result.append({
|
||||||
"key": key,
|
"key": key,
|
||||||
"is_set": bool(current),
|
"is_set": bool(current),
|
||||||
"masked": _mask(current) if current else None,
|
"display_value": (_mask(current) if secret else current) if current else None,
|
||||||
|
"secret": secret,
|
||||||
"overridden": key in saved,
|
"overridden": key in saved,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ _ADDED_COLUMNS = [
|
|||||||
("knowledge_entries", "headline", "VARCHAR"),
|
("knowledge_entries", "headline", "VARCHAR"),
|
||||||
("messages", "archived_at", "DATETIME"),
|
("messages", "archived_at", "DATETIME"),
|
||||||
("messages", "memory_id", "INTEGER"),
|
("messages", "memory_id", "INTEGER"),
|
||||||
|
("local_knowledge_docs", "embedding_model", "VARCHAR"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class LocalKnowledgeDoc(Base):
|
|||||||
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
|
"""backend/knowledge_base/ 配下のMarkdownファイルとその埋め込みベクトル。
|
||||||
|
|
||||||
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
|
話題に応じて関連ファイルをシステムプロンプトに注入するために使う。
|
||||||
ファイル内容のハッシュが変わった時だけ埋め込みを再計算する。
|
ファイル内容のハッシュか埋め込みモデルが変わった時だけ埋め込みを再計算する。
|
||||||
"""
|
"""
|
||||||
__tablename__ = "local_knowledge_docs"
|
__tablename__ = "local_knowledge_docs"
|
||||||
|
|
||||||
@@ -113,6 +113,10 @@ class LocalKnowledgeDoc(Base):
|
|||||||
title: Mapped[str] = mapped_column(String)
|
title: Mapped[str] = mapped_column(String)
|
||||||
content_hash: Mapped[str] = mapped_column(String)
|
content_hash: Mapped[str] = mapped_column(String)
|
||||||
embedding_json: Mapped[str] = mapped_column(Text)
|
embedding_json: Mapped[str] = mapped_column(Text)
|
||||||
|
# この埋め込みを計算したモデル名。モデルを切り替えた行を見分けて埋め直すために持つ
|
||||||
|
# (次元数の違うベクトル同士を比較しても意味のないスコアしか出ないため)。
|
||||||
|
# このカラムより前に作られた行は NULL で、モデル不明として埋め直し対象になる。
|
||||||
|
embedding_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, 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))
|
||||||
|
|
||||||
|
|
||||||
@@ -186,3 +190,24 @@ class ApiLog(Base):
|
|||||||
error: 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)
|
latency_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class Reminder(Base):
|
||||||
|
"""Discord Bot がメンションで受け付けた一回きりのリマインド。
|
||||||
|
|
||||||
|
登録は app.core.discord.reminders の create_reminder ツール、配信は同モジュールの
|
||||||
|
deliver_due_reminders() を APScheduler が定期的に呼ぶことで行う。
|
||||||
|
日時は他のテーブルと同じくUTCで持ち、ユーザーとのやり取りの際にローカル時刻へ直す。
|
||||||
|
"""
|
||||||
|
__tablename__ = "reminders"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
channel_id: Mapped[str] = mapped_column(String) # 登録された(=配信先の)チャンネル
|
||||||
|
user_id: Mapped[str] = mapped_column(String) # 依頼者。配信時にメンションする
|
||||||
|
user_name: Mapped[str] = mapped_column(String) # 一覧表示とログ用の表示名
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
due_at: Mapped[datetime] = mapped_column(UtcDateTime)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
|
# 配信済み。配信を諦めた場合もここを立てて再試行を止める(deliver_due_reminders を参照)
|
||||||
|
notified_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(UtcDateTime, nullable=True)
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
@@ -9,7 +10,7 @@ from app.api import chat, knowledge_base, logs, memory, notes, system
|
|||||||
from app.api import models as models_api
|
from app.api import models as models_api
|
||||||
from app.api import settings as settings_api
|
from app.api import settings as settings_api
|
||||||
from app.core import settings_store
|
from app.core import settings_store
|
||||||
from app.core.config import CORS_ORIGINS
|
from app.core.config import CORS_ORIGINS, LOG_LEVEL
|
||||||
from app.core.site_auth import require_site_session
|
from app.core.site_auth import require_site_session
|
||||||
from app.core.discord.discord_bot import start_discord_bot
|
from app.core.discord.discord_bot import start_discord_bot
|
||||||
from app.core.discord.scheduler import start_scheduler
|
from app.core.discord.scheduler import start_scheduler
|
||||||
@@ -17,6 +18,16 @@ 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, ModelPreset
|
from app.db.models import Message, ModelPreset
|
||||||
|
|
||||||
|
# uvicorn は自分のロガーしか設定しないため、アプリ側の logging.getLogger(__name__) は
|
||||||
|
# 何もしないと logging.lastResort 送りになる。それだと WARNING 未満が消えるうえ、
|
||||||
|
# 出力にレベルもタイムスタンプもロガー名も付かず、ログから障害を追えない。
|
||||||
|
logging.basicConfig(
|
||||||
|
level=LOG_LEVEL,
|
||||||
|
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
# LiteLLM は自前のハンドラを持つので、root にも流すと同じ行が二重に出る。
|
||||||
|
logging.getLogger("LiteLLM").propagate = False
|
||||||
|
|
||||||
# ModelSelector.vue に元々ハードコードされていたプリセット。初回起動時のみDBへシードする。
|
# ModelSelector.vue に元々ハードコードされていたプリセット。初回起動時のみDBへシードする。
|
||||||
_DEFAULT_MODEL_PRESETS = [
|
_DEFAULT_MODEL_PRESETS = [
|
||||||
("GPT-5.5", "gpt-5.5"),
|
("GPT-5.5", "gpt-5.5"),
|
||||||
|
|||||||
+40
@@ -306,6 +306,46 @@ attachment; filename="______.md"; filename*=UTF-8''%E8%B2%B7%E3%81%84%E7%89%A9%E
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### GET /api/kb/embedding
|
||||||
|
埋め込みモデルの現状と、再埋め込みの進捗を返す。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "ollama/nomic-embed-text",
|
||||||
|
"dimensions": 768,
|
||||||
|
"file_count": 36,
|
||||||
|
"indexed": 36,
|
||||||
|
"stale": 0,
|
||||||
|
"running": false,
|
||||||
|
"total": 36,
|
||||||
|
"done": 36,
|
||||||
|
"failed": 0,
|
||||||
|
"started_at": "2026-08-23T13:50:23.196274Z",
|
||||||
|
"finished_at": "2026-08-23T13:50:29.951099Z",
|
||||||
|
"last_error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`indexed` は**現在のモデルで**埋め込まれている件数、`stale` は別モデル(またはモデル不明)のまま残っている件数。
|
||||||
|
`running` 以下は実行中の進捗で、プロセス内のメモリにしか無い(再起動で消える。ワーカーを複数立てるとワーカーごとに別の値になる)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/kb/embedding/test
|
||||||
|
短いテキストを実際に 1 回埋め込んでみて、モデルが応答するかを確かめる。失敗しても 200 で返し、`error` に理由が入る。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "model": "ollama/nomic-embed-text", "dimensions": 768, "latency_ms": 412.3, "error": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/kb/embedding/reindex
|
||||||
|
全ファイルの再埋め込みをバックグラウンドで開始し、開始直後の状態(`running: true`)を返す。
|
||||||
|
すでに実行中なら何も起こさず現在の進捗を返す。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ログ
|
## ログ
|
||||||
|
|
||||||
### GET /logs
|
### GET /logs
|
||||||
|
|||||||
+47
-1
@@ -45,6 +45,10 @@ backend/
|
|||||||
│ │ ├── search/
|
│ │ ├── search/
|
||||||
│ │ │ └── websearch.py # Tavily による web_search ツール
|
│ │ │ └── websearch.py # Tavily による web_search ツール
|
||||||
│ │ ├── discord/ # Discord Bot・定期通知
|
│ │ ├── discord/ # Discord Bot・定期通知
|
||||||
|
│ │ │ ├── discord_bot.py # メンションへの返信(天気 / 雑談 / リマインドで分岐)
|
||||||
|
│ │ │ ├── scheduler.py # APScheduler ジョブ(天気通知・Tips・リマインド配信)
|
||||||
|
│ │ │ ├── reminders.py # リマインドの登録ツールと配信ジョブ
|
||||||
|
│ │ │ └── notify.py # Webhook 送信
|
||||||
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
|
│ │ ├── settings_store.py # GUI 設定を os.environ へ反映
|
||||||
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
|
│ │ └── auth.py / site_auth.py # 設定画面パスワード・サイトログイン
|
||||||
│ └── db/
|
│ └── db/
|
||||||
@@ -65,12 +69,13 @@ backend/
|
|||||||
| `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
|
| `memory_tags` | id, memory_id, tag, created_at | 要約への自動タグ |
|
||||||
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
|
| `knowledge_entries` | id, query, summary, created_at | Web 検索で得た知識(セッション横断) |
|
||||||
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
|
| `knowledge_sources` | id, entry_id, title, url | 上記知識の出典 URL |
|
||||||
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, updated_at | ローカル Markdown KB の埋め込みキャッシュ |
|
| `local_knowledge_docs` | id, path, title, content_hash, embedding_json, embedding_model, updated_at | ローカル Markdown KB の埋め込みキャッシュ(`embedding_model` が現在の設定と違う行は埋め直す) |
|
||||||
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
|
| `note_pages` | id, title, content, position, section, created_at, updated_at | ユーザーと AI が共同編集する共有ノートのページ(`section` でタブをグループ化) |
|
||||||
| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
|
| `session_note_pages` | id, session_id, page_id, created_at | 「このセッションではこのページの本文をプロンプトに載せる」指定(session_id × page_id は一意) |
|
||||||
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
|
| `app_settings` | key, value, updated_at | GUI 設定画面から編集した接続情報 |
|
||||||
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
|
| `model_presets` | id, label, value, created_at | モデル選択プルダウンの選択肢 |
|
||||||
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
|
| `api_logs` | id, session_id, model, request_json, response_text, error, latency_ms, created_at | LLM API 呼び出しログ |
|
||||||
|
| `reminders` | id, channel_id, user_id, user_name, content, due_at, created_at, notified_at, cancelled_at | Discord Bot が預かった一回きりのリマインド(`due_at` は UTC) |
|
||||||
|
|
||||||
### コンテキスト管理(`core/memory_kb/`)
|
### コンテキスト管理(`core/memory_kb/`)
|
||||||
|
|
||||||
@@ -172,6 +177,47 @@ data: {"type":"error","value":"エラー内容"} # 例外発生時
|
|||||||
|
|
||||||
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
|
||||||
|
|
||||||
|
### Discord Bot(`core/discord/`)
|
||||||
|
|
||||||
|
`main.py` の lifespan が `start_discord_bot()`(メンション応答)と `start_scheduler()`(定期ジョブ)を起動する。
|
||||||
|
|
||||||
|
`on_message` はメンション本文をキーワードで見て分岐する。判定順は **リマインド → 雑談 → 天気** で、
|
||||||
|
先にマッチしたものが返信を返して終わる。
|
||||||
|
|
||||||
|
| 分岐 | キーワード例 | 動作 |
|
||||||
|
|---|---|---|
|
||||||
|
| リマインド | リマインド / 忘れないように / 覚えといて / アラーム | `reminders.handle_reminder_request()` に渡す |
|
||||||
|
| 雑談 | どうでもいい / 雑談 / 暇 | 『どうでもいいTips』へ誘導しつつ、話題に近いタグで Tips を1本投稿 |
|
||||||
|
| 天気 | 天気 / 洗濯 / 雨 | 気象庁の明日の予報を system メッセージに足してから返信 |
|
||||||
|
|
||||||
|
APScheduler に登録されるジョブは 3 つ。
|
||||||
|
|
||||||
|
| ジョブ | トリガー | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `weather_laundry_notification` | Cron(`WEATHER_NOTIFY_HOUR:MINUTE`) | 明日の天気と洗濯指数を Webhook で投稿 |
|
||||||
|
| `useless_tips` | Interval(`USELESS_TIPS_INTERVAL_HOURS`) | どうでもいい Tips を Webhook で投稿(3時台のみ抑制) |
|
||||||
|
| `discord_reminders` | Interval(`REMINDER_CHECK_INTERVAL_SECONDS`) | 期限を過ぎたリマインドを配信 |
|
||||||
|
|
||||||
|
#### リマインド(`core/discord/reminders.py`)
|
||||||
|
|
||||||
|
一回きりのリマインドのみを扱う(繰り返しには非対応)。
|
||||||
|
|
||||||
|
- **登録**: LLM に `create_reminder` / `list_reminders` / `cancel_reminder` の 3 ツールを渡し、
|
||||||
|
自然言語から日時と用件を取り出させる。system メッセージに現在時刻と曜日を入れてあるので、
|
||||||
|
『明日』『30分後』といった相対表現はモデル側が絶対日時に直す。使うモデルは
|
||||||
|
`REMINDER_MODEL` →`WEATHER_NOTIFY_MODEL` → `DEFAULT_MODEL` の順にフォールバックするが、
|
||||||
|
**tool calling 対応モデルでないと動かない**(非対応時はエラー文を返す)
|
||||||
|
- **バリデーション**: 過去・`REMINDER_MAX_AHEAD_DAYS` より先・解釈不能な日時はツール結果として
|
||||||
|
エラー文をモデルに返し、モデルに聞き直させる
|
||||||
|
- **配信先**: Webhook ではなく Bot クライアントから、登録されたチャンネルへ依頼者をメンションして投稿する。
|
||||||
|
そのため専用の Webhook URL 設定は要らない
|
||||||
|
- **タイムゾーン**: DB は他テーブルと同じく UTC。ユーザーおよび LLM とのやり取りはすべて
|
||||||
|
このマシンのローカル時刻で行い、`reminders.py` の境界で変換する
|
||||||
|
- **配信の失敗**: チャンネル削除などで送れない場合はプロセス内カウンタで数え、5 回失敗したら
|
||||||
|
`notified_at` を立てて諦める(毎回エラーログを出し続けるのを防ぐ)。カウンタは DB に持たないので
|
||||||
|
バックエンドを再起動すればまた試す
|
||||||
|
- **バックエンド停止中に期限を迎えた分**: 起動して Bot が ready になった直後にまとめて配信される
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## フロントエンド
|
## フロントエンド
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ nano backend/.env # APIキーやモデル名など本番用の値を設定
|
|||||||
|
|
||||||
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
|
`backend/.env` はコンテナに `env_file` として渡され、Git 管理対象外(`.gitignore` 済み)なので直接編集してよい。
|
||||||
|
|
||||||
|
**ローカルの `.env` をそのまま持ち込むときの注意:** `OPENAI_BASE_URL` / `OPENAI_API_BASE` を
|
||||||
|
ローカルの llama-server や Ollama に向けていると、サーバー上では存在しない宛先に接続しに行くため、
|
||||||
|
`gpt-*` のモデルを選んでも litellm が「Connection Error」になる。サーバーの `.env` からは消しておくこと
|
||||||
|
(設定画面の「OpenAI 互換エンドポイント」からも上書きできる)。
|
||||||
|
|
||||||
### GUIからのAPIキー管理
|
### GUIからのAPIキー管理
|
||||||
|
|
||||||
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
|
初回起動後は、`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS認証情報などをSSHで`.env`を編集し直さなくても、
|
||||||
|
|||||||
+23
-6
@@ -28,7 +28,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` |
|
| 4 | 自動要約による長期記憶 | 一定件数を超えたら古い会話を要約して圧縮 | `memory_kb/memory.py` |
|
||||||
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` |
|
| 5 | Web 検索と知識の蓄積 | Tavily で検索 →要約 → DB とローカル KB に保存 | `core/search/` / `memory_kb/knowledge.py` |
|
||||||
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` |
|
| 6 | ローカル Markdown ナレッジベース | 話題に近いファイルを埋め込み類似度で選んで全文注入 | `memory_kb/local_knowledge.py` |
|
||||||
| 6.5 | ナレッジベース閲覧パネル | KB の Markdown をサイドパネルで一覧・全文表示 | `api/knowledge_base.py` / `KnowledgeBasePanel.vue` |
|
| 6.5 | ナレッジベース閲覧パネル | KB の Markdown を一覧・全文表示。埋め込みの状態確認・接続テスト・再埋め込み | `api/knowledge_base.py` / `KnowledgeBasePanel.vue` |
|
||||||
| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` |
|
| 7 | 共有ノート | タブ/セクション付きノートを人と AI の両方が編集 | `core/notes.py` / `NotePanel.vue` |
|
||||||
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` |
|
| 8 | 自動タグ付け | 発言・要約にタグを 3 つ付与(話題選びに使う) | `memory_kb/tagging.py` |
|
||||||
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` |
|
| 9 | ワードプール | 直近の名詞をインメモリに保持しタグクラウド表示 | `memory_kb/word_memory.py` / `WordPool.vue` |
|
||||||
@@ -94,7 +94,7 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
## 5. Web 検索と知識の蓄積
|
## 5. Web 検索と知識の蓄積
|
||||||
|
|
||||||
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
|
- LLM が必要と判断したときだけ `web_search` ツールが走る(Tavily。`TAVILY_API_KEY` 未設定なら空振り)。
|
||||||
- 結果は `SEARCH_SUMMARY_MODEL` で構造化要約にされ、**2 か所**に保存される。
|
- 結果は `SEARCH_SUMMARY_MODEL`(未設定なら `DEFAULT_MODEL`。設定画面から変更可)で構造化要約にされ、**2 か所**に保存される。
|
||||||
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
|
- `knowledge_entries` / `knowledge_sources` テーブル → 次回以降、層 1 として全セッションに効く
|
||||||
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
|
- `knowledge_base/web_search/*.md` → 層 3 の類似度検索の対象になる
|
||||||
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
|
- `POST /chat/knowledge/regenerate` で、既存の知識エントリを出典 URL から取り直して要約し直せる。
|
||||||
@@ -103,9 +103,15 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
|
|
||||||
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
|
- `backend/knowledge_base/**.md` を置いておくだけで、話題が近いときに自動で全文が注入される。
|
||||||
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
|
- 直近のユーザー発言との埋め込みコサイン類似度で選ぶ。埋め込みは `local_knowledge_docs` に
|
||||||
内容ハッシュ付きでキャッシュされ、ファイルが変わったときだけ取り直す。
|
内容ハッシュと**使用した埋め込みモデル名**付きでキャッシュされ、
|
||||||
|
ファイルが変わったときと、埋め込みモデルが変わったときだけ取り直す。
|
||||||
|
- 設定画面で `LOCAL_KB_EMBEDDING_MODEL` を変更すると、保存した時点でバックグラウンドの再埋め込みが走る
|
||||||
|
(モデルが違えば次元も意味空間も変わり、古いベクトルは比較しても無意味なため)。
|
||||||
|
パネルの「埋め直し」ボタンからも手動で開始できる。
|
||||||
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
- プロンプト上は「Web 検索結果より優先。これで答えられるなら検索するな」と明示している。
|
||||||
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
|
- ヘッダーのパネルメニュー →「ナレッジベース」で開くサイドパネル(`KnowledgeBasePanel.vue`)から中身を閲覧できる。
|
||||||
|
パネル上部には埋め込みの状態(モデル名・次元数・埋め込み済み件数・進捗バー)が出る。
|
||||||
|
「接続テスト」は短いテキストを 1 回埋め込んでみて、モデルが応答するかその場で確かめる。
|
||||||
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
|
フォルダ単位のグループ表示・折りたたみ、タイトル/パス/本文先頭での絞り込み、Markdown 描画に対応。
|
||||||
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
|
各ファイルの緑の点は埋め込み済み(=自動注入の対象)であることを示す。閲覧専用で、編集はできない。
|
||||||
|
|
||||||
@@ -158,10 +164,21 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
|
|||||||
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
|
- モデルのプルダウンは `model_presets` テーブル由来で、初回起動時に既定の一覧がシードされる。
|
||||||
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
|
設定画面からラベル/モデル文字列を追加・編集・削除できる(重複は 400 で弾く)。
|
||||||
- 設定画面では API キー類を**再起動なしで**変更できる。
|
- 設定画面では API キー類を**再起動なしで**変更できる。
|
||||||
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / AWS 系 /
|
- 変更できるのは許可リストにあるキーだけ(`OPENAI_API_KEY` / `OPENAI_BASE_URL` / `OPENAI_API_BASE` /
|
||||||
`OLLAMA_API_BASE` / `TAVILY_API_KEY` / `LOCAL_KB_EMBEDDING_MODEL`)
|
`ANTHROPIC_API_KEY` / AWS 系 / `OLLAMA_API_BASE` / `TAVILY_API_KEY` /
|
||||||
- 値は末尾 4 文字だけを見せるマスク表示。空欄で保存すると「変更なし」扱い
|
`LOCAL_KB_EMBEDDING_MODEL` / `SEARCH_SUMMARY_MODEL`)
|
||||||
|
- `OPENAI_BASE_URL` / `OPENAI_API_BASE` は OpenAI 互換エンドポイント(ローカルの llama-server や Ollama)
|
||||||
|
に向けるためのもの。向けたままだと `gpt-*` のモデルを選んでも本家 OpenAI には繋がらず、
|
||||||
|
litellm からは「Connection Error」に見える
|
||||||
|
- API キー類は末尾 4 文字だけを見せるマスク表示。モデル名や URL(`settings_store.NON_SECRET_KEYS`)は
|
||||||
|
現在値をそのまま表示し、入力欄も伏せ字にしない(今どれが効いているか分からないと設定しづらいため)
|
||||||
|
- 空欄で保存すると「変更なし」扱い
|
||||||
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
|
- 保存すると `app_settings` テーブルに入り `os.environ` に反映される。削除すると `.env` 由来の起動時の値に戻る
|
||||||
|
- 保存した値は `backend_data` ボリュームに残るため、コンテナを作り直しても消えない。
|
||||||
|
起動時に `load_into_env()` が `os.environ` へ流し込むので、`.env` の同名の値より優先される
|
||||||
|
- **アプリ側は `config.py` の定数ではなく `os.environ` を都度読むこと**。定数は import 時に評価され、
|
||||||
|
`load_into_env()` より前に確定してしまうため、GUI で保存した値が反映されない
|
||||||
|
(`websearch._api_key()` / `knowledge._summary_model()` / `local_knowledge._embedding_model()` がその形)
|
||||||
|
|
||||||
## 15-16. ログビューアとシステムモニタ
|
## 15-16. ログビューアとシステムモニタ
|
||||||
|
|
||||||
|
|||||||
@@ -176,7 +176,10 @@ export async function logout(): Promise<void> {
|
|||||||
export interface SettingEntry {
|
export interface SettingEntry {
|
||||||
key: string
|
key: string
|
||||||
is_set: boolean
|
is_set: boolean
|
||||||
masked: string | null
|
/** 秘匿キーはマスク済みの値、それ以外は現在値そのもの。未設定なら null。 */
|
||||||
|
display_value: string | null
|
||||||
|
/** APIキーなど、入力欄をパスワード扱いにするキーか。 */
|
||||||
|
secret: boolean
|
||||||
overridden: boolean
|
overridden: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,3 +431,46 @@ export async function fetchKbFile(path: string): Promise<KbFileContent> {
|
|||||||
const res = await fetch(`${BASE}/api/kb/file?path=${encodeURIComponent(path)}`)
|
const res = await fetch(`${BASE}/api/kb/file?path=${encodeURIComponent(path)}`)
|
||||||
return parseJson<KbFileContent>(res, 'ナレッジベースファイル取得')
|
return parseJson<KbFileContent>(res, 'ナレッジベースファイル取得')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ローカルナレッジベースの埋め込み状況。GUI の状態表示・進捗バーに使う。 */
|
||||||
|
export interface KbEmbeddingStatus {
|
||||||
|
model: string
|
||||||
|
/** 現在のモデルで埋め込まれたベクトルの次元数(1件も無ければ null)。 */
|
||||||
|
dimensions: number | null
|
||||||
|
file_count: number
|
||||||
|
indexed: number
|
||||||
|
/** 別のモデルで埋め込まれていて埋め直しが必要な件数。 */
|
||||||
|
stale: number
|
||||||
|
running: boolean
|
||||||
|
total: number
|
||||||
|
done: number
|
||||||
|
failed: number
|
||||||
|
started_at: string | null
|
||||||
|
finished_at: string | null
|
||||||
|
last_error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 埋め込みモデルに実際に1回リクエストしてみた結果。 */
|
||||||
|
export interface KbEmbeddingProbe {
|
||||||
|
ok: boolean
|
||||||
|
model: string
|
||||||
|
dimensions: number | null
|
||||||
|
latency_ms: number
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKbEmbeddingStatus(): Promise<KbEmbeddingStatus> {
|
||||||
|
const res = await fetch(`${BASE}/api/kb/embedding`)
|
||||||
|
return parseJson<KbEmbeddingStatus>(res, '埋め込み状態取得')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testKbEmbedding(): Promise<KbEmbeddingProbe> {
|
||||||
|
const res = await fetch(`${BASE}/api/kb/embedding/test`, { method: 'POST' })
|
||||||
|
return parseJson<KbEmbeddingProbe>(res, '埋め込みテスト')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全ファイルの再埋め込みをバックグラウンドで開始する。戻り値は開始直後の状態。 */
|
||||||
|
export async function reindexKbEmbeddings(): Promise<KbEmbeddingStatus> {
|
||||||
|
const res = await fetch(`${BASE}/api/kb/embedding/reindex`, { method: 'POST' })
|
||||||
|
return parseJson<KbEmbeddingStatus>(res, '再埋め込み開始')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
import MarkdownContent from './MarkdownContent.vue'
|
import MarkdownContent from './MarkdownContent.vue'
|
||||||
import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
|
import { useKnowledgeBaseStore } from '../stores/knowledgeBase'
|
||||||
|
|
||||||
const store = useKnowledgeBaseStore()
|
const store = useKnowledgeBaseStore()
|
||||||
|
|
||||||
onMounted(() => store.load())
|
onMounted(() => {
|
||||||
|
store.load()
|
||||||
|
store.loadEmbedding()
|
||||||
|
})
|
||||||
|
// パネルを閉じている間もポーリングが回り続けないように止める
|
||||||
|
onUnmounted(() => store.stopPolling())
|
||||||
|
|
||||||
|
const progressPercent = computed(() => {
|
||||||
|
const e = store.embedding
|
||||||
|
if (!e || e.total === 0) return 0
|
||||||
|
return Math.round(((e.done + e.failed) / e.total) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`
|
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`
|
||||||
@@ -41,6 +52,54 @@ function formatDate(iso: string): string {
|
|||||||
|
|
||||||
<!-- 一覧 -->
|
<!-- 一覧 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div v-if="store.embedding" class="embedding">
|
||||||
|
<div class="embedding-head">
|
||||||
|
<span class="embedding-model" :title="`埋め込みモデル: ${store.embedding.model}`">
|
||||||
|
🧠 {{ store.embedding.model }}
|
||||||
|
</span>
|
||||||
|
<button class="mini-btn" :disabled="store.testing" title="このモデルに実際に1回リクエストして応答を確かめる"
|
||||||
|
@click="store.testEmbedding()">
|
||||||
|
{{ store.testing ? 'テスト中…' : '接続テスト' }}
|
||||||
|
</button>
|
||||||
|
<button class="mini-btn" :disabled="store.embedding.running" title="全ファイルを現在のモデルで埋め込み直す"
|
||||||
|
@click="store.reindex()">
|
||||||
|
埋め直し
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="embedding-stats">
|
||||||
|
<span :class="{ warn: store.embedding.indexed < store.embedding.file_count }">
|
||||||
|
埋め込み済み {{ store.embedding.indexed }} / {{ store.embedding.file_count }}
|
||||||
|
</span>
|
||||||
|
<span v-if="store.embedding.stale > 0" class="warn">
|
||||||
|
・別モデルのまま {{ store.embedding.stale }}
|
||||||
|
</span>
|
||||||
|
<span v-if="store.embedding.dimensions">・{{ store.embedding.dimensions }} 次元</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="store.embedding.running">
|
||||||
|
<div class="progress"><div class="progress-bar" :style="{ width: `${progressPercent}%` }" /></div>
|
||||||
|
<div class="embedding-note">
|
||||||
|
<template v-if="store.embedding.total === 0">準備中…</template>
|
||||||
|
<template v-else>
|
||||||
|
埋め込み中 {{ store.embedding.done + store.embedding.failed }} / {{ store.embedding.total }}
|
||||||
|
<span v-if="store.embedding.failed > 0" class="ng">(失敗 {{ store.embedding.failed }})</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="store.probe" class="probe" :class="store.probe.ok ? 'ok' : 'ng'">
|
||||||
|
<template v-if="store.probe.ok">
|
||||||
|
✅ 応答あり — {{ store.probe.dimensions }} 次元 / {{ Math.round(store.probe.latency_ms) }} ms
|
||||||
|
</template>
|
||||||
|
<template v-else>❌ {{ store.probe.error }}</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.embedding.last_error" class="probe ng">
|
||||||
|
直近の埋め込みエラー: {{ store.embedding.last_error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み">
|
<input v-model="store.filter" type="search" placeholder="タイトル・パス・本文の先頭で絞り込み">
|
||||||
<span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span>
|
<span class="count">{{ store.filteredFiles.length }} / {{ store.files.length }}</span>
|
||||||
@@ -109,6 +168,30 @@ function formatDate(iso: string): string {
|
|||||||
.back-btn:hover, .reload-btn:hover:not(:disabled) { color: #fff; }
|
.back-btn:hover, .reload-btn:hover:not(:disabled) { color: #fff; }
|
||||||
.error { padding: 8px 12px; color: #f87171; font-size: 12px; }
|
.error { padding: 8px 12px; color: #f87171; font-size: 12px; }
|
||||||
|
|
||||||
|
.embedding {
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.embedding-head { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.embedding-model {
|
||||||
|
flex: 1; color: #cbd5e1; font-size: 12px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mini-btn {
|
||||||
|
background: #2a2a2a; color: #aaa; border: 1px solid #444;
|
||||||
|
border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mini-btn:hover:not(:disabled) { color: #fff; }
|
||||||
|
.mini-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.embedding-stats { color: #6b7280; font-size: 11px; }
|
||||||
|
.embedding-stats .warn { color: #fbbf24; }
|
||||||
|
.embedding-note { color: #6b7280; font-size: 11px; }
|
||||||
|
.progress { height: 4px; background: #262626; border-radius: 2px; overflow: hidden; }
|
||||||
|
.progress-bar { height: 100%; background: #2563eb; transition: width 0.3s; }
|
||||||
|
.probe { font-size: 11px; line-height: 1.5; word-break: break-word; }
|
||||||
|
.probe.ok { color: #4ade80; }
|
||||||
|
.probe.ng, .ng { color: #f87171; }
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
padding: 8px 12px; border-bottom: 1px solid #1e1e1e; flex-shrink: 0;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ async function addModelPreset() {
|
|||||||
|
|
||||||
const KEY_LABELS: Record<string, string> = {
|
const KEY_LABELS: Record<string, string> = {
|
||||||
OPENAI_API_KEY: 'OpenAI APIキー',
|
OPENAI_API_KEY: 'OpenAI APIキー',
|
||||||
|
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
|
||||||
|
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
|
||||||
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
ANTHROPIC_API_KEY: 'Anthropic APIキー',
|
||||||
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
|
||||||
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
|
||||||
@@ -46,11 +48,9 @@ const KEY_LABELS: Record<string, string> = {
|
|||||||
OLLAMA_API_BASE: 'Ollama API Base URL',
|
OLLAMA_API_BASE: 'Ollama API Base URL',
|
||||||
TAVILY_API_KEY: 'Tavily APIキー',
|
TAVILY_API_KEY: 'Tavily APIキー',
|
||||||
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
|
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
|
||||||
|
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
|
||||||
}
|
}
|
||||||
|
|
||||||
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
|
|
||||||
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
|
|
||||||
|
|
||||||
async function submitPassword() {
|
async function submitPassword() {
|
||||||
const ok = await store.unlock(passwordInput.value)
|
const ok = await store.unlock(passwordInput.value)
|
||||||
if (ok) passwordInput.value = ''
|
if (ok) passwordInput.value = ''
|
||||||
@@ -106,8 +106,8 @@ onMounted(() => {
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<input
|
<input
|
||||||
v-model="drafts[entry.key]"
|
v-model="drafts[entry.key]"
|
||||||
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'"
|
:type="entry.secret ? 'password' : 'text'"
|
||||||
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'"
|
:placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { fetchKbFile, fetchKbFiles, type KbFileContent, type KbFileInfo } from '../api/client'
|
import {
|
||||||
|
fetchKbEmbeddingStatus,
|
||||||
|
fetchKbFile,
|
||||||
|
fetchKbFiles,
|
||||||
|
reindexKbEmbeddings,
|
||||||
|
testKbEmbedding,
|
||||||
|
type KbEmbeddingProbe,
|
||||||
|
type KbEmbeddingStatus,
|
||||||
|
type KbFileContent,
|
||||||
|
type KbFileInfo,
|
||||||
|
} from '../api/client'
|
||||||
|
|
||||||
export interface KbGroup {
|
export interface KbGroup {
|
||||||
/** ナレッジベース直下は空文字。 */
|
/** ナレッジベース直下は空文字。 */
|
||||||
@@ -18,6 +28,15 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
|||||||
const filter = ref('')
|
const filter = ref('')
|
||||||
/** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */
|
/** 折りたたまれているフォルダ名。web_search のようにファイル数が多いフォルダを畳むために使う。 */
|
||||||
const collapsed = ref<Set<string>>(new Set())
|
const collapsed = ref<Set<string>>(new Set())
|
||||||
|
/** 埋め込みモデルの状態と進捗。 */
|
||||||
|
const embedding = ref<KbEmbeddingStatus | null>(null)
|
||||||
|
/** 「接続テスト」の結果。 */
|
||||||
|
const probe = ref<KbEmbeddingProbe | null>(null)
|
||||||
|
const testing = ref(false)
|
||||||
|
|
||||||
|
/** 埋め込み実行中だけ状態をポーリングするタイマー。 */
|
||||||
|
let pollTimer: number | null = null
|
||||||
|
const POLL_INTERVAL_MS = 1500
|
||||||
|
|
||||||
const filteredFiles = computed(() => {
|
const filteredFiles = computed(() => {
|
||||||
const q = filter.value.trim().toLowerCase()
|
const q = filter.value.trim().toLowerCase()
|
||||||
@@ -66,6 +85,57 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
|||||||
openFile.value = null
|
openFile.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 埋め込みの状態を取り直す。実行中ならポーリングを始め、終わっていれば止める。 */
|
||||||
|
async function loadEmbedding(): Promise<void> {
|
||||||
|
try {
|
||||||
|
embedding.value = await fetchKbEmbeddingStatus()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '埋め込み状態の取得に失敗しました'
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (embedding.value.running && pollTimer === null) {
|
||||||
|
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
|
||||||
|
} else if (!embedding.value.running && pollTimer !== null) {
|
||||||
|
stopPolling()
|
||||||
|
// 埋め込みが終わったので、一覧の「埋め込み済み」表示も更新する
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 埋め込みモデルに実際にリクエストして、応答するか確かめる。 */
|
||||||
|
async function testEmbedding(): Promise<void> {
|
||||||
|
testing.value = true
|
||||||
|
probe.value = null
|
||||||
|
try {
|
||||||
|
probe.value = await testKbEmbedding()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '埋め込みテストに失敗しました'
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全ファイルの再埋め込みをバックグラウンドで開始する。 */
|
||||||
|
async function reindex(): Promise<void> {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
embedding.value = await reindexKbEmbeddings()
|
||||||
|
if (embedding.value.running && pollTimer === null) {
|
||||||
|
pollTimer = window.setInterval(() => void loadEmbedding(), POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '再埋め込みの開始に失敗しました'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleGroup(directory: string): void {
|
function toggleGroup(directory: string): void {
|
||||||
const next = new Set(collapsed.value)
|
const next = new Set(collapsed.value)
|
||||||
if (next.has(directory)) next.delete(directory)
|
if (next.has(directory)) next.delete(directory)
|
||||||
@@ -73,5 +143,25 @@ export const useKnowledgeBaseStore = defineStore('knowledgeBase', () => {
|
|||||||
collapsed.value = next
|
collapsed.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
return { files, openFile, loading, error, filter, collapsed, filteredFiles, groups, load, open, close, toggleGroup }
|
return {
|
||||||
|
files,
|
||||||
|
openFile,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
filter,
|
||||||
|
collapsed,
|
||||||
|
embedding,
|
||||||
|
probe,
|
||||||
|
testing,
|
||||||
|
filteredFiles,
|
||||||
|
groups,
|
||||||
|
load,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
toggleGroup,
|
||||||
|
loadEmbedding,
|
||||||
|
stopPolling,
|
||||||
|
testEmbedding,
|
||||||
|
reindex,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user