步驟6 執行統計回寫:每顆零件跑完回寫 success_rate(task 29,design.md「執行統計設計」)
- registry 新增 POST /analytics/record:ANALYTICS_KV 計數器(stats:{hash_id}:{version})
為唯一真相源,衍生值(success_rate/avg_duration_ms/call_count)回填 comp: 記錄
——查詢讀取端讀哪就寫哪,不開第二真相源。KV 無 CAS,誠實標註非原子。
- cypher-executor execution-evaluator 從 stub 改真實作:執行收尾(/cypher/execute
成功與 ExecutionError 路徑+webhook 路徑)對 trace 裡每顆 Component 節點
fire-and-forget 回寫,waitUntil 包、不增加執行同步延遲(仿 recordRecipeStats 慣例)。
成敗判定=trace error 或 output.success===false(makeHttpRunner 非 2xx 不 throw)。
- registry 位置沿 search-nodes 慣例:REGISTRY_BASE_URL 覆蓋,未設走 wasmWorkerUrl。
- 新增單測 13 個全綠;本地雙 wrangler dev 端到端實測:http_request 跑 5 次
(3 成功+2 失敗)→ success_rate 1→0.6、call_count 0→5,/cypher/search 同步可見。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,7 +137,8 @@ export async function searchComponents(
|
||||
|
||||
// ── 內部工具函數 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function computeScore(v: Record<string, unknown>): number {
|
||||
// export:recordAnalytics 選「最優版本」要與 getComponent 同判準(單一評分真相)
|
||||
export function computeScore(v: Record<string, unknown>): number {
|
||||
const successRate = parseFloat(String(v.success_rate ?? '1'));
|
||||
const avgDuration = parseFloat(String(v.avg_duration_ms ?? '10'));
|
||||
const callCount = parseInt(String(v.call_count ?? '0'), 10);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// recordAnalytics — 零件執行結果回寫(POST /analytics/record 的實作)
|
||||
// SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
|
||||
//
|
||||
// 真相源=ANALYTICS_KV 計數器(key = stats:{hash_id}:{version},見 src/types.ts 註記):
|
||||
// { total_runs, success_runs, total_ms }
|
||||
// 讀取端(queryComponents / GET /components/*)讀的是 comp: 記錄上的
|
||||
// success_rate / avg_duration_ms / call_count 欄位——所以每次記錄後把衍生值
|
||||
// 回填 comp: 記錄(唯一寫入者是本函式,衍生值不是第二個真相源)。
|
||||
//
|
||||
// 尺度註記:design.md:499 寫 success_rate = success_runs / total_runs * 100(百分比顯示)。
|
||||
// 本 repo comp: 記錄的 success_rate 既有尺度是 0..1(預設 1,computeScore 直接相乘),
|
||||
// 為不破壞既有讀取端與未測零件的預設值,落庫維持 0..1;*100 只是等價的顯示換算。
|
||||
//
|
||||
// 誠實限制:CF KV 無 CAS/原子遞增,read-modify-write 併發下偶有丟計數;
|
||||
// 統計用途可接受(design 的「樂觀鎖」在 KV 上做不到,不假裝做到了)。
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
import { computeScore } from './queryComponents';
|
||||
|
||||
export interface AnalyticsRecordInput {
|
||||
canonical_id: string; // 也接受 cmp_xxxxxxxx hash_id
|
||||
version?: string; // 不給 → 記到查詢會回的最優版本(getComponent 同判準)
|
||||
success: boolean;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsRecordResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
canonical_id?: string;
|
||||
version?: string;
|
||||
total_runs?: number;
|
||||
success_runs?: number;
|
||||
success_rate?: number; // 0..1,與 comp: 記錄同尺度
|
||||
avg_duration_ms?: number;
|
||||
}
|
||||
|
||||
interface StatsCounters {
|
||||
total_runs: number;
|
||||
success_runs: number;
|
||||
total_ms: number;
|
||||
}
|
||||
|
||||
export async function recordAnalytics(
|
||||
input: AnalyticsRecordInput,
|
||||
env: Bindings,
|
||||
): Promise<AnalyticsRecordResult> {
|
||||
// 1. 解析 hash_id(與 queryComponents.resolveHashId 同規則)
|
||||
const hashId = input.canonical_id.startsWith('cmp_')
|
||||
? input.canonical_id
|
||||
: await env.SUBMISSIONS_KV.get(`idx:${input.canonical_id}`);
|
||||
if (!hashId) {
|
||||
return { ok: false, error: `零件 ${input.canonical_id} 不在索引` };
|
||||
}
|
||||
|
||||
// 2. 找目標版本記錄:指定 version 就用它;沒指定 → 最優版本(與 getComponent 同判準:score 最高)
|
||||
const list = await env.SUBMISSIONS_KV.list({ prefix: `comp:${hashId}:` });
|
||||
let targetKey: string | null = null;
|
||||
let targetRecord: Record<string, unknown> | null = null;
|
||||
let bestScore = -Infinity;
|
||||
|
||||
for (const key of list.keys) {
|
||||
const raw = await env.SUBMISSIONS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
let v: Record<string, unknown>;
|
||||
try { v = JSON.parse(raw); } catch { continue; }
|
||||
if (v.status === 'tombstone') continue;
|
||||
|
||||
if (input.version) {
|
||||
if (String(v.version) === input.version) {
|
||||
targetKey = key.name;
|
||||
targetRecord = v;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const score = computeScore(v);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
targetKey = key.name;
|
||||
targetRecord = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetKey || !targetRecord) {
|
||||
return { ok: false, error: `零件 ${input.canonical_id} 無可用版本記錄` };
|
||||
}
|
||||
|
||||
const version = String(targetRecord.version ?? 'v1');
|
||||
const canonicalId = String(targetRecord.canonical_id ?? input.canonical_id);
|
||||
|
||||
// 3. 更新計數器(真相源,ANALYTICS_KV stats:{hash_id}:{version})
|
||||
const statsKey = `stats:${hashId}:${version}`;
|
||||
let counters: StatsCounters = { total_runs: 0, success_runs: 0, total_ms: 0 };
|
||||
const rawStats = await env.ANALYTICS_KV.get(statsKey);
|
||||
if (rawStats) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawStats) as Partial<StatsCounters>;
|
||||
counters = {
|
||||
total_runs: Number(parsed.total_runs) || 0,
|
||||
success_runs: Number(parsed.success_runs) || 0,
|
||||
total_ms: Number(parsed.total_ms) || 0,
|
||||
};
|
||||
} catch { /* 損毀計數器 → 重新起算 */ }
|
||||
}
|
||||
counters.total_runs += 1;
|
||||
counters.success_runs += input.success ? 1 : 0;
|
||||
counters.total_ms += Math.max(0, Number(input.duration_ms) || 0);
|
||||
await env.ANALYTICS_KV.put(statsKey, JSON.stringify(counters));
|
||||
|
||||
// 4. 衍生值回填 comp: 記錄(讀取端讀的地方)
|
||||
const successRate = counters.success_runs / counters.total_runs;
|
||||
const avgDurationMs = Math.round(counters.total_ms / counters.total_runs);
|
||||
targetRecord.success_rate = successRate;
|
||||
targetRecord.avg_duration_ms = avgDurationMs;
|
||||
targetRecord.call_count = counters.total_runs;
|
||||
await env.SUBMISSIONS_KV.put(targetKey, JSON.stringify(targetRecord));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
canonical_id: canonicalId,
|
||||
version,
|
||||
total_runs: counters.total_runs,
|
||||
success_runs: counters.success_runs,
|
||||
success_rate: successRate,
|
||||
avg_duration_ms: avgDurationMs,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user