Add Docker/Lightsail deployment, web search summarization, and fix vite peer dep

- Add backend/frontend Dockerfiles, nginx reverse proxy config, and
  docker-compose.yml to run the app on AWS Lightsail; document the setup
  in docs/deploy-lightsail.md. Fix a .gitignore path anchoring bug that
  left backend/knowledge_base/web_search/ untracked instead of ignored.
- Summarize web search results into structured Markdown via LLM before
  saving them to the local knowledge base, and support regenerating
  existing entries from their source URLs (backend/app/core/memory_kb).
- Downgrade vite to ^6 to match @vitejs/plugin-vue's peer dependency
  range, fixing a failing `npm ci` (found while testing the Docker build).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 02:45:09 +09:00
co-authored by Claude Sonnet 5
parent 347230e3c8
commit 8e01458bb2
18 changed files with 1375 additions and 213 deletions
+10 -1
View File
@@ -11,6 +11,7 @@ from app.db.models import Session, Message
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.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL
from app.core.memory_kb.tagging import tag_message_by_id
@@ -77,6 +78,13 @@ def delete_session(session_id: str, db: DBSession = Depends(get_db)):
db.commit()
@router.post("/knowledge/regenerate")
async def regenerate_knowledge(db: DBSession = Depends(get_db)):
"""既存のKnowledgeEntryを出典URLから取得し直し、構造化要約に再生成する(一括実行用)。"""
count = await knowledge.regenerate_from_sources(db)
return {"regenerated": count}
@router.post("/stream")
async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
# セッション取得 or 新規作成
@@ -126,9 +134,10 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
]
yield sse({"type": "sources", "value": sources})
for s in searches:
summary = "\n".join(r["content"][:300] for r in s["results"] if r["content"])
summary = await knowledge.summarize_results(s["query"], s["results"])
if summary:
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
async for chunk in llm.stream_chat(
+2
View File
@@ -16,6 +16,8 @@ TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
TAVILY_API_KEY: str | None = os.getenv("TAVILY_API_KEY")
# 蓄積した知識をシステムプロンプトに注入する際の最大件数
KNOWLEDGE_CONTEXT_LIMIT: int = int(os.getenv("KNOWLEDGE_CONTEXT_LIMIT", "15"))
# Web検索結果を構造的な要約にまとめる際に使うモデル(未設定ならDEFAULT_MODELを使用)
SEARCH_SUMMARY_MODEL: str | None = os.getenv("SEARCH_SUMMARY_MODEL")
# ローカルMarkdownナレッジベース(話題に応じて関連ファイルをシステムプロンプトに注入)
LOCAL_KB_DIR: str = os.getenv("LOCAL_KB_DIR", "knowledge_base")
+126 -1
View File
@@ -1,8 +1,133 @@
import asyncio
import logging
import re
import httpx
import trafilatura
from sqlalchemy.orm import Session as DBSession
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT
from app.core import llm
from app.core.config import KNOWLEDGE_CONTEXT_LIMIT, SEARCH_SUMMARY_MODEL
from app.core.memory_kb import local_knowledge
from app.db.models import KnowledgeEntry, KnowledgeSource
logger = logging.getLogger(__name__)
_TAG_STRIP_RE = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.S | re.I)
_HTML_TAG_RE = re.compile(r"<[^>]+>")
_SUMMARY_PROMPT = (
"以下はWeb検索「{query}」で得られた複数の検索結果です。"
"これらの内容を整理し、Markdown形式で構造的にまとめてください。\n"
"- 検索結果間で重複する情報はまとめ、矛盾する情報があれば併記する\n"
"- 見出し(##)やトピックごとの箇条書きを使い、要点が一目で分かるようにする\n"
"- 各項目の末尾に (出典: サイト名) を付ける\n"
"- 説明文や前置きは不要。まとめ本文のみを出力する\n\n"
"検索結果:\n{results}"
)
async def summarize_results(query: str, results: list[dict], model: str | None = None) -> str:
"""Web検索結果(title/url/content のリスト)をLLMで構造的なMarkdown要約にまとめる。
要約に失敗した場合は空文字を返す(生のスクレイピング結果を未加工のままファイルに
書き込むと、ページに残ったスクリプト断片等がセキュリティソフトに誤検知されることがあるため、
フォールバックとして生テキストを書き出すことはしない)。
"""
if not results:
return ""
raw = "\n\n".join(
f"### {r.get('title') or r['url']}\n{r.get('content', '')[:1500]}"
for r in results
if r.get("content")
)
if not raw:
return ""
prompt = _SUMMARY_PROMPT.format(query=query, results=raw)
try:
return await llm.complete(
[{"role": "user", "content": prompt}],
model=model or SEARCH_SUMMARY_MODEL,
)
except Exception:
logger.exception("Failed to summarize search results for query=%r", query)
return ""
def _strip_tags(html: str) -> str:
"""trafilataruがメインコンテンツを抽出できなかった場合の素朴なフォールバック。"""
text = _TAG_STRIP_RE.sub(" ", html)
text = _HTML_TAG_RE.sub(" ", text)
return re.sub(r"\s+", " ", text).strip()
async def _fetch_page_text(url: str, max_chars: int = 4000) -> str:
"""URLからヘッダー・サイドバー・フッター・広告などを除いた本文だけを抽出する。取得失敗時は空文字。"""
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
res = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
res.raise_for_status()
html = res.text
except Exception:
logger.warning("Failed to fetch page for regeneration: %s", url)
return ""
# trafilatura.extract()はCPU負荷のある同期処理のため、そのままawait内で呼ぶと
# asyncioのイベントループ全体をブロックしてしまう(チャット応答等が止まる)。
# asyncio.to_thread()で別スレッドに逃がす。
main_text = await asyncio.to_thread(
trafilatura.extract,
html,
url=url,
include_comments=False,
include_tables=True,
favor_precision=True,
)
text = main_text if main_text else _strip_tags(html)
return text[:max_chars]
async def regenerate_from_sources(db: DBSession) -> int:
"""既存のKnowledgeEntryについて、出典URLから本文を取得し直し、構造的な要約に再生成する。
KnowledgeSourceにはURLしか保存されていないため、当時の検索結果本文ではなく
現時点のページ内容を取得し直す点に注意(記事が更新・削除されている場合がある)。
戻り値: 再生成できた件数
"""
entries = db.query(KnowledgeEntry).all()
count = 0
for entry in entries:
if not entry.sources:
continue
contents = await asyncio.gather(*(_fetch_page_text(s.url) for s in entry.sources))
results = [
{"title": s.title, "url": s.url, "content": content}
for s, content in zip(entry.sources, contents)
if content
]
if not results:
continue
new_summary = await summarize_results(entry.query, results)
if not new_summary:
continue
old_summary = entry.summary
entry.summary = new_summary
db.commit()
local_knowledge.delete_search_result_file(entry.query, old_summary)
local_knowledge.save_search_result(
entry.query, new_summary, [{"title": s.title, "url": s.url} for s in entry.sources],
)
count += 1
logger.info("Regenerated %d knowledge entries from their sources", count)
return count
def save_knowledge(db: DBSession, query: str, summary: str, sources: list[dict]) -> KnowledgeEntry:
"""Web検索で得た知見をDBに保存する。"""
+40 -1
View File
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session as DBSession
from app.core import llm
from app.core.config import LOCAL_KB_DIR, LOCAL_KB_EMBEDDING_MODEL, LOCAL_KB_MIN_SCORE, LOCAL_KB_TOP_K
from app.db.models import LocalKnowledgeDoc
from app.db.models import KnowledgeEntry, LocalKnowledgeDoc
logger = logging.getLogger(__name__)
@@ -43,6 +43,45 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
return dot / (norm_a * norm_b)
def save_search_result(query: str, summary: str, sources: list[dict]) -> None:
"""Web検索結果をローカルナレッジベースにMarkdownとして永続化する。
同じクエリ・内容の組み合わせは同じファイル名になるため重複保存されない。
保存しただけでは埋め込みは計算されず、次回の sync_local_kb() 実行時(=次の会話)に
自動的に埋め込まれ、以後の会話で話題に応じて引き出せるようになる。
"""
kb_dir = _kb_dir() / "web_search"
kb_dir.mkdir(parents=True, exist_ok=True)
filename = _hash(query + summary)[:16] + ".md"
urls = "\n".join(f"- [{s.get('title') or s['url']}]({s['url']})" for s in sources if s.get("url"))
content = f"# {query}\n\n{summary}\n\n## 出典\n{urls}\n"
(kb_dir / filename).write_text(content, encoding="utf-8")
def delete_search_result_file(query: str, summary: str) -> None:
"""save_search_result()が書き出したファイルを削除する。要約を再生成して内容が変わった際の旧ファイル掃除用。"""
filename = _hash(query + summary)[:16] + ".md"
(_kb_dir() / "web_search" / filename).unlink(missing_ok=True)
def backfill_search_results(db: DBSession) -> int:
"""DBに蓄積済みの過去のWeb検索結果(KnowledgeEntry)をすべてMarkdownファイルとして書き出す。
save_search_result()と同じファイル名規則(内容のハッシュ)を使うため、
すでにmd化済みのものは上書きされるだけで重複は増えない。
戻り値: 書き出した件数
"""
entries = db.query(KnowledgeEntry).all()
count = 0
for entry in entries:
sources = [{"title": s.title, "url": s.url} for s in entry.sources]
save_search_result(entry.query, entry.summary, sources)
count += 1
logger.info("Backfilled %d search result(s) to local knowledge base", count)
return count
async def sync_local_kb(db: DBSession) -> None:
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
削除されたファイルに対応するレコードも削除する。