Add shared notes with tabbed pages editable by user and AI
Adds a note_pages table backing a tabbed notes panel where the user and the model can both read and write. Only page titles (plus a short preview) are injected into the system prompt on every turn; full page content is kept out of context and fetched on demand via a read_note tool, with write_note letting the model append to, replace, or create a page. Generalizes llm.resolve_web_search into resolve_tool_calls so the tool-calling loop handles web_search and the new note tools through one path. The chat stream emits a notes_updated SSE event when the model edits a note so the frontend panel can refresh; local edits are tracked as dirty and never clobbered by that refresh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from app.core.memory_kb import memory as mem
|
||||
from app.core import llm
|
||||
from app.core.memory_kb import knowledge
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.core.notes import NOTE_TOOLS
|
||||
from app.core.search.websearch import WEB_SEARCH_TOOL
|
||||
from app.core.config import DEFAULT_MODEL
|
||||
from app.core.memory_kb.tagging import tag_message_by_id
|
||||
@@ -210,9 +211,12 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
used_model = req.model or session.model
|
||||
full_response = []
|
||||
try:
|
||||
resolved_context, searches = await llm.resolve_web_search(
|
||||
context, model=used_model, session_id=session.id,
|
||||
resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
|
||||
context, model=used_model, session_id=session.id, db=db,
|
||||
)
|
||||
if notes_changed:
|
||||
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
|
||||
yield sse({"type": "notes_updated"})
|
||||
if searches:
|
||||
sources = [
|
||||
{"title": r["title"], "url": r["url"]}
|
||||
@@ -225,9 +229,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
|
||||
knowledge.save_knowledge(db, s["query"], summary, s["results"])
|
||||
local_knowledge.save_search_result(s["query"], summary, s["results"])
|
||||
|
||||
search_tools = [WEB_SEARCH_TOOL] if len(resolved_context) > len(context) else None
|
||||
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
|
||||
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
|
||||
async for chunk in llm.stream_chat(
|
||||
resolved_context, model=used_model, session_id=session.id, tools=search_tools,
|
||||
resolved_context, model=used_model, session_id=session.id, tools=used_tools,
|
||||
):
|
||||
full_response.append(chunk)
|
||||
yield sse({"type": "chunk", "value": chunk})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.notes import next_position
|
||||
from app.db.database import get_db
|
||||
from app.db.models import NotePage
|
||||
|
||||
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||
|
||||
|
||||
class NotePageResponse(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
position: int
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotePageCreate(BaseModel):
|
||||
title: str = "新しいページ"
|
||||
content: str = ""
|
||||
|
||||
|
||||
class NotePageUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotePageResponse])
|
||||
def list_pages(db: DBSession = Depends(get_db)):
|
||||
return db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=NotePageResponse, status_code=201)
|
||||
def create_page(req: NotePageCreate, db: DBSession = Depends(get_db)):
|
||||
title = req.title.strip() or "新しいページ"
|
||||
page = NotePage(title=title, content=req.content, position=next_position(db))
|
||||
db.add(page)
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.patch("/{page_id}", response_model=NotePageResponse)
|
||||
def update_page(page_id: int, req: NotePageUpdate, db: DBSession = Depends(get_db)):
|
||||
page = db.get(NotePage, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Note page not found")
|
||||
if req.title is not None:
|
||||
title = req.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=400, detail="title is empty")
|
||||
page.title = title
|
||||
if req.content is not None:
|
||||
page.content = req.content
|
||||
db.commit()
|
||||
db.refresh(page)
|
||||
return page
|
||||
|
||||
|
||||
@router.delete("/{page_id}", status_code=204)
|
||||
def delete_page(page_id: int, db: DBSession = Depends(get_db)):
|
||||
page = db.get(NotePage, page_id)
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Note page not found")
|
||||
db.delete(page)
|
||||
db.commit()
|
||||
@@ -37,6 +37,10 @@ LOCAL_KB_EMBEDDING_MODEL: str = os.getenv("LOCAL_KB_EMBEDDING_MODEL", "text-embe
|
||||
LOCAL_KB_TOP_K: int = int(os.getenv("LOCAL_KB_TOP_K", "3"))
|
||||
LOCAL_KB_MIN_SCORE: float = float(os.getenv("LOCAL_KB_MIN_SCORE", "0.75"))
|
||||
|
||||
# 共有ノート(ユーザーとAIが共同編集するノート)
|
||||
# read_note ツールが1回に返す本文の最大文字数
|
||||
NOTES_TOOL_CONTENT_LIMIT: int = int(os.getenv("NOTES_TOOL_CONTENT_LIMIT", "4000"))
|
||||
|
||||
# Amazon Bedrock
|
||||
# 以下はすべて LiteLLM(boto3) が os.environ から直接読み取るため、.env に書くだけで自動的に有効になる。
|
||||
# IAM経由(推奨): AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定する。
|
||||
|
||||
+32
-16
@@ -2,13 +2,17 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from typing import AsyncIterator, AsyncGenerator
|
||||
from typing import TYPE_CHECKING, AsyncIterator, AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import litellm
|
||||
from app.core.config import DEFAULT_MODEL, DEBUG
|
||||
from app.core.notes import NOTE_TOOLS, execute_note_tool
|
||||
from app.core.search.websearch import WEB_SEARCH_TOOL, search_web
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
litellm.drop_params = True
|
||||
if DEBUG:
|
||||
litellm._turn_on_debug()
|
||||
@@ -103,32 +107,38 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
|
||||
return [item["embedding"] for item in response.data]
|
||||
|
||||
|
||||
async def resolve_web_search(
|
||||
async def resolve_tool_calls(
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
db: "DBSession | None" = None,
|
||||
max_rounds: int = 2,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""必要に応じてLLMにweb_searchツールを呼ばせ、検索結果を会話に組み込んだ上でメッセージ列を返す。
|
||||
) -> tuple[list[dict], list[dict], bool]:
|
||||
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
|
||||
|
||||
モデルがtool callingに対応していない場合は何もせず元のmessagesをそのまま返す。
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト)
|
||||
扱うツールは web_search と共有ノート(read_note / write_note)。ノート系は db が
|
||||
渡されたときだけ提示する。モデルがtool callingに対応していない場合は何もせず
|
||||
元のmessagesをそのまま返す。
|
||||
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
|
||||
"""
|
||||
used_model = model or DEFAULT_MODEL
|
||||
working_messages = list(messages)
|
||||
searches: list[dict] = []
|
||||
notes_changed = False
|
||||
|
||||
tools = [WEB_SEARCH_TOOL] + (NOTE_TOOLS if db is not None else [])
|
||||
|
||||
for _ in range(max_rounds):
|
||||
try:
|
||||
response = await litellm.acompletion(
|
||||
model=used_model,
|
||||
messages=working_messages,
|
||||
tools=[WEB_SEARCH_TOOL],
|
||||
tools=tools,
|
||||
tool_choice="auto",
|
||||
)
|
||||
except Exception:
|
||||
logger.info("Model %s does not support tool calling; skipping web search", used_model)
|
||||
return messages, searches
|
||||
logger.info("Model %s does not support tool calling; skipping tools", used_model)
|
||||
return messages, searches, notes_changed
|
||||
|
||||
choice_message = response.choices[0].message
|
||||
tool_calls = getattr(choice_message, "tool_calls", None)
|
||||
@@ -153,13 +163,19 @@ async def resolve_web_search(
|
||||
args = json.loads(tc.function.arguments or "{}")
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
query = args.get("query", "")
|
||||
results = await search_web(query)
|
||||
if results:
|
||||
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
|
||||
searches.append({"query": query, "results": results})
|
||||
|
||||
name = tc.function.name
|
||||
if name in ("read_note", "write_note") and db is not None:
|
||||
tool_text, changed = execute_note_tool(db, name, args)
|
||||
notes_changed = notes_changed or changed
|
||||
else:
|
||||
tool_text = "検索結果が見つかりませんでした。"
|
||||
query = args.get("query", "")
|
||||
results = await search_web(query)
|
||||
if results:
|
||||
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
|
||||
searches.append({"query": query, "results": results})
|
||||
else:
|
||||
tool_text = "検索結果が見つかりませんでした。"
|
||||
|
||||
working_messages.append({
|
||||
"role": "tool",
|
||||
@@ -167,4 +183,4 @@ async def resolve_web_search(
|
||||
"content": tool_text,
|
||||
})
|
||||
|
||||
return working_messages, searches
|
||||
return working_messages, searches, notes_changed
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.core import llm
|
||||
from app.core.memory_kb import knowledge
|
||||
from app.core.memory_kb import local_knowledge
|
||||
from app.core.memory_kb.tagging import tag_memory
|
||||
from app.core import notes
|
||||
|
||||
|
||||
async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
@@ -18,6 +19,11 @@ async def get_context_messages(db: DBSession, session_id: str) -> list[dict]:
|
||||
if knowledge_context:
|
||||
context.append({"role": "system", "content": knowledge_context})
|
||||
|
||||
# 共有ノートは「タイトル一覧」だけを常時見せ、本文は read_note ツールで読ませる
|
||||
notes_context = notes.get_notes_context(db)
|
||||
if notes_context:
|
||||
context.append({"role": "system", "content": notes_context})
|
||||
|
||||
if messages:
|
||||
latest_user_message = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
local_kb_context = await local_knowledge.get_local_kb_context(db, latest_user_message)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""ユーザーとAIが共同で編集する共有ノート。
|
||||
|
||||
システムプロンプトにはページの「タイトル一覧」だけを注入し、本文はモデルが
|
||||
read_note ツールで必要なときだけ読み出す。書き込みは write_note ツールで行う。
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from app.core.config import NOTES_TOOL_CONTENT_LIMIT
|
||||
from app.db.models import NotePage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
READ_NOTE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_note",
|
||||
"description": "共有ノートの特定ページの本文を読み出す。システムプロンプトに提示されたタイトル一覧の中から、"
|
||||
"今の会話に関係しそうなページがあるときに使う。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "読み出すページのタイトル(タイトル一覧に載っているもの)"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
WRITE_NOTE_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_note",
|
||||
"description": "共有ノートのページに書き込む。ユーザーからノートへの記録・追記・整理を頼まれたときに使う。"
|
||||
"存在しないタイトルを指定した場合は新しいページを作成する。"
|
||||
"既存ページを replace で上書きすると元の内容は失われるので、追記でよい場合は必ず append を使うこと。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "書き込む対象のページタイトル"},
|
||||
"content": {"type": "string", "description": "書き込む本文(Markdown可)"},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["append", "replace"],
|
||||
"description": "append=末尾に追記(既定) / replace=ページ全体を置き換え",
|
||||
},
|
||||
},
|
||||
"required": ["title", "content"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
NOTE_TOOLS = [READ_NOTE_TOOL, WRITE_NOTE_TOOL]
|
||||
|
||||
|
||||
def _find_page(db: DBSession, title: str) -> NotePage | None:
|
||||
"""タイトルでページを探す。完全一致 → 大文字小文字・前後空白を無視した一致 の順に試す。"""
|
||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
for page in pages:
|
||||
if page.title == title:
|
||||
return page
|
||||
normalized = title.strip().casefold()
|
||||
for page in pages:
|
||||
if page.title.strip().casefold() == normalized:
|
||||
return page
|
||||
return None
|
||||
|
||||
|
||||
def next_position(db: DBSession) -> int:
|
||||
"""新規ページを末尾に置くための position を返す。"""
|
||||
last = db.query(NotePage).order_by(NotePage.position.desc()).first()
|
||||
return (last.position + 1) if last else 0
|
||||
|
||||
|
||||
def get_notes_context(db: DBSession) -> str | None:
|
||||
"""共有ノートのタイトル一覧をシステムプロンプト用のテキストとして返す。"""
|
||||
pages = db.query(NotePage).order_by(NotePage.position, NotePage.id).all()
|
||||
|
||||
lines = [
|
||||
"【共有ノート】",
|
||||
"ユーザーとあなたが共同で編集できるノートです。以下のページがあります。",
|
||||
"",
|
||||
]
|
||||
if pages:
|
||||
for page in pages:
|
||||
preview = " ".join(page.content.split())[:40]
|
||||
suffix = f" — {preview}…" if preview else " — (空)"
|
||||
lines.append(f"- {page.title}{suffix}")
|
||||
else:
|
||||
lines.append("- (まだページがありません)")
|
||||
lines += [
|
||||
"",
|
||||
"本文はここには含まれていません。内容が必要なときは read_note ツールで読み出してください。",
|
||||
"ノートへの記録・追記を頼まれたときは write_note ツールで書き込んでください"
|
||||
"(新しいタイトルを指定すればページが新規作成されます)。",
|
||||
"ユーザーが明示的に頼んでいないのに勝手に書き込まないこと。",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def read_note(db: DBSession, title: str) -> str:
|
||||
"""ページ本文をツール結果用のテキストとして返す。"""
|
||||
page = _find_page(db, title)
|
||||
if not page:
|
||||
available = ", ".join(p.title for p in db.query(NotePage).order_by(NotePage.position, NotePage.id).all())
|
||||
return f"「{title}」というページはありません。現在のページ: {available or '(なし)'}"
|
||||
if not page.content.strip():
|
||||
return f"ページ「{page.title}」は空です。"
|
||||
content = page.content
|
||||
if len(content) > NOTES_TOOL_CONTENT_LIMIT:
|
||||
content = content[:NOTES_TOOL_CONTENT_LIMIT] + "\n…(以下省略)"
|
||||
return f"ページ「{page.title}」の本文:\n\n{content}"
|
||||
|
||||
|
||||
_LIST_ITEM = re.compile(r"^\s*([-*+]|\d+[.)])\s")
|
||||
|
||||
|
||||
def _append(existing: str, addition: str) -> str:
|
||||
"""既存本文に追記する。箇条書き同士をつなぐ場合だけ空行を挟まない。"""
|
||||
if not existing.strip():
|
||||
return addition
|
||||
body = existing.rstrip()
|
||||
last_line = body.rsplit("\n", 1)[-1]
|
||||
first_line = addition.lstrip("\n").split("\n", 1)[0]
|
||||
separator = "\n" if _LIST_ITEM.match(last_line) and _LIST_ITEM.match(first_line) else "\n\n"
|
||||
return f"{body}{separator}{addition}"
|
||||
|
||||
|
||||
def write_note(db: DBSession, title: str, content: str, mode: str = "append") -> str:
|
||||
"""ページに書き込む。存在しなければ新規作成する。"""
|
||||
if not title.strip():
|
||||
return "タイトルが空のため書き込めませんでした。"
|
||||
|
||||
page = _find_page(db, title)
|
||||
if page is None:
|
||||
page = NotePage(title=title.strip(), content=content, position=next_position(db))
|
||||
db.add(page)
|
||||
db.commit()
|
||||
return f"新しいページ「{page.title}」を作成し、本文を書き込みました。"
|
||||
|
||||
if mode == "replace":
|
||||
page.content = content
|
||||
result = f"ページ「{page.title}」の内容を置き換えました。"
|
||||
else:
|
||||
page.content = _append(page.content, content)
|
||||
result = f"ページ「{page.title}」に追記しました。"
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
|
||||
def execute_note_tool(db: DBSession, name: str, args: dict) -> tuple[str, bool]:
|
||||
"""ノート系ツールを実行する。戻り値: (ツール結果テキスト, ノートを変更したか)。"""
|
||||
try:
|
||||
if name == "read_note":
|
||||
return read_note(db, args.get("title", "")), False
|
||||
if name == "write_note":
|
||||
result = write_note(
|
||||
db,
|
||||
args.get("title", ""),
|
||||
args.get("content", ""),
|
||||
args.get("mode", "append"),
|
||||
)
|
||||
return result, True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("Note tool %s failed", name)
|
||||
return f"ノート操作に失敗しました: {e}", False
|
||||
return f"未知のツールです: {name}", False
|
||||
@@ -104,6 +104,22 @@ class LocalKnowledgeDoc(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class NotePage(Base):
|
||||
"""ユーザーとAIが共同で編集する共有ノートの1ページ。UIではタブとして表示する。
|
||||
|
||||
ページのタイトル一覧は毎回システムプロンプトに注入され、本文はモデルが
|
||||
read_note ツールで読み出す(app.core.notes を参照)。
|
||||
"""
|
||||
__tablename__ = "note_pages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
position: Mapped[int] = mapped_column(Integer, default=0) # タブの並び順
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""GUIの設定画面から編集する接続情報(APIキー等)。app.core.settings_store が読み書きする。"""
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import auth as auth_api
|
||||
from app.api import chat, logs, memory, system
|
||||
from app.api import chat, logs, memory, notes, system
|
||||
from app.api import models as models_api
|
||||
from app.api import settings as settings_api
|
||||
from app.core import settings_store
|
||||
@@ -93,6 +93,7 @@ app.add_middleware(
|
||||
app.include_router(auth_api.router)
|
||||
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(notes.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(system.router, dependencies=[Depends(require_site_session)])
|
||||
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
|
||||
|
||||
Reference in New Issue
Block a user