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:
+10
-1
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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に保存する。"""
|
||||
|
||||
@@ -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:
|
||||
"""ナレッジベースディレクトリをスキャンし、新規/変更ファイルの埋め込みを更新する。
|
||||
削除されたファイルに対応するレコードも削除する。
|
||||
|
||||
Reference in New Issue
Block a user