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

This commit is contained in:
ogt
2026-07-09 16:29:22 +08:00
parent 6d2d59c027
commit f0a928432b
2 changed files with 351 additions and 34 deletions

View File

@@ -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]:
"""關鍵字搜尋"""

View File

@@ -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)