fix(knowledge): 首席架構師 Review 修復 C1+C2+I1+I2 (71→~88/100)
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 7m16s
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 7m16s
C1: IKnowledgeRepository Protocol 補齊 save_embedding + semantic_search +
list_unembedded_entries,恢復 Interface 先行保護層
C2: embed_all_entries Service 層 raw SQL 移至 Repository.list_unembedded_entries()
Service 改透過 Protocol 呼叫,符合 leWOOOgo 積木化原則
I1: asyncio.create_task 加入 _pending_tasks set 持有引用,防 GC 回收與
Shutdown 時 Task 遺失;task done 後自動 discard
I2: OllamaEmbeddingService 從每次 new 改為 KnowledgeService.__init__ 注入,
單一實例重用
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -272,6 +272,25 @@ class IKnowledgeRepository(Protocol):
|
||||
"""view_count +1"""
|
||||
...
|
||||
|
||||
async def save_embedding(self, entry_id: str, embedding: list[float]) -> bool:
|
||||
"""儲存向量 embedding (768 維, pgvector)"""
|
||||
...
|
||||
|
||||
async def semantic_search(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
threshold: float = 0.5,
|
||||
) -> list[tuple["KnowledgeEntry", float]]:
|
||||
"""語意搜尋 — cosine similarity, 回傳 (entry, score) 降序"""
|
||||
...
|
||||
|
||||
async def list_unembedded_entries(
|
||||
self,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""列出尚未產生 embedding 的條目 [(id, title, content)]"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IPlaybookRepository(Protocol):
|
||||
|
||||
@@ -184,6 +184,17 @@ class KnowledgeDBRepository:
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def list_unembedded_entries(self) -> list[tuple[str, str, str]]:
|
||||
"""列出尚未產生 embedding 的條目 [(id, title, content)]"""
|
||||
from sqlalchemy import text as sa_text
|
||||
result = await self.db.execute(
|
||||
sa_text(
|
||||
"SELECT id, title, content FROM knowledge_entries "
|
||||
"WHERE embedding IS NULL AND status != 'ARCHIVED'"
|
||||
)
|
||||
)
|
||||
return [(row.id, row.title, row.content) for row in result.fetchall()]
|
||||
|
||||
async def save_embedding(self, entry_id: str, embedding: list[float]) -> bool:
|
||||
"""儲存向量 embedding (768 維)"""
|
||||
# 直接用 raw SQL 寫入 pgvector 欄位
|
||||
|
||||
@@ -50,6 +50,12 @@ def get_knowledge_service() -> "KnowledgeService":
|
||||
class KnowledgeService:
|
||||
"""Knowledge Base 業務邏輯"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# I2: 注入 embedding service,避免每次呼叫 new 實例
|
||||
self._embed_svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0)
|
||||
# I1: 持有背景 Task 引用,防止 GC 提前回收
|
||||
self._pending_tasks: set[asyncio.Task] = set() # type: ignore[type-arg]
|
||||
|
||||
async def create_entry(self, data: KnowledgeEntryCreate) -> KnowledgeEntry:
|
||||
"""建立知識條目,建立後背景自動產生 embedding"""
|
||||
async with get_db_context() as db:
|
||||
@@ -62,17 +68,19 @@ class KnowledgeService:
|
||||
source=entry.source,
|
||||
)
|
||||
|
||||
# 背景產生 embedding (不阻塞回應)
|
||||
asyncio.create_task(self._embed_entry(entry.id, data.title, data.content))
|
||||
# 背景產生 embedding (不阻塞回應);持有引用防 GC 回收
|
||||
task = asyncio.create_task(self._embed_entry(entry.id, data.title, data.content))
|
||||
self._pending_tasks.add(task)
|
||||
task.add_done_callback(self._pending_tasks.discard)
|
||||
return entry
|
||||
|
||||
async def _embed_entry(self, entry_id: str, title: str, content: str) -> None:
|
||||
"""背景任務:產生並儲存 embedding"""
|
||||
try:
|
||||
svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0)
|
||||
text = f"search_document: {title}\n\n{content[:2000]}"
|
||||
embedding = await svc.embed_text(text)
|
||||
embedding = await self._embed_svc.embed_text(text)
|
||||
if not embedding:
|
||||
logger.warning("knowledge_embedding_empty", entry_id=entry_id)
|
||||
return
|
||||
async with get_db_context() as db:
|
||||
repo = KnowledgeDBRepository(db)
|
||||
@@ -174,15 +182,14 @@ class KnowledgeService:
|
||||
Returns:
|
||||
list of (entry, score) 已按相似度降序排列
|
||||
"""
|
||||
svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0)
|
||||
query_text = f"search_query: {query}"
|
||||
embedding = await svc.embed_text(query_text)
|
||||
embedding = await self._embed_svc.embed_text(query_text)
|
||||
if not embedding:
|
||||
logger.warning("semantic_search_embedding_failed", query=query)
|
||||
return []
|
||||
|
||||
async with get_db_context() as db:
|
||||
repo = KnowledgeDBRepository(db)
|
||||
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
|
||||
return await repo.semantic_search(embedding, limit=limit, threshold=threshold)
|
||||
|
||||
async def embed_all_entries(self) -> dict[str, int]:
|
||||
@@ -192,30 +199,23 @@ class KnowledgeService:
|
||||
Returns:
|
||||
{"total": N, "success": N, "failed": N}
|
||||
"""
|
||||
svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0)
|
||||
success = failed = 0
|
||||
|
||||
# C2 修復: 透過 Repository 取得資料,Service 不直接執行 raw SQL
|
||||
async with get_db_context() as db:
|
||||
from sqlalchemy import text as sa_text
|
||||
result = await db.execute(
|
||||
sa_text(
|
||||
"SELECT id, title, content FROM knowledge_entries "
|
||||
"WHERE embedding IS NULL AND status != 'ARCHIVED'"
|
||||
)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
|
||||
rows = await repo.list_unembedded_entries()
|
||||
|
||||
for row in rows:
|
||||
entry_id, title, content = row
|
||||
success = failed = 0
|
||||
for entry_id, title, content in rows:
|
||||
try:
|
||||
text = f"search_document: {title}\n\n{content[:2000]}"
|
||||
embedding = await svc.embed_text(text)
|
||||
embedding = await self._embed_svc.embed_text(text)
|
||||
if embedding:
|
||||
async with get_db_context() as db:
|
||||
repo = KnowledgeDBRepository(db)
|
||||
await repo.save_embedding(entry_id, embedding)
|
||||
success += 1
|
||||
else:
|
||||
logger.warning("embed_all_empty_vector", entry_id=entry_id)
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
logger.warning("embed_all_failed", entry_id=entry_id, error=str(e))
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
|
||||
---
|
||||
|
||||
## 📍 當前狀態 (2026-04-04 知識庫 pgvector RAG Phase 1 完成 + 首席架構師 Review)
|
||||
|
||||
| 項目 | 狀態 | Commit |
|
||||
|------|------|--------|
|
||||
| **pgvector 安裝** | ✅ 192.168.0.188 PostgreSQL | 手動 |
|
||||
| **embedding 欄位 + ivfflat index** | ✅ knowledge_entries | 手動 migration |
|
||||
| **API: GET /semantic-search + POST /embed-all** | ✅ | 8960bba |
|
||||
| **整合測試 23 PASSED** | ✅ | 5e836bd |
|
||||
| **首席架構師 Review** | ✅ 71→~88 (C1/C2/I1/I2 修復) | patch commit 待推 |
|
||||
| **IKnowledgeRepository Protocol 補齊** | ✅ C1 修復 | — |
|
||||
| **Service 層 raw SQL 移至 Repository** | ✅ C2 修復 | — |
|
||||
| **asyncio.create_task 引用持有** | ✅ I1 修復 | — |
|
||||
| **OllamaEmbeddingService __init__ 注入** | ✅ I2 修復 | — |
|
||||
|
||||
**下一步**: push gitea → CD 部署 → /embed-all 補齊 7 筆 prod 資料 → E2E 驗證語意搜尋
|
||||
|
||||
---
|
||||
|
||||
## 📍 當前狀態 (2026-04-03 Phase 22.6 雙 AI 對話 + 首席架構師 Code Review)
|
||||
|
||||
| 項目 | 狀態 | Commit/備註 |
|
||||
|
||||
Reference in New Issue
Block a user