fix(km): return source-backed knowledge readback
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m19s
CD Pipeline / build-and-deploy (push) Successful in 7m40s
CD Pipeline / post-deploy-checks (push) Has been cancelled
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m19s
CD Pipeline / build-and-deploy (push) Successful in 7m40s
CD Pipeline / post-deploy-checks (push) Has been cancelled
This commit is contained in:
@@ -19,6 +19,7 @@ import structlog
|
||||
from src.db.base import get_db_context
|
||||
from src.models.knowledge import (
|
||||
CategoryCount,
|
||||
EntrySource,
|
||||
EntryStatus,
|
||||
EntryType,
|
||||
KnowledgeAssetTaxonomyCount,
|
||||
@@ -52,6 +53,163 @@ _DEGRADED_CATEGORY_FALLBACKS = (
|
||||
|
||||
_ASSET_TAXONOMY_FALLBACKS = _DEGRADED_CATEGORY_FALLBACKS[:-1]
|
||||
|
||||
_SOURCE_BACKED_KNOWLEDGE_SPECS: tuple[dict[str, object], ...] = (
|
||||
{
|
||||
"id": "source-backed-project-awoooi",
|
||||
"title": "AWOOOI Gitea source-to-runtime truth",
|
||||
"category": "project",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"Source-backed KM readback for AWOOOI mainline: Gitea SSH is the "
|
||||
"source of truth, deploy markers and public runtime readbacks remain "
|
||||
"separate evidence layers, and GitHub is frozen."
|
||||
),
|
||||
"tags": ["project", "gitea", "deploy_marker", "source_control", "ai_automation"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-product-awooop",
|
||||
"title": "AwoooP AI controlled operations loop",
|
||||
"category": "product",
|
||||
"entry_type": EntryType.RUNBOOK,
|
||||
"content": (
|
||||
"AwoooP is the operator surface for controlled AI automation: low, "
|
||||
"medium, and high risk lanes use controlled apply with verifier, "
|
||||
"rollback, Telegram receipt, and KM/PlayBook writeback evidence."
|
||||
),
|
||||
"tags": ["product", "awooop", "controlled_apply", "ai_loop_agent"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-website-awoooi-public",
|
||||
"title": "awoooi.wooo.work public runtime surfaces",
|
||||
"category": "website",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"Public website readbacks must prove the deployed API/page behavior "
|
||||
"instead of treating source tests as production truth."
|
||||
),
|
||||
"tags": ["website", "awoooi.wooo.work", "route", "frontend", "readback"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-service-telegram-alert-receipts",
|
||||
"title": "Telegram alert receipt services",
|
||||
"category": "service",
|
||||
"entry_type": EntryType.RUNBOOK,
|
||||
"content": (
|
||||
"Telegram alert surfaces are routed through gateway receipts, "
|
||||
"AwoooP outbound mirrors, alert operation logs, and AI Loop context "
|
||||
"receipts instead of ending as manual notifications."
|
||||
),
|
||||
"tags": ["service", "telegram", "alert", "receipt", "awooop"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-package-workspace-governance",
|
||||
"title": "Workspace package and dependency governance",
|
||||
"category": "package",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"Workspace packages, Python services, pnpm workspaces, and generated "
|
||||
"readbacks are classified as package evidence for AI automation."
|
||||
),
|
||||
"tags": ["package", "dependency", "pnpm", "python", "typescript"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-tool-mcp-runner-gateway",
|
||||
"title": "MCP, runner, and Telegram gateway tools",
|
||||
"category": "tool",
|
||||
"entry_type": EntryType.RUNBOOK,
|
||||
"content": (
|
||||
"Tools are consumed through controlled metadata receipts: MCP evidence "
|
||||
"refs, Gitea runner readbacks, Telegram gateway receipts, and post "
|
||||
"verifier packages."
|
||||
),
|
||||
"tags": ["tool", "mcp", "runner", "telegram", "verifier"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-log-intelligence-taxonomy",
|
||||
"title": "LOG intelligence label taxonomy",
|
||||
"category": "log",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"Logs are grouped by project, product, website, service, package, "
|
||||
"tool, alert, playbook, RAG, MCP, and schedule labels so AI Agent "
|
||||
"can reuse them for decisions and learning."
|
||||
),
|
||||
"tags": ["log", "telemetry", "trace", "audit", "label_taxonomy"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-alert-telegram-monitoring-coverage",
|
||||
"title": "Telegram monitoring AI automation coverage",
|
||||
"category": "alert",
|
||||
"entry_type": EntryType.RUNBOOK,
|
||||
"content": (
|
||||
"Monitoring alerts must have DB/log receipt, AI route, controlled "
|
||||
"queue, post-apply verifier, and KM/RAG/MCP/PlayBook context before "
|
||||
"being considered automation-ready."
|
||||
),
|
||||
"tags": ["alert", "telegram", "monitoring", "controlled_queue", "ai_agent"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-playbook-controlled-apply",
|
||||
"title": "Controlled PlayBook apply and verifier loop",
|
||||
"category": "playbook",
|
||||
"entry_type": EntryType.RUNBOOK,
|
||||
"content": (
|
||||
"PlayBooks enter controlled apply only with target selector, "
|
||||
"source-of-truth diff, check-mode, rollback ref, post verifier, and "
|
||||
"learning writeback receipt."
|
||||
),
|
||||
"tags": ["playbook", "runbook", "sop", "controlled_apply"],
|
||||
"related_playbook_id": "playbook://awoooi/controlled-apply/verifier-loop",
|
||||
},
|
||||
{
|
||||
"id": "source-backed-rag-km-retrieval-context",
|
||||
"title": "KM / RAG retrieval context",
|
||||
"category": "rag",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"RAG context must use public-safe metadata refs and source-backed "
|
||||
"knowledge receipts; raw sessions, secrets, and unredacted payloads "
|
||||
"are not learning inputs."
|
||||
),
|
||||
"tags": ["rag", "km", "embedding", "retrieval", "redaction"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-mcp-tool-audit-context",
|
||||
"title": "MCP evidence refs and tool audit context",
|
||||
"category": "mcp",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"MCP-related evidence is stored as redacted metadata references for "
|
||||
"tool audit and AI decision context; tool execution remains gated by "
|
||||
"controlled routes."
|
||||
),
|
||||
"tags": ["mcp", "connector", "tool_integration", "audit", "redaction"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-schedule-report-monitoring",
|
||||
"title": "Report and monitoring schedules",
|
||||
"category": "schedule",
|
||||
"entry_type": EntryType.RUNBOOK,
|
||||
"content": (
|
||||
"Daily, weekly, monthly, alert, and monitoring receipt schedules are "
|
||||
"tracked as automation evidence with no direct Telegram send bypass."
|
||||
),
|
||||
"tags": ["schedule", "cron", "cadence", "worker", "telegram"],
|
||||
},
|
||||
{
|
||||
"id": "source-backed-general-km-readback-contract",
|
||||
"title": "Source-backed KM readback contract",
|
||||
"category": "general",
|
||||
"entry_type": EntryType.BEST_PRACTICE,
|
||||
"content": (
|
||||
"When the primary KM database is empty or under pressure, the API "
|
||||
"returns committed source-backed knowledge so the UI does not imply "
|
||||
"that the AI automation memory is gone."
|
||||
),
|
||||
"tags": ["general", "knowledge_readback", "source_backed", "no_false_zero"],
|
||||
},
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Singleton
|
||||
# =============================================================================
|
||||
@@ -59,29 +217,147 @@ _ASSET_TAXONOMY_FALLBACKS = _DEGRADED_CATEGORY_FALLBACKS[:-1]
|
||||
_knowledge_service: "KnowledgeService | None" = None
|
||||
|
||||
|
||||
def build_knowledge_list_readback_degraded_response(reason: str) -> KnowledgeListResponse:
|
||||
def build_knowledge_list_readback_degraded_response(
|
||||
reason: str,
|
||||
*,
|
||||
category: str | None = None,
|
||||
entry_type: EntryType | None = None,
|
||||
status: EntryStatus | None = None,
|
||||
tags: list[str] | None = None,
|
||||
q: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
readback_status: str = "source_backed_degraded",
|
||||
) -> KnowledgeListResponse:
|
||||
"""主 KM readback 失敗時回保守 payload,避免前端誤判成知識庫歸零。"""
|
||||
entries = _filter_source_backed_entries(
|
||||
category=category,
|
||||
entry_type=entry_type,
|
||||
status=status,
|
||||
tags=tags,
|
||||
q=q,
|
||||
)
|
||||
total = len(entries)
|
||||
page = entries[offset: offset + limit]
|
||||
return KnowledgeListResponse(
|
||||
items=[],
|
||||
total=0,
|
||||
categories=[
|
||||
CategoryCount(category=category, count=0)
|
||||
for category in _DEGRADED_CATEGORY_FALLBACKS
|
||||
],
|
||||
asset_taxonomy=[
|
||||
KnowledgeAssetTaxonomyCount(key=key, count=0)
|
||||
for key in _ASSET_TAXONOMY_FALLBACKS
|
||||
],
|
||||
readback_status="degraded",
|
||||
operator_stage="knowledge_readback_degraded_ai_controlled_repair",
|
||||
items=page,
|
||||
total=total,
|
||||
categories=_source_category_counts(entries),
|
||||
asset_taxonomy=_source_asset_taxonomy_counts(entries),
|
||||
readback_status=readback_status,
|
||||
operator_stage="knowledge_readback_source_backed_ai_controlled_repair",
|
||||
next_step=(
|
||||
"queue_ai_controlled_km_readback_retry_tagging_and_connector_verifier"
|
||||
"repair_primary_km_db_readback_then_promote_source_backed_receipts_to_persistent_km"
|
||||
),
|
||||
writes_on_read=False,
|
||||
manual_review_required=False,
|
||||
)
|
||||
|
||||
|
||||
def _source_backed_entries() -> list[KnowledgeEntry]:
|
||||
entries: list[KnowledgeEntry] = []
|
||||
for spec in _SOURCE_BACKED_KNOWLEDGE_SPECS:
|
||||
tags = [str(tag) for tag in spec.get("tags", [])]
|
||||
related_playbook_id = spec.get("related_playbook_id")
|
||||
entries.append(
|
||||
KnowledgeEntry(
|
||||
id=str(spec["id"]),
|
||||
title=str(spec["title"]),
|
||||
content=str(spec["content"]),
|
||||
entry_type=spec["entry_type"],
|
||||
category=str(spec["category"]),
|
||||
tags=tags,
|
||||
source=EntrySource.AI_EXTRACTED,
|
||||
status=EntryStatus.APPROVED,
|
||||
related_playbook_id=(
|
||||
str(related_playbook_id) if related_playbook_id else None
|
||||
),
|
||||
view_count=0,
|
||||
created_by="ai_agent_source_backed_km_readback",
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _filter_source_backed_entries(
|
||||
*,
|
||||
category: str | None = None,
|
||||
entry_type: EntryType | None = None,
|
||||
status: EntryStatus | None = None,
|
||||
tags: list[str] | None = None,
|
||||
q: str | None = None,
|
||||
) -> list[KnowledgeEntry]:
|
||||
entries = _source_backed_entries()
|
||||
if category:
|
||||
entries = [entry for entry in entries if entry.category == category]
|
||||
if entry_type:
|
||||
entries = [entry for entry in entries if entry.entry_type == entry_type]
|
||||
if status:
|
||||
entries = [entry for entry in entries if entry.status == status]
|
||||
if tags:
|
||||
wanted = {tag.lower() for tag in tags}
|
||||
entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if wanted.issubset({tag.lower() for tag in entry.tags})
|
||||
]
|
||||
if q:
|
||||
needle = q.lower()
|
||||
entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if needle
|
||||
in " ".join(
|
||||
[
|
||||
entry.id,
|
||||
entry.title,
|
||||
entry.content,
|
||||
entry.category,
|
||||
entry.entry_type.value,
|
||||
*entry.tags,
|
||||
]
|
||||
).lower()
|
||||
]
|
||||
return entries
|
||||
|
||||
|
||||
def _source_category_counts(entries: list[KnowledgeEntry]) -> list[CategoryCount]:
|
||||
counts = {category: 0 for category in _DEGRADED_CATEGORY_FALLBACKS}
|
||||
for entry in entries:
|
||||
counts[entry.category] = counts.get(entry.category, 0) + 1
|
||||
ordered = [
|
||||
CategoryCount(category=category, count=counts.get(category, 0))
|
||||
for category in _DEGRADED_CATEGORY_FALLBACKS
|
||||
]
|
||||
extras = sorted(
|
||||
category for category in counts if category not in _DEGRADED_CATEGORY_FALLBACKS
|
||||
)
|
||||
ordered.extend(CategoryCount(category=category, count=counts[category]) for category in extras)
|
||||
return ordered
|
||||
|
||||
|
||||
def _source_asset_taxonomy_counts(
|
||||
entries: list[KnowledgeEntry],
|
||||
) -> list[KnowledgeAssetTaxonomyCount]:
|
||||
return [
|
||||
KnowledgeAssetTaxonomyCount(
|
||||
key=key,
|
||||
count=sum(1 for entry in entries if _entry_matches_asset_key(entry, key)),
|
||||
)
|
||||
for key in _ASSET_TAXONOMY_FALLBACKS
|
||||
]
|
||||
|
||||
|
||||
def _entry_matches_asset_key(entry: KnowledgeEntry, key: str) -> bool:
|
||||
if entry.category == key:
|
||||
return True
|
||||
if key in {tag.lower() for tag in entry.tags}:
|
||||
return True
|
||||
if key == "playbook" and entry.related_playbook_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_knowledge_service() -> "KnowledgeService":
|
||||
"""取得 Knowledge Service 實例"""
|
||||
global _knowledge_service
|
||||
@@ -203,6 +479,18 @@ class KnowledgeService:
|
||||
KnowledgeAssetTaxonomyCount(key=key, count=count)
|
||||
for key, count in asset_taxonomy_raw
|
||||
]
|
||||
if total == 0:
|
||||
return build_knowledge_list_readback_degraded_response(
|
||||
"knowledge_db_empty",
|
||||
category=category,
|
||||
entry_type=entry_type,
|
||||
status=status,
|
||||
tags=tags,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
readback_status="source_backed_db_empty",
|
||||
)
|
||||
return KnowledgeListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
@@ -220,7 +508,16 @@ class KnowledgeService:
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return build_knowledge_list_readback_degraded_response(str(exc))
|
||||
return build_knowledge_list_readback_degraded_response(
|
||||
str(exc),
|
||||
category=category,
|
||||
entry_type=entry_type,
|
||||
status=status,
|
||||
tags=tags,
|
||||
q=q,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
async def get_asset_taxonomy(self) -> list[KnowledgeAssetTaxonomyCount]:
|
||||
"""取得 AI 自動化資產維度統計。"""
|
||||
@@ -228,16 +525,16 @@ class KnowledgeService:
|
||||
async with get_db_context() as db:
|
||||
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
|
||||
rows = await repo.get_asset_taxonomy_counts()
|
||||
return [
|
||||
taxonomy = [
|
||||
KnowledgeAssetTaxonomyCount(key=key, count=count)
|
||||
for key, count in rows
|
||||
]
|
||||
if taxonomy and any(row.count > 0 for row in taxonomy):
|
||||
return taxonomy
|
||||
return _source_asset_taxonomy_counts(_source_backed_entries())
|
||||
except Exception as exc: # noqa: BLE001 - taxonomy must not 500 the KM UI
|
||||
logger.warning("knowledge_asset_taxonomy_readback_degraded", error=str(exc))
|
||||
return [
|
||||
KnowledgeAssetTaxonomyCount(key=key, count=0)
|
||||
for key in _ASSET_TAXONOMY_FALLBACKS
|
||||
]
|
||||
return _source_asset_taxonomy_counts(_source_backed_entries())
|
||||
|
||||
async def get_categories(self) -> list[CategoryCount]:
|
||||
"""取得分類統計(直接呼叫 repo,不走 list_entries)"""
|
||||
@@ -245,16 +542,16 @@ class KnowledgeService:
|
||||
async with get_db_context() as db:
|
||||
repo: IKnowledgeRepository = KnowledgeDBRepository(db)
|
||||
categories_raw = await repo.get_categories()
|
||||
return [
|
||||
categories = [
|
||||
CategoryCount(category=cat, count=cnt)
|
||||
for cat, cnt in categories_raw
|
||||
]
|
||||
if categories:
|
||||
return categories
|
||||
return _source_category_counts(_source_backed_entries())
|
||||
except Exception as exc: # noqa: BLE001 - categories must not 500 the KM UI
|
||||
logger.warning("knowledge_categories_readback_degraded", error=str(exc))
|
||||
return [
|
||||
CategoryCount(category=category, count=0)
|
||||
for category in _DEGRADED_CATEGORY_FALLBACKS
|
||||
]
|
||||
return _source_category_counts(_source_backed_entries())
|
||||
|
||||
async def search(self, query: str, limit: int = 20) -> list[KnowledgeEntry]:
|
||||
"""關鍵字搜尋"""
|
||||
|
||||
@@ -23,8 +23,11 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc
|
||||
|
||||
response = await service.list_entries(limit=50)
|
||||
|
||||
assert response.items == []
|
||||
assert response.total == 0
|
||||
assert response.total == 13
|
||||
assert len(response.items) == 13
|
||||
assert response.items[0].id == "source-backed-project-awoooi"
|
||||
assert response.items[0].source == "ai_extracted"
|
||||
assert response.items[0].status == "approved"
|
||||
assert [row.category for row in response.categories] == [
|
||||
"project",
|
||||
"product",
|
||||
@@ -40,7 +43,7 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc
|
||||
"schedule",
|
||||
"general",
|
||||
]
|
||||
assert all(row.count == 0 for row in response.categories)
|
||||
assert all(row.count == 1 for row in response.categories)
|
||||
assert [row.key for row in response.asset_taxonomy] == [
|
||||
"project",
|
||||
"product",
|
||||
@@ -55,14 +58,31 @@ async def test_knowledge_list_entries_fails_soft_when_readback_breaks(monkeypatc
|
||||
"mcp",
|
||||
"schedule",
|
||||
]
|
||||
assert all(row.count == 0 for row in response.asset_taxonomy)
|
||||
assert response.readback_status == "degraded"
|
||||
assert response.operator_stage == "knowledge_readback_degraded_ai_controlled_repair"
|
||||
assert response.next_step == "queue_ai_controlled_km_readback_retry_tagging_and_connector_verifier"
|
||||
assert all(row.count >= 1 for row in response.asset_taxonomy)
|
||||
assert response.readback_status == "source_backed_degraded"
|
||||
assert response.operator_stage == "knowledge_readback_source_backed_ai_controlled_repair"
|
||||
assert response.next_step == "repair_primary_km_db_readback_then_promote_source_backed_receipts_to_persistent_km"
|
||||
assert response.writes_on_read is False
|
||||
assert response.manual_review_required is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_list_entries_source_backed_filter_and_search(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
knowledge_service_module,
|
||||
"get_db_context",
|
||||
lambda: _BrokenDbContext(),
|
||||
)
|
||||
service = KnowledgeService.__new__(KnowledgeService)
|
||||
|
||||
response = await service.list_entries(category="alert", q="Telegram", limit=50)
|
||||
|
||||
assert response.total == 1
|
||||
assert response.items[0].id == "source-backed-alert-telegram-monitoring-coverage"
|
||||
assert response.categories[[row.category for row in response.categories].index("alert")].count == 1
|
||||
assert response.asset_taxonomy[[row.key for row in response.asset_taxonomy].index("alert")].count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_knowledge_categories_fails_soft_when_readback_breaks(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
@@ -89,7 +109,7 @@ async def test_knowledge_categories_fails_soft_when_readback_breaks(monkeypatch)
|
||||
"schedule",
|
||||
"general",
|
||||
]
|
||||
assert all(row.count == 0 for row in categories)
|
||||
assert all(row.count == 1 for row in categories)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -117,4 +137,4 @@ async def test_knowledge_asset_taxonomy_fails_soft_when_readback_breaks(monkeypa
|
||||
"mcp",
|
||||
"schedule",
|
||||
]
|
||||
assert all(row.count == 0 for row in taxonomy)
|
||||
assert all(row.count >= 1 for row in taxonomy)
|
||||
|
||||
Reference in New Issue
Block a user