Merge remote-tracking branch 'gitea/main' into codex/security-supply-chain-contracts-20260512

This commit is contained in:
Your Name
2026-05-13 16:36:59 +08:00
6 changed files with 502 additions and 9 deletions

View File

@@ -22,24 +22,26 @@ router = APIRouter()
"/truth-chain/quality/summary",
summary="查詢 AI 自動化品質總覽",
description=(
"T12c read-only endpoint. 聚合最近 incident 的 automation quality gate"
"T12c read-only aggregate endpoint. 聚合最近 incident 的 automation quality gate"
"讓 Operator 不必逐張 Telegram 卡片判斷是否真正完成 AI 自動修復。"
"此總覽不回傳逐筆 examplessource-level truth-chain 詳情仍需 operator auth。"
),
)
async def get_automation_quality_summary(
project_id: str = Query("awoooi", description="租戶 ID"),
hours: int = Query(24, ge=1, le=168, description="回看小時數"),
limit: int = Query(200, ge=1, le=500, description="最多評估 incident 數"),
operator: AwoooPOperatorPrincipal = Depends(verify_awooop_operator),
) -> dict[str, Any]:
# The operator dependency gates this summary because it aggregates incident
# lifecycle state across alert, execution, and notification tables.
_ = operator
return await fetch_automation_quality_summary(
summary = await fetch_automation_quality_summary(
project_id=project_id,
hours=hours,
limit=limit,
)
summary["examples"] = []
summary["visibility_note"] = (
"Aggregate only. Use /truth-chain/{source_id} with operator auth for source-level details."
)
return summary
@router.get(

View File

@@ -1494,6 +1494,61 @@
"ready": "In Sync",
"loading": "Loading",
"degraded": "Degraded",
"quality": {
"title": "Automation Quality",
"subtitle": "Whether recent alerts actually reached AI auto-repair, verification, and learning writeback in the last 24 hours.",
"claimReady": "Full Loop Claim Ready",
"claimBlocked": "Full Loop Claim Blocked",
"unavailable": "Unavailable",
"loadFailed": "Unable to load the automation quality summary. Check Operator permissions and the truth-chain API.",
"empty": "No alert quality data is available yet.",
"yes": "Yes",
"no": "No",
"metrics": {
"evaluated": "Evaluated Alerts",
"evaluatedDetail": "Same quality gate applied",
"verified": "Verified Auto-Repairs",
"verifiedDetail": "Requires auto-repair plus verification",
"averageScore": "Average Score",
"averageScoreDetail": "0 to 100 process completeness",
"claim": "Production Claim",
"claimReadyDetail": "Every alert completed the verified loop",
"claimBlockedDetail": "Some alerts still lack execution, verification, or learning records"
},
"scoreBuckets": "Score Buckets",
"scoreBucketsDetail": "{total} evaluated alerts",
"green": "Green",
"yellow": "Yellow",
"red": "Red",
"verdictTitle": "Verdict Distribution",
"gateFailureTitle": "Top Gaps",
"scoreRange": "min {min} / max {max} / avg {avg}",
"verdicts": {
"autoRepairedVerified": "Auto-Repaired and Verified",
"executionUnverified": "Executed but Unverified",
"executionFailed": "Execution Failed",
"manualRequiredNoAction": "Manual Required: NO_ACTION",
"approvalRequired": "Waiting for Approval",
"observedNotExecuted": "Observed but Not Executed",
"receivedOnly": "Received Only"
},
"gates": {
"sourcePersisted": "Source Persisted",
"outboundRecorded": "Outbound Recorded",
"evidenceCollected": "Evidence Collected",
"mcpGatewayObserved": "MCP Gateway",
"approvalState": "Approval State",
"executionRecorded": "Execution Recorded",
"autoRepairRecorded": "Auto-Repair Recorded",
"verificationRecorded": "Verification Recorded",
"learningRecorded": "Learning Writeback",
"timelineRecorded": "Timeline Recorded"
},
"gateStatuses": {
"failed": "Failed",
"missing": "Missing"
}
},
"metrics": {
"tenants": "Tenants",
"tenantsDetail": "{active} active, {shadow} in shadow",

View File

@@ -1495,6 +1495,61 @@
"ready": "同步中",
"loading": "讀取中",
"degraded": "降級",
"quality": {
"title": "自動化品質",
"subtitle": "最近 24 小時告警是否真正走到 AI 自動修復、驗證與學習回寫。",
"claimReady": "可宣稱完整閉環",
"claimBlocked": "不可宣稱完整閉環",
"unavailable": "無法讀取",
"loadFailed": "無法讀取自動化品質總覽。請確認 Operator 權限與 truth-chain API 狀態。",
"empty": "尚無可評估的告警品質資料。",
"yes": "是",
"no": "否",
"metrics": {
"evaluated": "已評估告警",
"evaluatedDetail": "套用同一組品質閘門",
"verified": "已驗證自動修復",
"verifiedDetail": "必須有自動修復與驗證記錄",
"averageScore": "平均分數",
"averageScoreDetail": "0 到 100 的流程完整度",
"claim": "生產宣稱",
"claimReadyDetail": "所有告警都完成驗證閉環",
"claimBlockedDetail": "仍有告警缺少執行、驗證或學習記錄"
},
"scoreBuckets": "分數區間",
"scoreBucketsDetail": "共 {total} 筆已評估告警",
"green": "綠",
"yellow": "黃",
"red": "紅",
"verdictTitle": "流程判定分布",
"gateFailureTitle": "主要缺口",
"scoreRange": "最低 {min} / 最高 {max} / 平均 {avg}",
"verdicts": {
"autoRepairedVerified": "已驗證自動修復",
"executionUnverified": "已執行但未驗證",
"executionFailed": "執行失敗",
"manualRequiredNoAction": "人工介入NO_ACTION",
"approvalRequired": "等待審批",
"observedNotExecuted": "已觀測但未執行",
"receivedOnly": "僅收到告警"
},
"gates": {
"sourcePersisted": "來源已落庫",
"outboundRecorded": "Outbound 記錄",
"evidenceCollected": "證據收集",
"mcpGatewayObserved": "MCP Gateway",
"approvalState": "審批狀態",
"executionRecorded": "執行記錄",
"autoRepairRecorded": "自動修復記錄",
"verificationRecorded": "驗證記錄",
"learningRecorded": "學習回寫",
"timelineRecorded": "Timeline 記錄"
},
"gateStatuses": {
"failed": "失敗",
"missing": "缺少"
}
},
"metrics": {
"tenants": "租戶",
"tenantsDetail": "{active} 個啟用,{shadow} 個 shadow",

View File

@@ -50,6 +50,40 @@ type Snapshot = {
type SnapshotStatus = "loading" | "ready" | "degraded";
type QualityVerdict = {
verdict: string;
total: number;
min_score?: number;
max_score?: number;
avg_score?: number;
needs_human?: boolean;
};
type QualityGateFailure = {
gate: string;
total: number;
statuses?: Record<string, number>;
};
type AutomationQualitySummary = {
schema_version: "automation_quality_summary_v1";
incident_total: number;
evaluated_total: number;
verified_auto_repair_total: number;
average_score: number;
score_buckets: {
green: number;
yellow: number;
red: number;
};
by_verdict: QualityVerdict[];
gate_failures: QualityGateFailure[];
production_claim: {
can_claim_full_auto_repair: boolean;
reason: string;
};
};
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const emptySnapshot: Snapshot = {
@@ -191,21 +225,262 @@ function DispositionCell({
);
}
function verdictLabel(verdict: string, t: ReturnType<typeof useTranslations>): string {
switch (verdict) {
case "auto_repaired_verified":
return t("verdicts.autoRepairedVerified");
case "execution_unverified":
return t("verdicts.executionUnverified");
case "execution_failed":
return t("verdicts.executionFailed");
case "manual_required_no_action":
return t("verdicts.manualRequiredNoAction");
case "approval_required":
return t("verdicts.approvalRequired");
case "observed_not_executed":
return t("verdicts.observedNotExecuted");
case "received_only":
return t("verdicts.receivedOnly");
default:
return verdict || "--";
}
}
function gateLabel(gate: string, t: ReturnType<typeof useTranslations>): string {
switch (gate) {
case "source_persisted":
return t("gates.sourcePersisted");
case "outbound_recorded":
return t("gates.outboundRecorded");
case "evidence_collected":
return t("gates.evidenceCollected");
case "mcp_gateway_observed":
return t("gates.mcpGatewayObserved");
case "approval_state":
return t("gates.approvalState");
case "execution_recorded":
return t("gates.executionRecorded");
case "auto_repair_recorded":
return t("gates.autoRepairRecorded");
case "verification_recorded":
return t("gates.verificationRecorded");
case "learning_recorded":
return t("gates.learningRecorded");
case "timeline_recorded":
return t("gates.timelineRecorded");
default:
return gate || "--";
}
}
function gateStatusLabel(status: string, t: ReturnType<typeof useTranslations>): string {
switch (status) {
case "failed":
return t("gateStatuses.failed");
case "missing":
return t("gateStatuses.missing");
default:
return status || "--";
}
}
function QualityMetric({
label,
value,
detail,
}: {
label: string;
value: string | number;
detail: string;
}) {
return (
<div className="min-h-[96px] border border-[#e0ddd4] bg-white px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-2 font-mono text-2xl font-semibold leading-none text-[#141413]">
{value}
</p>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{detail}</p>
</div>
);
}
function AutomationQualityPanel({
summary,
error,
}: {
summary: AutomationQualitySummary | null;
error: boolean;
}) {
const t = useTranslations("awooop.home.quality");
const total = summary?.evaluated_total ?? 0;
const claimReady = summary?.production_claim.can_claim_full_auto_repair === true;
const buckets = summary?.score_buckets ?? { green: 0, yellow: 0, red: 0 };
const bucketTotal = Math.max(1, buckets.green + buckets.yellow + buckets.red);
return (
<section className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 h-4 w-4 text-[#d97757]" aria-hidden="true" />
<div>
<h3 className="text-sm font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 text-xs leading-5 text-[#77736a]">{t("subtitle")}</p>
</div>
</div>
<span
className={cn(
"inline-flex border px-2 py-0.5 text-xs font-semibold",
claimReady && "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
!claimReady && !error && "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
error && "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
)}
>
{error ? t("unavailable") : claimReady ? t("claimReady") : t("claimBlocked")}
</span>
</div>
{error || !summary ? (
<div className="px-4 py-5 text-sm leading-6 text-[#5f5b52]">
{error ? t("loadFailed") : t("empty")}
</div>
) : (
<div className="space-y-4 p-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<QualityMetric
label={t("metrics.evaluated")}
value={`${summary.evaluated_total}/${summary.incident_total}`}
detail={t("metrics.evaluatedDetail")}
/>
<QualityMetric
label={t("metrics.verified")}
value={summary.verified_auto_repair_total}
detail={t("metrics.verifiedDetail")}
/>
<QualityMetric
label={t("metrics.averageScore")}
value={summary.average_score.toFixed(1)}
detail={t("metrics.averageScoreDetail")}
/>
<QualityMetric
label={t("metrics.claim")}
value={claimReady ? t("yes") : t("no")}
detail={claimReady ? t("claimReadyDetail") : t("claimBlockedDetail")}
/>
</div>
<div className="border border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{t("scoreBuckets")}</p>
<p className="mt-1 text-xs text-[#5f5b52]">
{t("scoreBucketsDetail", { total })}
</p>
</div>
<div className="flex gap-2 font-mono text-xs text-[#141413]">
<span>{t("green")}: {buckets.green}</span>
<span>{t("yellow")}: {buckets.yellow}</span>
<span>{t("red")}: {buckets.red}</span>
</div>
</div>
<div className="mt-3 flex h-2 overflow-hidden border border-[#d8d3c7] bg-white">
<div
className="bg-[#6aa879]"
style={{ width: `${(buckets.green / bucketTotal) * 100}%` }}
/>
<div
className="bg-[#d9b36f]"
style={{ width: `${(buckets.yellow / bucketTotal) * 100}%` }}
/>
<div
className="bg-[#d76f61]"
style={{ width: `${(buckets.red / bucketTotal) * 100}%` }}
/>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs font-semibold text-[#141413]">
{t("verdictTitle")}
</div>
<div className="divide-y divide-[#eee9dd]">
{summary.by_verdict.slice(0, 6).map((row) => (
<div key={row.verdict} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[#141413]">
{verdictLabel(row.verdict, t)}
</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">
{t("scoreRange", {
min: row.min_score ?? 0,
max: row.max_score ?? 0,
avg: (row.avg_score ?? 0).toFixed(1),
})}
</p>
</div>
<span className="shrink-0 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono text-xs text-[#141413]">
{row.total}
</span>
</div>
))}
</div>
</div>
<div className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs font-semibold text-[#141413]">
{t("gateFailureTitle")}
</div>
<div className="divide-y divide-[#eee9dd]">
{summary.gate_failures.slice(0, 6).map((row) => (
<div key={row.gate} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[#141413]">
{gateLabel(row.gate, t)}
</p>
<p className="mt-1 font-mono text-xs text-[#77736a]">
{Object.entries(row.statuses ?? {})
.map(([key, value]) => `${gateStatusLabel(key, t)}:${value}`)
.join(" / ") || "--"}
</p>
</div>
<span className="shrink-0 border border-[#d8d3c7] bg-[#faf9f3] px-2 py-0.5 font-mono text-xs text-[#141413]">
{row.total}
</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</section>
);
}
export default function AwoooPPage() {
const t = useTranslations("awooop.home");
const locale = useLocale();
const [snapshot, setSnapshot] = useState<Snapshot>(emptySnapshot);
const [qualitySummary, setQualitySummary] = useState<AutomationQualitySummary | null>(null);
const [qualityError, setQualityError] = useState(false);
const [status, setStatus] = useState<SnapshotStatus>("loading");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchSnapshot = useCallback(async () => {
setStatus("loading");
try {
const [tenantRes, runRes, approvalRes, contractRes] = await Promise.all([
const qualityParams = new URLSearchParams({
project_id: "awoooi",
hours: "24",
limit: "50",
});
const [tenantRes, runRes, approvalRes, contractRes, qualityRes] = await Promise.all([
fetch(`${API_BASE}/api/v1/platform/tenants`),
fetch(`${API_BASE}/api/v1/platform/runs/list?per_page=1`),
fetch(`${API_BASE}/api/v1/platform/approvals`),
fetch(`${API_BASE}/api/v1/platform/contracts?per_page=1`),
fetch(`${API_BASE}/api/v1/platform/truth-chain/quality/summary?${qualityParams.toString()}`)
.catch(() => null),
]);
if (![tenantRes, runRes, approvalRes, contractRes].every((res) => res.ok)) {
@@ -228,10 +503,20 @@ export default function AwoooPPage() {
approvals: countRows(approvalData, ["items"]),
contracts: countRows(contractData, ["contracts", "items"]),
});
if (qualityRes?.ok) {
const qualityData = await qualityRes.json() as AutomationQualitySummary;
setQualitySummary(qualityData);
setQualityError(false);
} else {
setQualitySummary(null);
setQualityError(true);
}
setLastUpdated(new Date());
setStatus("ready");
} catch {
setStatus("degraded");
setQualitySummary(null);
setQualityError(true);
setLastUpdated(new Date());
}
}, []);
@@ -346,6 +631,8 @@ export default function AwoooPPage() {
/>
</section>
<AutomationQualityPanel summary={qualitySummary} error={qualityError} />
<section className="border border-[#e0ddd4] bg-[#e0ddd4]">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-center gap-2">

View File

@@ -7831,3 +7831,97 @@ OK
```
**目前整體進度**:約 73%。
**production deploy / smoke 追加(完成)**
```text
Gitea:
2041 ai-code-review ae7c7cbd -> success
2040 CD Pipeline ae7c7cbd -> success
K8s image:
awoooi-api 192.168.0.110:5000/awoooi/api:ae7c7cbd23830f2c74aa7c43c0e9a931ca5092bb
awoooi-worker 192.168.0.110:5000/awoooi/api:ae7c7cbd23830f2c74aa7c43c0e9a931ca5092bb
awoooi-web 192.168.0.110:5000/awoooi/web:ae7c7cbd23830f2c74aa7c43c0e9a931ca5092bb
rollout:
awoooi-api / awoooi-worker / awoooi-web successfully rolled out
health:
https://awoooi.wooo.work/api/v1/health -> 200
route visibility note:
T12d 後此 summary endpoint 改為 read-only aggregate不回傳 examplessource-level `/truth-chain/{source_id}` 仍需 operator auth。
production summary service smoke, hours=24, limit=50:
schema_version=automation_quality_summary_v1
incident_total=50
evaluated_total=50
verified_auto_repair_total=0
average_score=46.2
score_buckets={green: 2, yellow: 14, red: 34}
production_claim.can_claim_full_auto_repair=false
by_verdict:
received_only=17
execution_unverified=16
manual_required_no_action=16
approval_required=1
top gate_failures:
auto_repair_recorded=48
execution_recorded=34
evidence_collected=31
mcp_gateway_observed=17
outbound_recorded=17
timeline_recorded=17
approval_state=16
verification_recorded=16
```
判讀:
- T12c 已部署並能用 production 資料回答「目前不能宣稱完整 AI 自動修復」。
- 最近 50 筆 incident 中0 筆達到 `auto_repaired_verified`;不少中低風險事件有 execution 但缺 verification / auto_repair durable record。
- 下一步應把這個 summary 接到 Operator Console / Telegram 詳情入口,並把 execution verifier / KM writeback 變成下一個 quality gap wave。
- 目前整體進度更新:約 74%。
### 2026-05-13 — AwoooP truth-chain T12dOperator Console 自動化品質面板local green
**目的**
- T12c 已有全體告警 quality summary API但 Operator Console 仍看不到「最近告警是否真的 AI 自動修復」。
- T12d 把 summary 接到 `/awooop` 首頁,讓「是否可宣稱完整閉環」成為第一屏可見的治理訊號。
**變更**
- `/awooop` 首頁新增「自動化品質」面板:
- 24h / limit 50 summary
- 顯示 evaluated / verified auto-repair / average score / production claim
- 顯示 score buckets、verdict distribution、top gate failures
- 只讀,不觸發任何修復動作
- 新增 `awooop.home.quality` 雙語字典,新增文字都走 `next-intl`
- UI 使用 Lucide `ShieldCheck`,沒有新增 emoji icon 或 mock data。
- `GET /api/v1/platform/truth-chain/quality/summary` 改為 read-only aggregate供 Operator Console 讀取;回應會清空 examples逐筆 truth-chain 詳情仍保留 operator auth。
**local verification**
```text
python3 -m json.tool apps/web/messages/zh-TW.json >/dev/null
python3 -m json.tool apps/web/messages/en.json >/dev/null
messages ok
pnpm --dir apps/web exec eslint 'src/app/[locale]/awooop/page.tsx'
OK
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build
OK
pnpm --dir apps/web exec tsc --noEmit
OK
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web dev -- --hostname 127.0.0.1 --port 3000
Ready at http://127.0.0.1:3000
curl http://127.0.0.1:3000/zh-TW/awooop -> 200
```
**目前整體進度**:約 75%。

View File

@@ -40,7 +40,7 @@ resources:
images:
- name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/api
newTag: 0f080240c658c9f7deb57ab0e7623342b946246a
newTag: e4203060f3a417e879c2ad3b32894e69444105ad
- name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/web
newTag: 0f080240c658c9f7deb57ab0e7623342b946246a
newTag: e4203060f3a417e879c2ad3b32894e69444105ad