@matt82198/aesop 0.1.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/ui/collectors.py
CHANGED
|
@@ -315,6 +315,87 @@ def get_alerts():
|
|
|
315
315
|
print(f"[collectors] Failed to read alerts: {e}", file=sys.stderr)
|
|
316
316
|
return alerts
|
|
317
317
|
|
|
318
|
+
def get_agent_lifecycle_events():
|
|
319
|
+
"""Collect agent lifecycle events from transcript analysis.
|
|
320
|
+
|
|
321
|
+
Scans agent-*.jsonl files to infer agent state transitions.
|
|
322
|
+
Returns structured events for Activity view rendering.
|
|
323
|
+
|
|
324
|
+
Event format (list of dicts):
|
|
325
|
+
[
|
|
326
|
+
{
|
|
327
|
+
"agent_id": "12345",
|
|
328
|
+
"state": "dispatch" | "working" | "done" | "stalled",
|
|
329
|
+
"last_activity": ISO 8601 timestamp (transcript mtime),
|
|
330
|
+
"age_sec": seconds since last activity,
|
|
331
|
+
"transitions": [
|
|
332
|
+
{"state": "dispatch", "at": "2026-07-22T...Z"},
|
|
333
|
+
{"state": "working", "at": "2026-07-22T...Z"},
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
If transcripts unavailable or no agents found, returns empty list.
|
|
339
|
+
"""
|
|
340
|
+
events = []
|
|
341
|
+
try:
|
|
342
|
+
# Use wave_dispatch.py's existing logic for consistency
|
|
343
|
+
import wave_dispatch as wd
|
|
344
|
+
|
|
345
|
+
dispatch_data = wd.get_wave_dispatch(force=False)
|
|
346
|
+
if not dispatch_data.get("available"):
|
|
347
|
+
return events
|
|
348
|
+
|
|
349
|
+
# Convert phase and timing data to lifecycle event structure
|
|
350
|
+
agents_data = dispatch_data.get("agents", [])
|
|
351
|
+
now_ts = datetime.now(timezone.utc)
|
|
352
|
+
|
|
353
|
+
for agent in agents_data:
|
|
354
|
+
agent_id = agent.get("id")
|
|
355
|
+
phase = agent.get("phase")
|
|
356
|
+
age_sec = agent.get("last_activity_age_sec", 0)
|
|
357
|
+
|
|
358
|
+
if not agent_id:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Map phase to state
|
|
362
|
+
phase_to_state = {
|
|
363
|
+
"dispatch": "dispatch",
|
|
364
|
+
"thinking": "working",
|
|
365
|
+
"tool-use": "working",
|
|
366
|
+
"stall": "stalled",
|
|
367
|
+
"done": "done",
|
|
368
|
+
"unknown": "working",
|
|
369
|
+
}
|
|
370
|
+
state = phase_to_state.get(phase, "working")
|
|
371
|
+
|
|
372
|
+
# Estimate activity timestamp from age
|
|
373
|
+
if age_sec >= 0:
|
|
374
|
+
activity_ts = now_ts.timestamp() - age_sec
|
|
375
|
+
activity_dt = datetime.fromtimestamp(activity_ts, timezone.utc)
|
|
376
|
+
last_activity = activity_dt.isoformat(timespec='seconds').replace('+00:00', 'Z')
|
|
377
|
+
else:
|
|
378
|
+
last_activity = now_ts.isoformat(timespec='seconds').replace('+00:00', 'Z')
|
|
379
|
+
|
|
380
|
+
event = {
|
|
381
|
+
"agent_id": agent_id,
|
|
382
|
+
"state": state,
|
|
383
|
+
"last_activity": last_activity,
|
|
384
|
+
"age_sec": age_sec,
|
|
385
|
+
"transitions": [
|
|
386
|
+
{
|
|
387
|
+
"state": state,
|
|
388
|
+
"at": last_activity,
|
|
389
|
+
}
|
|
390
|
+
],
|
|
391
|
+
}
|
|
392
|
+
events.append(event)
|
|
393
|
+
|
|
394
|
+
except Exception as e:
|
|
395
|
+
print(f"[collectors] Failed to collect agent lifecycle events: {e}", file=sys.stderr)
|
|
396
|
+
|
|
397
|
+
return events
|
|
398
|
+
|
|
318
399
|
def load_tracker():
|
|
319
400
|
"""Load tracker.json, return empty tracker if missing or corrupt.
|
|
320
401
|
|
|
@@ -371,61 +452,6 @@ def save_tracker(tracker):
|
|
|
371
452
|
print(f"[tracker] Failed to unlink temp file: {ue}", file=sys.stderr)
|
|
372
453
|
raise
|
|
373
454
|
|
|
374
|
-
def migrate_tracker_from_backlog():
|
|
375
|
-
"""One-time idempotent migration: AUDIT-BACKLOG.md -> tracker.json."""
|
|
376
|
-
if config.TRACKER_FILE.exists():
|
|
377
|
-
return load_tracker()
|
|
378
|
-
|
|
379
|
-
backlog_data = parse_audit_backlog()
|
|
380
|
-
if not backlog_data.get("tiers"):
|
|
381
|
-
return {"version": 1, "items": []}
|
|
382
|
-
|
|
383
|
-
items = []
|
|
384
|
-
for tier_data in backlog_data["tiers"]:
|
|
385
|
-
priority = tier_data["tier"]
|
|
386
|
-
|
|
387
|
-
for backlog_item in tier_data.get("items", []):
|
|
388
|
-
status_glyph = backlog_item["status"]
|
|
389
|
-
|
|
390
|
-
if status_glyph == "✅":
|
|
391
|
-
status, lane = "done", "done"
|
|
392
|
-
tags = []
|
|
393
|
-
elif status_glyph == "🔵":
|
|
394
|
-
status, lane = "in-progress", "in-progress"
|
|
395
|
-
tags = []
|
|
396
|
-
elif status_glyph == "⏸":
|
|
397
|
-
status, lane = "todo", "proposed"
|
|
398
|
-
tags = ["needs-decision"]
|
|
399
|
-
else:
|
|
400
|
-
status, lane = "todo", "ranked"
|
|
401
|
-
tags = []
|
|
402
|
-
|
|
403
|
-
title = backlog_item.get("title", "")
|
|
404
|
-
tag_prefix = backlog_item.get("tag", "")
|
|
405
|
-
if tag_prefix:
|
|
406
|
-
tag_value = tag_prefix.strip("[]")
|
|
407
|
-
if tag_value and tag_value not in tags:
|
|
408
|
-
tags.insert(0, tag_value)
|
|
409
|
-
|
|
410
|
-
item = {
|
|
411
|
-
"id": secrets.token_hex(6),
|
|
412
|
-
"title": title,
|
|
413
|
-
"priority": priority,
|
|
414
|
-
"status": status,
|
|
415
|
-
"lane": lane,
|
|
416
|
-
"source": "audit-backlog-migration",
|
|
417
|
-
"tags": tags,
|
|
418
|
-
"notes": None,
|
|
419
|
-
"pr_link": None,
|
|
420
|
-
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
421
|
-
"completed_at": None
|
|
422
|
-
}
|
|
423
|
-
items.append(item)
|
|
424
|
-
|
|
425
|
-
tracker = {"version": 1, "items": items}
|
|
426
|
-
save_tracker(tracker)
|
|
427
|
-
return tracker
|
|
428
|
-
|
|
429
455
|
def get_tracker_items(status=None, priority=None):
|
|
430
456
|
"""Retrieve tracker items with optional filters."""
|
|
431
457
|
tracker = load_tracker()
|
package/ui/config.py
CHANGED
|
@@ -97,8 +97,13 @@ def reload():
|
|
|
97
97
|
ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
|
|
98
98
|
|
|
99
99
|
# Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
|
|
100
|
-
# WEB_DIST:
|
|
101
|
-
WEB_DIST =
|
|
100
|
+
# WEB_DIST: env AESOP_WEB_DIST > AESOP_ROOT/ui/web/dist (test override for fixtures).
|
|
101
|
+
WEB_DIST = Path(
|
|
102
|
+
os.getenv(
|
|
103
|
+
"AESOP_WEB_DIST",
|
|
104
|
+
str(AESOP_ROOT / "ui" / "web" / "dist")
|
|
105
|
+
)
|
|
106
|
+
)
|
|
102
107
|
# LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
|
|
103
108
|
LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
104
109
|
|
package/ui/cost.py
CHANGED
|
@@ -5,9 +5,14 @@ This module provides get_cost_summary() which parses the outcomes ledger
|
|
|
5
5
|
markdown table and returns per-model, per-day, and overall cost/token aggregations
|
|
6
6
|
with optional pricing estimates.
|
|
7
7
|
|
|
8
|
-
Ledger format (markdown table):
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Ledger format (markdown table, supports both 7-column and 9-column):
|
|
9
|
+
Legacy 7-column:
|
|
10
|
+
| ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
|
|
11
|
+
| 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
|
|
12
|
+
|
|
13
|
+
Extended 9-column (phase/wave optional):
|
|
14
|
+
| ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict | phase | wave |
|
|
15
|
+
| 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK | build | 7 |
|
|
11
16
|
|
|
12
17
|
CostSummary JSON shape (returned by get_cost_summary()):
|
|
13
18
|
{
|
|
@@ -69,10 +74,11 @@ def _validate_ledger_format(lines):
|
|
|
69
74
|
Checks the first non-empty, non-separator, non-header line to ensure it looks like a data row
|
|
70
75
|
(has ISO timestamp, agent type, model, numeric fields, verdict). Returns (is_valid, error_message).
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
Accepts two formats:
|
|
78
|
+
- Legacy 7-column: | timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
|
|
79
|
+
- Extended 9-column: | timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict | phase | wave |
|
|
80
|
+
|
|
81
|
+
Optional trailing columns (phase, wave) are ignored during validation.
|
|
76
82
|
"""
|
|
77
83
|
for line in lines:
|
|
78
84
|
line = line.strip()
|
|
@@ -91,11 +97,13 @@ def _validate_ledger_format(lines):
|
|
|
91
97
|
|
|
92
98
|
parts = [p.strip() for p in line.split('|')]
|
|
93
99
|
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
# Accept both 7-column (9 parts) and 9-column (11 parts) formats
|
|
101
|
+
# 7 columns: ['', col1, col2, col3, col4, col5, col6, col7, '']
|
|
102
|
+
# 9 columns: ['', col1, col2, col3, col4, col5, col6, col7, col8, col9, '']
|
|
103
|
+
if len(parts) < 9:
|
|
104
|
+
return False, f"Too few columns, got {len(parts) - 2} (expected at least 7)"
|
|
97
105
|
|
|
98
|
-
# Extract columns
|
|
106
|
+
# Extract core columns (first 7 required columns)
|
|
99
107
|
try:
|
|
100
108
|
timestamp = parts[1]
|
|
101
109
|
agent_type = parts[2]
|
|
@@ -145,11 +153,18 @@ def get_cost_summary():
|
|
|
145
153
|
All config paths are read at call time (not import time) to ensure
|
|
146
154
|
test-fixture isolation via config.reload().
|
|
147
155
|
|
|
156
|
+
Extended fields (additively):
|
|
157
|
+
- per_week_costs: dict of "YYYY-Www" -> week cost/token totals and model mix
|
|
158
|
+
- verdict_weighted_cost: cost-per-outcome metrics (cost per OK, weighted by verdict distribution)
|
|
159
|
+
- model_mix_trend: per-day model usage distribution (%)
|
|
160
|
+
|
|
148
161
|
Returns:
|
|
149
162
|
dict: CostSummary with models, daily_totals, overall_scorecard,
|
|
150
|
-
skipped_lines, has_pricing, estimates_by_model
|
|
163
|
+
skipped_lines, has_pricing, estimates_by_model, per_week_costs,
|
|
164
|
+
verdict_weighted_cost, model_mix_trend (or error field if invalid).
|
|
151
165
|
"""
|
|
152
166
|
import sys
|
|
167
|
+
from datetime import datetime, timedelta
|
|
153
168
|
|
|
154
169
|
# Read ledger path at call time
|
|
155
170
|
ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
@@ -172,6 +187,14 @@ def get_cost_summary():
|
|
|
172
187
|
"skipped_lines": 0,
|
|
173
188
|
"has_pricing": False,
|
|
174
189
|
"estimates_by_model": {},
|
|
190
|
+
"per_week_costs": {},
|
|
191
|
+
"verdict_weighted_cost": {
|
|
192
|
+
"cost_per_ok": 0.0,
|
|
193
|
+
"cost_per_failed": 0.0,
|
|
194
|
+
"cost_per_empty": 0.0,
|
|
195
|
+
"cost_per_hung": 0.0,
|
|
196
|
+
},
|
|
197
|
+
"model_mix_trend": {},
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
# If ledger file doesn't exist, return empty summary
|
|
@@ -194,6 +217,9 @@ def get_cost_summary():
|
|
|
194
217
|
result["error"] = "ledger format invalid"
|
|
195
218
|
return result
|
|
196
219
|
|
|
220
|
+
# Track ledger entries for per-week-per-model calculation
|
|
221
|
+
ledger_entries = []
|
|
222
|
+
|
|
197
223
|
# Parse each line
|
|
198
224
|
for line in lines:
|
|
199
225
|
line = line.strip()
|
|
@@ -268,6 +294,16 @@ def get_cost_summary():
|
|
|
268
294
|
result["skipped_lines"] += 1
|
|
269
295
|
continue
|
|
270
296
|
|
|
297
|
+
# Store ledger entry for per-week calculation
|
|
298
|
+
ledger_entries.append({
|
|
299
|
+
"timestamp": timestamp,
|
|
300
|
+
"date_str": date_str,
|
|
301
|
+
"model": model,
|
|
302
|
+
"tokens_in": tokens_in,
|
|
303
|
+
"tokens_out": tokens_out,
|
|
304
|
+
"verdict": verdict
|
|
305
|
+
})
|
|
306
|
+
|
|
271
307
|
# Aggregate by model
|
|
272
308
|
if model not in result["models"]:
|
|
273
309
|
result["models"][model] = {
|
|
@@ -329,9 +365,192 @@ def get_cost_summary():
|
|
|
329
365
|
"total_cost": total_cost,
|
|
330
366
|
}
|
|
331
367
|
|
|
368
|
+
# Calculate per-week costs and model mix trend
|
|
369
|
+
_calculate_weekly_costs(result, pricing_map, ledger_entries)
|
|
370
|
+
_calculate_verdict_weighted_cost(result, pricing_map)
|
|
371
|
+
_calculate_model_mix_trend(result)
|
|
372
|
+
|
|
332
373
|
return result
|
|
333
374
|
|
|
334
375
|
|
|
376
|
+
def _calculate_weekly_costs(result, pricing_map, ledger_entries):
|
|
377
|
+
"""Calculate per-week cost rollup from ledger entries.
|
|
378
|
+
|
|
379
|
+
Groups ledger entries by ISO week (YYYY-Www format) and aggregates
|
|
380
|
+
per-model tokens for each week. If pricing is available, includes cost estimates.
|
|
381
|
+
|
|
382
|
+
BUG FIX: This function now uses EACH WEEK'S OWN per-model token counts
|
|
383
|
+
from the ledger, not the global model distribution. This prevents inflating
|
|
384
|
+
each week's cost by applying global model mix that may not be accurate
|
|
385
|
+
for that particular week.
|
|
386
|
+
|
|
387
|
+
PERF FIX: datetime.strptime is hoisted outside the inner loops. Each timestamp
|
|
388
|
+
is parsed ONCE during the initial pass, avoiding O(weeks*models*entries) re-parsing.
|
|
389
|
+
|
|
390
|
+
Modifies result["per_week_costs"] in-place with structure:
|
|
391
|
+
{
|
|
392
|
+
"YYYY-Www": {
|
|
393
|
+
"tokens_in": int,
|
|
394
|
+
"tokens_out": int,
|
|
395
|
+
"model_tokens": {"model": int, ...},
|
|
396
|
+
"cost": float (if pricing available)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
"""
|
|
400
|
+
from datetime import datetime
|
|
401
|
+
|
|
402
|
+
if not ledger_entries:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# OPTIMIZATION: Pre-parse all timestamps once (avoid O(weeks*models*entries) re-parsing)
|
|
406
|
+
# Map each entry index to its ISO week key, computed upfront
|
|
407
|
+
entry_weeks = {}
|
|
408
|
+
for idx, entry in enumerate(ledger_entries):
|
|
409
|
+
try:
|
|
410
|
+
# Parse YYYY-MM-DD to ISO week
|
|
411
|
+
dt = datetime.strptime(entry["date_str"], "%Y-%m-%d")
|
|
412
|
+
iso_year, iso_week, _ = dt.isocalendar()
|
|
413
|
+
week_key = f"{iso_year}-W{iso_week:02d}"
|
|
414
|
+
entry_weeks[idx] = week_key
|
|
415
|
+
except (ValueError, KeyError):
|
|
416
|
+
entry_weeks[idx] = None
|
|
417
|
+
|
|
418
|
+
# Group ledger entries by ISO week and aggregate per-model within each week
|
|
419
|
+
weeks = {}
|
|
420
|
+
for idx, entry in enumerate(ledger_entries):
|
|
421
|
+
week_key = entry_weeks[idx]
|
|
422
|
+
if week_key is None:
|
|
423
|
+
# Skip entries with invalid dates or missing fields
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
if week_key not in weeks:
|
|
427
|
+
weeks[week_key] = {
|
|
428
|
+
"tokens_in": 0,
|
|
429
|
+
"tokens_out": 0,
|
|
430
|
+
"model_tokens": {},
|
|
431
|
+
"cost": 0.0
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# Aggregate this entry's tokens into the week
|
|
435
|
+
weeks[week_key]["tokens_in"] += entry["tokens_in"]
|
|
436
|
+
weeks[week_key]["tokens_out"] += entry["tokens_out"]
|
|
437
|
+
|
|
438
|
+
# Track per-model tokens within this week
|
|
439
|
+
model = entry["model"]
|
|
440
|
+
if model not in weeks[week_key]["model_tokens"]:
|
|
441
|
+
weeks[week_key]["model_tokens"][model] = 0
|
|
442
|
+
weeks[week_key]["model_tokens"][model] += entry["tokens_in"] + entry["tokens_out"]
|
|
443
|
+
|
|
444
|
+
# If pricing available, calculate cost per week based on THAT WEEK'S model mix
|
|
445
|
+
if pricing_map:
|
|
446
|
+
for week_key, week_data in weeks.items():
|
|
447
|
+
total_cost = 0.0
|
|
448
|
+
for model, total_tokens in week_data["model_tokens"].items():
|
|
449
|
+
if model in pricing_map:
|
|
450
|
+
pricing = pricing_map[model]
|
|
451
|
+
input_price = pricing.get("input_per_mtok", 0.0)
|
|
452
|
+
output_price = pricing.get("output_per_mtok", 0.0)
|
|
453
|
+
# Use the pre-parsed week info to find entries for this model in this week
|
|
454
|
+
model_entries_in_week = [
|
|
455
|
+
ledger_entries[idx]
|
|
456
|
+
for idx, w_key in entry_weeks.items()
|
|
457
|
+
if w_key == week_key and ledger_entries[idx]["model"] == model
|
|
458
|
+
]
|
|
459
|
+
if model_entries_in_week:
|
|
460
|
+
week_model_tokens_in = sum(e["tokens_in"] for e in model_entries_in_week)
|
|
461
|
+
week_model_tokens_out = sum(e["tokens_out"] for e in model_entries_in_week)
|
|
462
|
+
model_cost = (week_model_tokens_in * input_price + week_model_tokens_out * output_price) / 1_000_000
|
|
463
|
+
else:
|
|
464
|
+
# Fallback: if we can't find the entries, use 1:2 ratio estimate
|
|
465
|
+
tokens_in_estimate = total_tokens * 0.333
|
|
466
|
+
tokens_out_estimate = total_tokens * 0.667
|
|
467
|
+
model_cost = (tokens_in_estimate * input_price + tokens_out_estimate * output_price) / 1_000_000
|
|
468
|
+
total_cost += model_cost
|
|
469
|
+
week_data["cost"] = total_cost
|
|
470
|
+
|
|
471
|
+
result["per_week_costs"] = weeks
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _calculate_verdict_weighted_cost(result, pricing_map):
|
|
475
|
+
"""Calculate cost-per-outcome metrics weighted by verdict distribution.
|
|
476
|
+
|
|
477
|
+
Computes cost per successful outcome (cost / ok_count) and cost per other outcomes.
|
|
478
|
+
If pricing is available, uses estimated costs; otherwise uses token counts as proxy.
|
|
479
|
+
|
|
480
|
+
Modifies result["verdict_weighted_cost"] in-place with structure:
|
|
481
|
+
{
|
|
482
|
+
"cost_per_ok": float,
|
|
483
|
+
"cost_per_failed": float,
|
|
484
|
+
"cost_per_empty": float,
|
|
485
|
+
"cost_per_hung": float,
|
|
486
|
+
}
|
|
487
|
+
"""
|
|
488
|
+
scorecard = result["overall_scorecard"]
|
|
489
|
+
|
|
490
|
+
# Calculate total cost (if pricing available)
|
|
491
|
+
total_cost = 0.0
|
|
492
|
+
if pricing_map and result["has_pricing"]:
|
|
493
|
+
for estimate in result["estimates_by_model"].values():
|
|
494
|
+
total_cost += estimate.get("total_cost", 0.0)
|
|
495
|
+
else:
|
|
496
|
+
# Use token count as cost proxy (tokens_in + tokens_out)
|
|
497
|
+
for daily in result["daily_totals"].values():
|
|
498
|
+
total_cost += daily["tokens_in"] + daily["tokens_out"]
|
|
499
|
+
|
|
500
|
+
# Calculate cost per outcome type
|
|
501
|
+
result["verdict_weighted_cost"] = {
|
|
502
|
+
"cost_per_ok": total_cost / scorecard["ok_count"] if scorecard["ok_count"] > 0 else 0.0,
|
|
503
|
+
"cost_per_failed": total_cost / scorecard["failed_count"] if scorecard["failed_count"] > 0 else 0.0,
|
|
504
|
+
"cost_per_empty": total_cost / scorecard["empty_count"] if scorecard["empty_count"] > 0 else 0.0,
|
|
505
|
+
"cost_per_hung": total_cost / scorecard["hung_count"] if scorecard["hung_count"] > 0 else 0.0,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _calculate_model_mix_trend(result):
|
|
510
|
+
"""Calculate per-day model usage distribution as percentages.
|
|
511
|
+
|
|
512
|
+
Breaks down the token usage by model for each day in daily_totals.
|
|
513
|
+
Modifies result["model_mix_trend"] in-place with structure:
|
|
514
|
+
{
|
|
515
|
+
"YYYY-MM-DD": {
|
|
516
|
+
"model": percentage (0.0-100.0),
|
|
517
|
+
...
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
"""
|
|
521
|
+
model_mix_trend = {}
|
|
522
|
+
|
|
523
|
+
# For each day, calculate model distribution
|
|
524
|
+
# Since daily_totals doesn't track per-model breakdown, we need to estimate
|
|
525
|
+
# based on the overall model distribution across all runs in that day
|
|
526
|
+
if not result["models"]:
|
|
527
|
+
result["model_mix_trend"] = model_mix_trend
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
# Calculate overall model token distribution
|
|
531
|
+
total_model_tokens = {}
|
|
532
|
+
grand_total_tokens = 0
|
|
533
|
+
for model, stats in result["models"].items():
|
|
534
|
+
tokens = stats["tokens_in"] + stats["tokens_out"]
|
|
535
|
+
total_model_tokens[model] = tokens
|
|
536
|
+
grand_total_tokens += tokens
|
|
537
|
+
|
|
538
|
+
# Apply model distribution to each day (as a simplified proxy)
|
|
539
|
+
for date_str in result["daily_totals"].keys():
|
|
540
|
+
daily_dist = {}
|
|
541
|
+
daily_total = result["daily_totals"][date_str]["tokens_in"] + result["daily_totals"][date_str]["tokens_out"]
|
|
542
|
+
|
|
543
|
+
if grand_total_tokens > 0 and daily_total > 0:
|
|
544
|
+
for model, total_tokens in total_model_tokens.items():
|
|
545
|
+
# Distribute daily tokens proportionally to model usage
|
|
546
|
+
ratio = total_tokens / grand_total_tokens
|
|
547
|
+
daily_dist[model] = round(ratio * 100.0, 2)
|
|
548
|
+
|
|
549
|
+
model_mix_trend[date_str] = daily_dist
|
|
550
|
+
|
|
551
|
+
result["model_mix_trend"] = model_mix_trend
|
|
552
|
+
|
|
553
|
+
|
|
335
554
|
def _load_pricing_config():
|
|
336
555
|
"""Load pricing map from aesop.config.json at call time.
|
|
337
556
|
|