ai-kit-python 1.4.2__py3-none-any.whl
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.
- ai_kit_python-1.4.2.dist-info/METADATA +132 -0
- ai_kit_python-1.4.2.dist-info/RECORD +278 -0
- ai_kit_python-1.4.2.dist-info/WHEEL +5 -0
- ai_kit_python-1.4.2.dist-info/entry_points.txt +2 -0
- ai_kit_python-1.4.2.dist-info/licenses/LICENSE +21 -0
- ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/security-audit/LICENSE +21 -0
- ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/strix/LICENSE +201 -0
- ai_kit_python-1.4.2.dist-info/top_level.txt +1 -0
- aikit/.agents/skills/go-work/SKILL.md +8 -0
- aikit/.agents/skills/laravel-work/SKILL.md +8 -0
- aikit/.agents/skills/moodle-work/SKILL.md +8 -0
- aikit/.agents/skills/project-continuity/SKILL.md +34 -0
- aikit/.agents/skills/python-work/SKILL.md +8 -0
- aikit/.dockerignore +90 -0
- aikit/.gitattributes +3 -0
- aikit/.github/workflows/check-kit.yml +70 -0
- aikit/.github/workflows/publish-pypi.yml +108 -0
- aikit/.gitignore +60 -0
- aikit/BOOTSTRAP_PROMPT.md +18 -0
- aikit/CHANGELOG.md +85 -0
- aikit/CLAUDE.md +3 -0
- aikit/CONTRIBUTING.md +46 -0
- aikit/Dockerfile +44 -0
- aikit/KIMI.md +3 -0
- aikit/LICENSE +21 -0
- aikit/MANIFEST.in +22 -0
- aikit/MANUS.md +3 -0
- aikit/Makefile +70 -0
- aikit/README.md +1161 -0
- aikit/SECURITY.md +8 -0
- aikit/SKILL.md +105 -0
- aikit/VERSION +1 -0
- aikit/__init__.py +1 -0
- aikit/ai-kit/ADAPTERS.md +5 -0
- aikit/ai-kit/CONTAINERS.md +5 -0
- aikit/ai-kit/ENGINEERING.md +5 -0
- aikit/ai-kit/INTENT.md +5 -0
- aikit/ai-kit/MODELS.md +5 -0
- aikit/ai-kit/SKILLS.md +5 -0
- aikit/ai-kit/stacks/GO.md +5 -0
- aikit/ai-kit/stacks/LARAVEL.md +5 -0
- aikit/ai-kit/stacks/MOODLE.md +5 -0
- aikit/ai-kit/stacks/PYTHON.md +5 -0
- aikit/aikit_cli.py +59 -0
- aikit/deploy/k8s/install-preview-job.yaml +56 -0
- aikit/docker/build-requirements.txt +4 -0
- aikit/docker/check_version.py +29 -0
- aikit/docker/pypi-requirements.txt +3 -0
- aikit/docs/ADAPTER_PATH_FIX.md +23 -0
- aikit/docs/BENCHMARK.md +106 -0
- aikit/docs/CONTEXT7.md +33 -0
- aikit/docs/DECISIONS.md +22 -0
- aikit/docs/DEVELOPMENT_PLAN.md +16 -0
- aikit/docs/DIALOGUE_MEMORY.md +70 -0
- aikit/docs/DOCKER.md +74 -0
- aikit/docs/DOCKER_HUB_OVERVIEW.md +311 -0
- aikit/docs/EVALUATION.md +30 -0
- aikit/docs/IMPLEMENTATION.md +134 -0
- aikit/docs/MANUS_REVIEW.md +46 -0
- aikit/docs/PYPI.md +87 -0
- aikit/docs/PYPI_DESCRIPTION.md +103 -0
- aikit/docs/SECURITY_AUDIT.md +26 -0
- aikit/docs/STRIX.md +28 -0
- aikit/docs/V0_5_0.md +27 -0
- aikit/docs/V0_6_4.md +25 -0
- aikit/docs/V1_4_0.md +73 -0
- aikit/docs/V1_4_1.md +55 -0
- aikit/docs/V1_4_2.md +33 -0
- aikit/docs/WEB_DESIGN.md +72 -0
- aikit/docs/WINDOWS_CI_FIX.md +39 -0
- aikit/docs/adr/0000-template.md +24 -0
- aikit/docs/adr/0001-adaptive-model-routing.md +30 -0
- aikit/docs/adr/0002-template-separation.md +24 -0
- aikit/docs/adr/0003-agent-registry-and-router.md +24 -0
- aikit/docs/adr/0004-onboarding-and-project-facts.md +24 -0
- aikit/docs/adr/0005-client-settings-ownership.md +30 -0
- aikit/docs/adr/0006-routing-evidence-and-local-measurements.md +26 -0
- aikit/docs/adr/0007-generated-client-configuration-and-presets.md +34 -0
- aikit/docs/adr/0008-unified-cli-and-fact-drift.md +20 -0
- aikit/docs/adr/0009-provider-review-doctor-fix-and-ci-self-install.md +20 -0
- aikit/docs/adr/0010-metrics-analysis-and-gemini-verification.md +20 -0
- aikit/docs/adr/0011-evidence-lifecycle-and-evaluation.md +20 -0
- aikit/docs/adr/0012-measurement-led-routing.md +31 -0
- aikit/docs/adr/0013-docker-cli-image.md +24 -0
- aikit/docs/adr/0014-local-dialogue-memory.md +22 -0
- aikit/docs/history/original-bundle-checks.md +11 -0
- aikit/docs/history/v0.1-changelog.txt +37 -0
- aikit/docs/history/v0.10-changelog.md +12 -0
- aikit/docs/history/v0.2-changelog.md +35 -0
- aikit/docs/history/v0.3-changelog.md +25 -0
- aikit/docs/history/v0.4-changelog.md +31 -0
- aikit/docs/history/v0.5-changelog.md +14 -0
- aikit/docs/history/v0.6.4-changelog.md +12 -0
- aikit/docs/history/v0.6.5-v0.9-changelog.md +35 -0
- aikit/docs/history/v1.1.0-changelog.md +12 -0
- aikit/docs/history/v1.2.0-changelog.md +11 -0
- aikit/docs/history/verification-before-measurements.md +53 -0
- aikit/integrations/CONVENTIONS.md +3 -0
- aikit/integrations/README.md +7 -0
- aikit/integrations/agents.json +136 -0
- aikit/integrations/ai-kit-check.yml +66 -0
- aikit/integrations/ai-kit-cline.md +4 -0
- aikit/integrations/ai-kit-roo.md +4 -0
- aikit/integrations/ai-kit-windsurf.md +4 -0
- aikit/integrations/ai-kit.mdc +6 -0
- aikit/integrations/claude-settings.data-ask.json +23 -0
- aikit/integrations/claude-settings.git-ask.json +10 -0
- aikit/integrations/copilot-instructions.md +3 -0
- aikit/integrations/security-audit.json +135 -0
- aikit/integrations/strix.json +69 -0
- aikit/pyproject.toml +60 -0
- aikit/scripts/__init__.py +1 -0
- aikit/scripts/adapters.py +184 -0
- aikit/scripts/adaptive.py +167 -0
- aikit/scripts/adr.py +61 -0
- aikit/scripts/benchmark.py +543 -0
- aikit/scripts/changelog.py +55 -0
- aikit/scripts/check_distribution.py +178 -0
- aikit/scripts/check_kit.py +406 -0
- aikit/scripts/context.py +184 -0
- aikit/scripts/docker_test_usage.py +55 -0
- aikit/scripts/doctor.py +313 -0
- aikit/scripts/evaluate.py +125 -0
- aikit/scripts/install.py +1584 -0
- aikit/scripts/learning.py +260 -0
- aikit/scripts/memory.py +344 -0
- aikit/scripts/memory_import.py +177 -0
- aikit/scripts/memory_store.py +125 -0
- aikit/scripts/metrics.py +351 -0
- aikit/scripts/router.py +399 -0
- aikit/template/.agents/hooks/session-start.md +3 -0
- aikit/template/.agents/hooks/session-start.sh +21 -0
- aikit/template/.agents/skills/ai-kit-bootstrap/SKILL.md +14 -0
- aikit/template/.agents/skills/ai-kit-review/SKILL.md +11 -0
- aikit/template/.agents/skills/ai-kit-sync-context/SKILL.md +12 -0
- aikit/template/.agents/skills/context7-docs/SKILL.md +11 -0
- aikit/template/.agents/skills/design-extract/SKILL.md +9 -0
- aikit/template/.agents/skills/dotnet-work/SKILL.md +9 -0
- aikit/template/.agents/skills/filament-work/SKILL.md +9 -0
- aikit/template/.agents/skills/frontend-work/SKILL.md +9 -0
- aikit/template/.agents/skills/go-work/SKILL.md +9 -0
- aikit/template/.agents/skills/infra-work/SKILL.md +9 -0
- aikit/template/.agents/skills/java-work/SKILL.md +9 -0
- aikit/template/.agents/skills/laravel-blade-ui/SKILL.md +9 -0
- aikit/template/.agents/skills/laravel-work/SKILL.md +10 -0
- aikit/template/.agents/skills/library-work/SKILL.md +9 -0
- aikit/template/.agents/skills/moodle-work/SKILL.md +9 -0
- aikit/template/.agents/skills/php-work/SKILL.md +9 -0
- aikit/template/.agents/skills/project-continuity/SKILL.md +21 -0
- aikit/template/.agents/skills/python-work/SKILL.md +9 -0
- aikit/template/.agents/skills/rust-work/SKILL.md +9 -0
- aikit/template/.agents/skills/security-audit/SKILL.md +16 -0
- aikit/template/.agents/skills/security-audit/agents/openai.yaml +2 -0
- aikit/template/.agents/skills/strix/SKILL.md +16 -0
- aikit/template/.agents/skills/strix/agents/openai.yaml +2 -0
- aikit/template/.agents/skills/ui-review/SKILL.md +9 -0
- aikit/template/.agents/skills/web-design/SKILL.md +9 -0
- aikit/template/AGENTS.md +16 -0
- aikit/template/CHANGELOG.md +6 -0
- aikit/template/CLAUDE.md +5 -0
- aikit/template/GEMINI.md +4 -0
- aikit/template/KIMI.md +4 -0
- aikit/template/MANUS.md +4 -0
- aikit/template/PROJECT_CONTEXT.md +35 -0
- aikit/template/README.md +6 -0
- aikit/template/WIKI.md +37 -0
- aikit/template/ai-kit/ADAPTERS.md +100 -0
- aikit/template/ai-kit/BOOTSTRAP.md +15 -0
- aikit/template/ai-kit/CONTAINERS.md +63 -0
- aikit/template/ai-kit/CONTEXT7.md +96 -0
- aikit/template/ai-kit/CORE.md +23 -0
- aikit/template/ai-kit/ENGINEERING.md +34 -0
- aikit/template/ai-kit/MCP.md +17 -0
- aikit/template/ai-kit/METRICS.md +83 -0
- aikit/template/ai-kit/SECURITY-AUDIT.md +21 -0
- aikit/template/ai-kit/SECURITY.md +34 -0
- aikit/template/ai-kit/SKILLS.md +37 -0
- aikit/template/ai-kit/STRIX.md +44 -0
- aikit/template/ai-kit/UPSTREAM.md +38 -0
- aikit/template/ai-kit/WEB-DESIGN.md +37 -0
- aikit/template/ai-kit/profiles/OWNER.md +19 -0
- aikit/template/ai-kit/project.json +14 -0
- aikit/template/ai-kit/router/POLICY.md +79 -0
- aikit/template/ai-kit/router/providers/anthropic.json +44 -0
- aikit/template/ai-kit/router/providers/anthropic.md +14 -0
- aikit/template/ai-kit/router/providers/gemini.json +13 -0
- aikit/template/ai-kit/router/providers/gemini.md +10 -0
- aikit/template/ai-kit/router/providers/kimi.json +26 -0
- aikit/template/ai-kit/router/providers/kimi.md +13 -0
- aikit/template/ai-kit/router/providers/local.json +18 -0
- aikit/template/ai-kit/router/providers/local.md +10 -0
- aikit/template/ai-kit/router/providers/openai.json +48 -0
- aikit/template/ai-kit/router/providers/openai.md +14 -0
- aikit/template/ai-kit/router/routing.json +11 -0
- aikit/template/ai-kit/router/selection.json +14 -0
- aikit/template/ai-kit/settings.json +11 -0
- aikit/template/ai-kit/stacks/DOTNET.md +13 -0
- aikit/template/ai-kit/stacks/FILAMENT.md +18 -0
- aikit/template/ai-kit/stacks/FRONTEND.md +9 -0
- aikit/template/ai-kit/stacks/GO.md +19 -0
- aikit/template/ai-kit/stacks/INFRA.md +12 -0
- aikit/template/ai-kit/stacks/JAVA.md +13 -0
- aikit/template/ai-kit/stacks/LARAVEL.md +15 -0
- aikit/template/ai-kit/stacks/LIBRARY.md +9 -0
- aikit/template/ai-kit/stacks/MOODLE.md +12 -0
- aikit/template/ai-kit/stacks/PHP.md +11 -0
- aikit/template/ai-kit/stacks/PYTHON.md +11 -0
- aikit/template/ai-kit/stacks/RUST.md +18 -0
- aikit/template/ai-kit/vendor/security-audit/AI-AND-LLM.md +83 -0
- aikit/template/ai-kit/vendor/security-audit/ATTACK-CLASSES.md +130 -0
- aikit/template/ai-kit/vendor/security-audit/CLIENT-SIDE.md +83 -0
- aikit/template/ai-kit/vendor/security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
- aikit/template/ai-kit/vendor/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
- aikit/template/ai-kit/vendor/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
- aikit/template/ai-kit/vendor/security-audit/HUNTING.md +251 -0
- aikit/template/ai-kit/vendor/security-audit/LICENSE +21 -0
- aikit/template/ai-kit/vendor/security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
- aikit/template/ai-kit/vendor/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
- aikit/template/ai-kit/vendor/security-audit/RECONNAISSANCE.md +156 -0
- aikit/template/ai-kit/vendor/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
- aikit/template/ai-kit/vendor/security-audit/SKILL.md +192 -0
- aikit/template/ai-kit/vendor/security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
- aikit/template/ai-kit/vendor/security-audit/VALIDATION-AND-REPORTING.md +186 -0
- aikit/template/ai-kit/vendor/security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
- aikit/template/ai-kit/vendor/security-audit/report-schema.json +461 -0
- aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.cjs +872 -0
- aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.test.cjs +740 -0
- aikit/template/ai-kit/vendor/security-audit/validate-findings.cjs +773 -0
- aikit/template/ai-kit/vendor/security-audit/validate-findings.test.cjs +652 -0
- aikit/template/ai-kit/vendor/strix/LICENSE +201 -0
- aikit/template/ai-kit/vendor/strix/api-security-testing/SKILL.md +61 -0
- aikit/template/ai-kit/vendor/strix/application-security-testing/SKILL.md +66 -0
- aikit/template/ai-kit/vendor/strix/ci-security-scanning-with-strix/SKILL.md +149 -0
- aikit/template/ai-kit/vendor/strix/find-security-vulnerabilities-in-code/SKILL.md +62 -0
- aikit/template/ai-kit/vendor/strix/fix-security-vulnerabilities-with-strix/SKILL.md +77 -0
- aikit/template/ai-kit/vendor/strix/managed-pentesting-with-strix/SKILL.md +321 -0
- aikit/template/ai-kit/vendor/strix/owasp-top-10-testing/SKILL.md +64 -0
- aikit/template/ai-kit/vendor/strix/penetration-testing-with-strix/SKILL.md +162 -0
- aikit/template/ai-kit/vendor/strix/web-app-penetration-testing/SKILL.md +54 -0
- aikit/template/ai-kit/web-design/LARAVEL.md +51 -0
- aikit/template/ai-kit/web-design/LIBRARIES.md +39 -0
- aikit/template/ai-kit/web-design/REVIEW.md +37 -0
- aikit/template/ai-kit/web-design/TOOLS.md +43 -0
- aikit/template/docs/DECISIONS.md +7 -0
- aikit/template/docs/adr/0000-template.md +20 -0
- aikit/template/docs/design/ACCEPTANCE.md +24 -0
- aikit/template/docs/design/BRIEF.md +26 -0
- aikit/template/docs/design/SYSTEM.md +30 -0
- aikit/templates/agent-secrets.ignore +19 -0
- aikit/templates/gitignore.private +160 -0
- aikit/templates/gitignore.team +139 -0
- aikit/tests/build_distribution.py +73 -0
- aikit/tests/docker_smoke.py +172 -0
- aikit/tests/fixtures/create-junction.ps1 +18 -0
- aikit/tests/fixtures/golden/claude-settings-local.json +58 -0
- aikit/tests/test_adapters.py +105 -0
- aikit/tests/test_aikit.py +79 -0
- aikit/tests/test_benchmark.py +241 -0
- aikit/tests/test_check_kit.py +203 -0
- aikit/tests/test_context.py +76 -0
- aikit/tests/test_context7.py +153 -0
- aikit/tests/test_context_selection.py +70 -0
- aikit/tests/test_distribution.py +181 -0
- aikit/tests/test_docker_image.py +63 -0
- aikit/tests/test_docker_test_usage.py +54 -0
- aikit/tests/test_doctor.py +148 -0
- aikit/tests/test_evaluate.py +66 -0
- aikit/tests/test_install.py +939 -0
- aikit/tests/test_learning.py +247 -0
- aikit/tests/test_memory.py +371 -0
- aikit/tests/test_metrics.py +209 -0
- aikit/tests/test_native.py +342 -0
- aikit/tests/test_onboarding.py +301 -0
- aikit/tests/test_router.py +336 -0
- aikit/tests/test_security_audit.py +237 -0
- aikit/tests/test_strix.py +245 -0
- aikit/tests/test_web_design.py +219 -0
- aikit/tests/wheel_smoke.py +104 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Normalize selected text dialogue branches without network access or tool replay."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
import memory_store as store
|
|
10
|
+
|
|
11
|
+
ROLES = {"user", "assistant", "system", "developer", "tool", "function", "unknown"}
|
|
12
|
+
MAX_MESSAGES = 4000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def text(value: object, label: str, *, maximum: int = 1024 * 1024) -> str:
|
|
16
|
+
if not isinstance(value, str) or len(value) > maximum or "\x00" in value:
|
|
17
|
+
raise ValueError("Invalid " + label)
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def content(value: object) -> tuple[str, int]:
|
|
22
|
+
if isinstance(value, str):
|
|
23
|
+
return text(value, "message text"), 0
|
|
24
|
+
if value is None:
|
|
25
|
+
return "", 1
|
|
26
|
+
if not isinstance(value, list):
|
|
27
|
+
raise ValueError("Expected text or a list of content blocks")
|
|
28
|
+
parts, omitted = [], 0
|
|
29
|
+
for part in value:
|
|
30
|
+
if isinstance(part, str):
|
|
31
|
+
parts.append(text(part, "text part"))
|
|
32
|
+
elif isinstance(part, dict):
|
|
33
|
+
if part.get("type") in {"text", "input_text", "output_text"}:
|
|
34
|
+
parts.append(text(part.get("text"), "text block"))
|
|
35
|
+
else:
|
|
36
|
+
omitted += 1
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError("Invalid content part")
|
|
39
|
+
return text("\n".join(parts), "combined message"), omitted
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize(rows: list[dict], *, source_format: str, raw: bytes,
|
|
43
|
+
selection: dict | None = None, omitted: int = 0) -> dict:
|
|
44
|
+
if not rows or len(rows) > MAX_MESSAGES:
|
|
45
|
+
raise ValueError("Dialogue must contain 1-4000 messages")
|
|
46
|
+
messages = []
|
|
47
|
+
for number, row in enumerate(rows, 1):
|
|
48
|
+
role = row.get("role")
|
|
49
|
+
if not isinstance(role, str) or role not in ROLES:
|
|
50
|
+
raise ValueError("Unknown message role")
|
|
51
|
+
body, dropped = content(row.get("content"))
|
|
52
|
+
omitted += dropped + int(bool(row.get("tool_calls")))
|
|
53
|
+
timestamp = row.get("timestamp")
|
|
54
|
+
if timestamp is not None and (type(timestamp) not in {str, int, float} or
|
|
55
|
+
(type(timestamp) in {int, float} and not math.isfinite(timestamp))):
|
|
56
|
+
raise ValueError("Invalid message timestamp")
|
|
57
|
+
original_id = row.get("id")
|
|
58
|
+
if original_id is not None:
|
|
59
|
+
text(original_id, "original message identifier", maximum=256)
|
|
60
|
+
messages.append({"id": f"m{number:04d}", "role": role, "text": body,
|
|
61
|
+
"original_id": original_id, "timestamp": timestamp})
|
|
62
|
+
if not any(m["text"].strip() for m in messages):
|
|
63
|
+
raise ValueError("Dialogue has no usable text")
|
|
64
|
+
return {"schema": 1, "kind": "aikit-dialogue", "format": source_format,
|
|
65
|
+
"source_sha256": hashlib.sha256(raw).hexdigest(), "selection": selection or {},
|
|
66
|
+
"omitted_nontext": omitted, "messages": messages}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def chatgpt(value: object, conversation_id: str | None, branch: str | None) -> tuple[list[dict], dict]:
|
|
70
|
+
conversations = value if isinstance(value, list) else [value]
|
|
71
|
+
if not conversations or any(not isinstance(c, dict) for c in conversations):
|
|
72
|
+
raise ValueError("Expected ChatGPT conversation object(s)")
|
|
73
|
+
if conversation_id is not None:
|
|
74
|
+
conversations = [c for c in conversations if c.get("id", c.get("conversation_id")) == conversation_id]
|
|
75
|
+
if len(conversations) != 1:
|
|
76
|
+
raise ValueError("Select exactly one conversation with --conversation-id")
|
|
77
|
+
conversation = conversations[0]
|
|
78
|
+
mapping = conversation.get("mapping")
|
|
79
|
+
if not isinstance(mapping, dict) or not mapping or len(mapping) > 20000:
|
|
80
|
+
raise ValueError("Expected a bounded ChatGPT mapping")
|
|
81
|
+
if any(not isinstance(key, str) or not isinstance(node, dict) for key, node in mapping.items()):
|
|
82
|
+
raise ValueError("Invalid ChatGPT mapping nodes")
|
|
83
|
+
leaf = branch if branch is not None else conversation.get("current_node")
|
|
84
|
+
if leaf is None:
|
|
85
|
+
parents = {n.get("parent") for n in mapping.values() if isinstance(n.get("parent"), str)}
|
|
86
|
+
leaves = set(mapping) - parents
|
|
87
|
+
if len(leaves) != 1:
|
|
88
|
+
raise ValueError("Ambiguous ChatGPT branch; pass --branch")
|
|
89
|
+
leaf = next(iter(leaves))
|
|
90
|
+
if not isinstance(leaf, str) or leaf not in mapping:
|
|
91
|
+
raise ValueError("Selected ChatGPT branch is missing")
|
|
92
|
+
visited, nodes = set(), []
|
|
93
|
+
node_id = leaf
|
|
94
|
+
while node_id is not None:
|
|
95
|
+
if not isinstance(node_id, str) or node_id not in mapping or node_id in visited:
|
|
96
|
+
raise ValueError("Broken or cyclic ChatGPT parent chain")
|
|
97
|
+
visited.add(node_id)
|
|
98
|
+
node = mapping[node_id]
|
|
99
|
+
if "parent" not in node:
|
|
100
|
+
raise ValueError("Missing ChatGPT parent field")
|
|
101
|
+
nodes.append((node_id, node))
|
|
102
|
+
node_id = node.get("parent")
|
|
103
|
+
rows = []
|
|
104
|
+
for node_id, node in reversed(nodes):
|
|
105
|
+
message = node.get("message")
|
|
106
|
+
if message is None:
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(message, dict) or not isinstance(message.get("author"), dict):
|
|
109
|
+
raise ValueError("Invalid ChatGPT message")
|
|
110
|
+
payload = message.get("content")
|
|
111
|
+
if not isinstance(payload, dict):
|
|
112
|
+
raise ValueError("Invalid ChatGPT content object")
|
|
113
|
+
if "parts" in payload:
|
|
114
|
+
body = payload["parts"]
|
|
115
|
+
elif isinstance(payload.get("text"), str):
|
|
116
|
+
body = payload["text"]
|
|
117
|
+
else:
|
|
118
|
+
body = None # Nontext-only messages stay visible as an omission.
|
|
119
|
+
rows.append({"id": node_id, "role": message["author"].get("role"), "content": body,
|
|
120
|
+
"timestamp": message.get("create_time")})
|
|
121
|
+
selected_id = conversation.get("id", conversation.get("conversation_id"))
|
|
122
|
+
if selected_id is not None:
|
|
123
|
+
text(selected_id, "conversation identifier", maximum=256)
|
|
124
|
+
return rows, {"conversation_id": selected_id, "branch": leaf}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def transcript(value: str) -> list[dict]:
|
|
128
|
+
"""Recognize explicit role headers outside code fences; preserve unlabeled text."""
|
|
129
|
+
rows, lines, role, fence = [], [], "unknown", None
|
|
130
|
+
header = re.compile(r"^(?:#{1,6}\s+)?(user|assistant|system|developer|tool|human|chatgpt|\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c|\u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442):\s*(.*)$", re.I)
|
|
131
|
+
for line in value.splitlines(keepends=True):
|
|
132
|
+
match = header.fullmatch(line.rstrip("\r\n")) if fence is None else None
|
|
133
|
+
if match:
|
|
134
|
+
if lines:
|
|
135
|
+
rows.append({"role": role, "content": "".join(lines)})
|
|
136
|
+
label = match[1].casefold()
|
|
137
|
+
role = {"human": "user", "chatgpt": "assistant", "\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c": "user",
|
|
138
|
+
"\u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442": "assistant"}.get(label, label)
|
|
139
|
+
lines = [match[2] + ("\n" if line.endswith("\n") else "")] if match[2] else []
|
|
140
|
+
marker = re.match(r"^\s*(`{3,}|~{3,})", match[2])
|
|
141
|
+
if marker:
|
|
142
|
+
fence = marker[1]
|
|
143
|
+
else:
|
|
144
|
+
lines.append(line)
|
|
145
|
+
marker = re.match(r"^\s*(`{3,}|~{3,})", line)
|
|
146
|
+
if marker:
|
|
147
|
+
token = marker[1]
|
|
148
|
+
if fence is None:
|
|
149
|
+
fence = token
|
|
150
|
+
elif token[0] == fence[0] and len(token) >= len(fence):
|
|
151
|
+
fence = None
|
|
152
|
+
if lines:
|
|
153
|
+
rows.append({"role": role, "content": "".join(lines)})
|
|
154
|
+
return rows
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load(path: Path, *, source_format: str = "auto", conversation_id: str | None = None,
|
|
158
|
+
branch: str | None = None) -> dict:
|
|
159
|
+
raw = store.read_bytes(path)
|
|
160
|
+
if source_format == "auto":
|
|
161
|
+
source_format = "json" if path.suffix.casefold() == ".json" else "text"
|
|
162
|
+
if source_format == "text":
|
|
163
|
+
if conversation_id is not None or branch is not None:
|
|
164
|
+
raise ValueError("Conversation/branch selection requires ChatGPT JSON")
|
|
165
|
+
return normalize(transcript(raw.decode("utf-8-sig")), source_format="text", raw=raw)
|
|
166
|
+
value = store.decode(raw)
|
|
167
|
+
is_mapping = isinstance(value, dict) and "mapping" in value
|
|
168
|
+
is_collection = isinstance(value, list) and any(isinstance(c, dict) and "mapping" in c for c in value)
|
|
169
|
+
if source_format == "chatgpt" or (source_format == "json" and (is_mapping or is_collection)):
|
|
170
|
+
rows, selection = chatgpt(value, conversation_id, branch)
|
|
171
|
+
return normalize(rows, source_format="chatgpt", raw=raw, selection=selection)
|
|
172
|
+
if conversation_id is not None or branch is not None:
|
|
173
|
+
raise ValueError("Conversation/branch selection requires ChatGPT JSON")
|
|
174
|
+
rows = value.get("messages") if isinstance(value, dict) else value
|
|
175
|
+
if not isinstance(rows, list) or any(not isinstance(m, dict) for m in rows):
|
|
176
|
+
raise ValueError("Expected a messages array or an object containing messages")
|
|
177
|
+
return normalize(rows, source_format="messages", raw=raw)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Bounded local I/O and immutable artifacts for dialogue memory."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import stat
|
|
9
|
+
|
|
10
|
+
import install
|
|
11
|
+
|
|
12
|
+
LIMIT = 16 * 1024 * 1024
|
|
13
|
+
MARKER = {"schema": 1, "kind": "aikit-memory-workspace"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode(value: object) -> bytes:
|
|
17
|
+
return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False) + "\n").encode("utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def digest(value: object) -> str:
|
|
21
|
+
return hashlib.sha256(encode(value)).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def guarded(path: Path) -> Path:
|
|
25
|
+
path = path.absolute()
|
|
26
|
+
for parent in (path, *path.parents):
|
|
27
|
+
if install.is_link(parent):
|
|
28
|
+
raise ValueError("Linked paths and junctions are not supported")
|
|
29
|
+
if parent != path and parent.exists() and not parent.is_dir():
|
|
30
|
+
raise ValueError("Path parent is not a directory")
|
|
31
|
+
return path.resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_bytes(path: Path, *, limit: int = LIMIT) -> bytes:
|
|
35
|
+
path = guarded(path)
|
|
36
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
37
|
+
with os.fdopen(os.open(path, flags), "rb") as stream:
|
|
38
|
+
info = os.fstat(stream.fileno())
|
|
39
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
|
40
|
+
raise ValueError("Expected a bounded regular file")
|
|
41
|
+
data = stream.read(limit + 1)
|
|
42
|
+
if len(data) > limit:
|
|
43
|
+
raise ValueError("Input exceeds the byte limit")
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decode(data: bytes) -> object:
|
|
48
|
+
def pairs(items):
|
|
49
|
+
result = {}
|
|
50
|
+
for key, value in items:
|
|
51
|
+
if key in result:
|
|
52
|
+
raise ValueError("Duplicate JSON keys are not supported")
|
|
53
|
+
result[key] = value
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def constant(_):
|
|
57
|
+
raise ValueError("Nonfinite JSON numbers are not supported")
|
|
58
|
+
|
|
59
|
+
return json.loads(data.decode("utf-8-sig"), object_pairs_hook=pairs, parse_constant=constant)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_json(path: Path) -> object:
|
|
63
|
+
return decode(read_bytes(path))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def root(path: Path, *, existing: bool = True) -> Path:
|
|
67
|
+
path = guarded(path)
|
|
68
|
+
if path.exists() and not path.is_dir():
|
|
69
|
+
raise ValueError("Workspace must be a directory")
|
|
70
|
+
marker = install.safe_path(path, "workspace.json")
|
|
71
|
+
if marker.exists():
|
|
72
|
+
value = read_json(marker)
|
|
73
|
+
if not isinstance(value, dict) or type(value.get("schema")) is not int or value != MARKER:
|
|
74
|
+
raise ValueError("Unknown workspace marker")
|
|
75
|
+
elif existing or (path.exists() and any(path.iterdir())):
|
|
76
|
+
raise ValueError("Use a new/empty directory or an AI-KIT memory workspace")
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def artifact(workspace: Path, relative: str) -> object:
|
|
81
|
+
return read_json(install.safe_path(root(workspace), relative))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write(workspace: Path, files: dict[str, bytes], *, apply: bool) -> list[str]:
|
|
85
|
+
"""Preview first; exclusively create files, preserve existing bytes, allow identical reruns."""
|
|
86
|
+
workspace = root(workspace, existing=False)
|
|
87
|
+
if "workspace.json" in files:
|
|
88
|
+
raise ValueError("Workspace marker is managed internally")
|
|
89
|
+
files = {"workspace.json": encode(MARKER), **files}
|
|
90
|
+
writes = []
|
|
91
|
+
for relative, data in files.items():
|
|
92
|
+
if len(data) > LIMIT:
|
|
93
|
+
raise ValueError("Artifact exceeds the byte limit")
|
|
94
|
+
target = install.safe_path(workspace, relative)
|
|
95
|
+
if target.exists():
|
|
96
|
+
if target.stat().st_nlink != 1 or read_bytes(target) != data:
|
|
97
|
+
raise ValueError("Existing artifact differs or is hard-linked; use a new name/workspace")
|
|
98
|
+
else:
|
|
99
|
+
writes.append(relative)
|
|
100
|
+
if not apply:
|
|
101
|
+
return writes
|
|
102
|
+
for relative in writes:
|
|
103
|
+
target = install.safe_path(guarded(workspace), relative)
|
|
104
|
+
missing = []
|
|
105
|
+
parent = target.parent
|
|
106
|
+
while not parent.exists():
|
|
107
|
+
missing.append(parent)
|
|
108
|
+
parent = parent.parent
|
|
109
|
+
for parent in reversed(missing):
|
|
110
|
+
guarded(parent)
|
|
111
|
+
parent.mkdir(mode=0o700, exist_ok=True)
|
|
112
|
+
# O_EXCL refuses a file/link created after planning, rather than replacing it.
|
|
113
|
+
descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
114
|
+
identity = os.fstat(descriptor)
|
|
115
|
+
try:
|
|
116
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
117
|
+
stream.write(files[relative])
|
|
118
|
+
stream.flush()
|
|
119
|
+
os.fsync(stream.fileno())
|
|
120
|
+
except BaseException:
|
|
121
|
+
current = target.lstat()
|
|
122
|
+
if (current.st_dev, current.st_ino) == (identity.st_dev, identity.st_ino):
|
|
123
|
+
target.unlink()
|
|
124
|
+
raise
|
|
125
|
+
return writes
|
aikit/scripts/metrics.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Opt-in local task measurements. No prompts, credentials, API calls, or automatic pricing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
15
|
+
import install
|
|
16
|
+
|
|
17
|
+
JOURNAL = "ai-kit/.metrics/tasks.jsonl"
|
|
18
|
+
LOCK = "ai-kit/.metrics/write.lock"
|
|
19
|
+
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}\Z")
|
|
20
|
+
TOKEN_FIELDS = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens")
|
|
21
|
+
ATTEMPT_DISPOSITIONS = ("succeeded", "failed", "inconclusive")
|
|
22
|
+
STACKS = ("LARAVEL", "FILAMENT", "GO", "MOODLE", "PYTHON", "RUST", "DOTNET", "JAVA", "INFRA", "unknown")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fields(value: object, allowed: set[str], label: str) -> dict:
|
|
26
|
+
if not isinstance(value, dict) or set(value) - allowed:
|
|
27
|
+
raise ValueError(f"Invalid {label} object or unknown fields")
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _identifier(value: object, label: str) -> str:
|
|
32
|
+
if not isinstance(value, str) or not IDENTIFIER.fullmatch(value):
|
|
33
|
+
raise ValueError(f"Invalid {label}; use an opaque identifier, not task text")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_record(value: object) -> dict:
|
|
38
|
+
record = _fields(value, {"schema", "task_id", "experiment", "arm", "kit_version", "outcome",
|
|
39
|
+
"checks", "rework", "escalations", "attempts", "metadata"}, "task record")
|
|
40
|
+
if type(record.get("schema")) is not int or record["schema"] not in {1, 2, 3}:
|
|
41
|
+
raise ValueError("Expected measurement schema 1, 2 or 3")
|
|
42
|
+
if record["schema"] < 3 and "metadata" in record:
|
|
43
|
+
raise ValueError("Metadata requires schema 3")
|
|
44
|
+
if record["schema"] == 3:
|
|
45
|
+
metadata = _fields(record.get("metadata"), {"stack", "task_class", "level", "revision",
|
|
46
|
+
"repetition", "review", "capabilities", "case_id"}, "metadata")
|
|
47
|
+
if set(metadata) != {"stack", "task_class", "level", "revision", "repetition", "review", "capabilities", "case_id"}:
|
|
48
|
+
raise ValueError("Schema 3 requires complete metadata, with null for unknown revision/level")
|
|
49
|
+
if not isinstance(metadata["stack"], str) or metadata["stack"] not in STACKS:
|
|
50
|
+
raise ValueError("Unknown measurement stack")
|
|
51
|
+
_identifier(metadata["task_class"], "task_class")
|
|
52
|
+
_identifier(metadata["case_id"], "case_id")
|
|
53
|
+
if metadata["level"] is not None and (type(metadata["level"]) is not int or not 0 <= metadata["level"] <= 6):
|
|
54
|
+
raise ValueError("Metadata level must be 0-6 or null")
|
|
55
|
+
if metadata["revision"] is not None and (not isinstance(metadata["revision"], str) or
|
|
56
|
+
not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", metadata["revision"])):
|
|
57
|
+
raise ValueError("Revision must be a full Git object ID or null")
|
|
58
|
+
if type(metadata["repetition"]) is not int or metadata["repetition"] < 1:
|
|
59
|
+
raise ValueError("Repetition must be a positive integer")
|
|
60
|
+
if not isinstance(metadata["review"], str) or metadata["review"] not in {"manual", "independent", "pending"}:
|
|
61
|
+
raise ValueError("Review must be manual, independent or pending")
|
|
62
|
+
if metadata["review"] == "pending" and record.get("outcome") != "incomplete":
|
|
63
|
+
raise ValueError("Pending semantic review requires an incomplete outcome")
|
|
64
|
+
if not isinstance(metadata["capabilities"], list):
|
|
65
|
+
raise ValueError("Capabilities must be a list")
|
|
66
|
+
for capability in metadata["capabilities"]:
|
|
67
|
+
_identifier(capability, "capability")
|
|
68
|
+
for field in ("task_id", "experiment", "arm", "kit_version"):
|
|
69
|
+
_identifier(record.get(field), field)
|
|
70
|
+
if not isinstance(record.get("outcome"), str) or record["outcome"] not in {"correct", "incorrect", "incomplete"}:
|
|
71
|
+
raise ValueError("Outcome must be correct, incorrect, or incomplete after review")
|
|
72
|
+
if not isinstance(record.get("checks"), str) or record["checks"] not in {"passed", "failed", "not-run"}:
|
|
73
|
+
raise ValueError("Checks must be passed, failed, or not-run")
|
|
74
|
+
if record["outcome"] == "correct" and record["checks"] == "failed":
|
|
75
|
+
raise ValueError("A correct result cannot have failed required checks")
|
|
76
|
+
for field in ("rework", "escalations"):
|
|
77
|
+
if record["schema"] == 3 and field in record and record[field] is None:
|
|
78
|
+
continue
|
|
79
|
+
if type(record.get(field)) is not int or record[field] < 0:
|
|
80
|
+
raise ValueError(f"{field} must be a nonnegative integer")
|
|
81
|
+
attempts = record.get("attempts")
|
|
82
|
+
if not isinstance(attempts, list) or not attempts:
|
|
83
|
+
raise ValueError("Include all attempts, including failures and recovery")
|
|
84
|
+
for attempt in attempts:
|
|
85
|
+
allowed = {"provider", "model", "effort", "duration_seconds", "cost", *TOKEN_FIELDS}
|
|
86
|
+
if record["schema"] >= 2:
|
|
87
|
+
allowed.add("disposition")
|
|
88
|
+
_fields(attempt, allowed,
|
|
89
|
+
"attempt")
|
|
90
|
+
for field in ("provider", "model", "effort"):
|
|
91
|
+
if attempt.get(field) is not None:
|
|
92
|
+
_identifier(attempt[field], field)
|
|
93
|
+
duration = attempt.get("duration_seconds")
|
|
94
|
+
if duration is not None and (type(duration) not in {int, float} or
|
|
95
|
+
duration < 0 or duration > sys.float_info.max or
|
|
96
|
+
not math.isfinite(duration)):
|
|
97
|
+
raise ValueError("duration_seconds must be finite and nonnegative, or null")
|
|
98
|
+
for field in TOKEN_FIELDS:
|
|
99
|
+
number = attempt.get(field)
|
|
100
|
+
if number is not None and (type(number) is not int or number < 0):
|
|
101
|
+
raise ValueError(f"{field} must be a nonnegative integer or null")
|
|
102
|
+
for subset, total in (("cached_input_tokens", "input_tokens"),
|
|
103
|
+
("reasoning_tokens", "output_tokens")):
|
|
104
|
+
if (attempt.get(subset) is not None and attempt.get(total) is not None and
|
|
105
|
+
attempt[subset] > attempt[total]):
|
|
106
|
+
raise ValueError(f"{subset} cannot exceed {total}")
|
|
107
|
+
cost = attempt.get("cost")
|
|
108
|
+
if cost is not None:
|
|
109
|
+
_fields(cost, {"amount", "currency", "source"}, "cost")
|
|
110
|
+
amount = cost.get("amount")
|
|
111
|
+
if not isinstance(amount, str) or not re.fullmatch(r"\d{1,16}(?:\.\d{1,12})?", amount):
|
|
112
|
+
raise ValueError("Cost amount must be a nonnegative decimal string")
|
|
113
|
+
try:
|
|
114
|
+
Decimal(amount)
|
|
115
|
+
except InvalidOperation as exc:
|
|
116
|
+
raise ValueError("Invalid decimal amount") from exc
|
|
117
|
+
if not isinstance(cost.get("currency"), str) or not re.fullmatch(r"[A-Z]{3}", cost["currency"]):
|
|
118
|
+
raise ValueError("Cost currency must be a three-letter uppercase code")
|
|
119
|
+
if not isinstance(cost.get("source"), str) or cost["source"] not in {"billing", "provider-usage", "estimate"}:
|
|
120
|
+
raise ValueError("Cost source must be billing, provider-usage, or estimate")
|
|
121
|
+
if record["schema"] >= 2 and attempt.get("disposition") not in ATTEMPT_DISPOSITIONS:
|
|
122
|
+
raise ValueError("Schema 2 attempts need disposition: succeeded, failed, or inconclusive")
|
|
123
|
+
# Normalize JSON values without carrying caller-owned mutable objects into a write plan.
|
|
124
|
+
return json.loads(json.dumps(record, allow_nan=False))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _target(project: Path) -> Path:
|
|
128
|
+
project = project.absolute()
|
|
129
|
+
if install.is_link(project):
|
|
130
|
+
raise ValueError("Refusing a linked project root")
|
|
131
|
+
project = project.resolve()
|
|
132
|
+
if not project.is_dir():
|
|
133
|
+
raise ValueError("Project must be an existing directory")
|
|
134
|
+
return project
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_records(project: Path) -> list[dict]:
|
|
138
|
+
path = install.safe_path(_target(project), JOURNAL)
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return []
|
|
141
|
+
records, identities = [], set()
|
|
142
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
143
|
+
try:
|
|
144
|
+
record = validate_record(json.loads(line))
|
|
145
|
+
identity = (record["experiment"], record["arm"], record["task_id"])
|
|
146
|
+
if identity in identities:
|
|
147
|
+
raise ValueError("Duplicate task identity")
|
|
148
|
+
identities.add(identity)
|
|
149
|
+
records.append(record)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise ValueError(f"Invalid journal line {number}: {exc}") from exc
|
|
152
|
+
return records
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def record_task(project: Path, record: dict, *, apply: bool = False) -> dict:
|
|
156
|
+
project = _target(project)
|
|
157
|
+
record = validate_record(record)
|
|
158
|
+
path = install.safe_path(project, JOURNAL)
|
|
159
|
+
lock = install.safe_path(project, LOCK)
|
|
160
|
+
|
|
161
|
+
def check_duplicate(records: list[dict]) -> None:
|
|
162
|
+
if any(all(existing[key] == record[key] for key in ("experiment", "arm", "task_id"))
|
|
163
|
+
for existing in records):
|
|
164
|
+
raise ValueError("Task already recorded for this experiment/arm; journal left unchanged")
|
|
165
|
+
|
|
166
|
+
check_duplicate(read_records(project))
|
|
167
|
+
if apply:
|
|
168
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
# Exclusive lock protects cooperating writers. Never remove another writer's lock.
|
|
170
|
+
descriptor = os.open(install.safe_path(project, LOCK), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
171
|
+
try:
|
|
172
|
+
os.close(descriptor)
|
|
173
|
+
records = read_records(project)
|
|
174
|
+
check_duplicate(records)
|
|
175
|
+
data = "".join(json.dumps(item, ensure_ascii=True, allow_nan=False) + "\n"
|
|
176
|
+
for item in [*records, record]).encode("utf-8")
|
|
177
|
+
install.atomic_write(install.safe_path(project, JOURNAL), data)
|
|
178
|
+
finally:
|
|
179
|
+
install.safe_path(project, LOCK).unlink()
|
|
180
|
+
return {"preview": not apply, "journal": str(path), "task_id": record["task_id"],
|
|
181
|
+
"attempts": len(record["attempts"])}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def summarize(records: list[dict]) -> dict:
|
|
185
|
+
groups: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
|
186
|
+
for record in records:
|
|
187
|
+
record = validate_record(record)
|
|
188
|
+
groups[(record["experiment"], record["arm"])].append(record)
|
|
189
|
+
results = []
|
|
190
|
+
for (experiment, arm), tasks in sorted(groups.items()):
|
|
191
|
+
attempts = [attempt for task in tasks for attempt in task["attempts"]]
|
|
192
|
+
correct = sum(task["outcome"] == "correct" for task in tasks)
|
|
193
|
+
costs: dict[str, Decimal] = defaultdict(Decimal)
|
|
194
|
+
known_costs = 0
|
|
195
|
+
estimated = 0
|
|
196
|
+
for attempt in attempts:
|
|
197
|
+
cost = attempt.get("cost")
|
|
198
|
+
if cost is not None:
|
|
199
|
+
costs[cost["currency"]] += Decimal(cost["amount"])
|
|
200
|
+
known_costs += 1
|
|
201
|
+
estimated += cost["source"] == "estimate"
|
|
202
|
+
complete = known_costs == len(attempts) and len(costs) == 1 and not estimated
|
|
203
|
+
total = next(iter(costs.values())) if complete else None
|
|
204
|
+
durations = [attempt["duration_seconds"] for attempt in attempts
|
|
205
|
+
if attempt.get("duration_seconds") is not None]
|
|
206
|
+
results.append({"experiment": experiment, "arm": arm, "tasks": len(tasks), "correct": correct,
|
|
207
|
+
"success_rate": correct / len(tasks), "attempts": len(attempts),
|
|
208
|
+
"rework": sum(task["rework"] for task in tasks) if all(task["rework"] is not None for task in tasks) else None,
|
|
209
|
+
"escalations": sum(task["escalations"] for task in tasks) if all(task["escalations"] is not None for task in tasks) else None,
|
|
210
|
+
"intervention_coverage": {field: {"known": sum(task[field] is not None for task in tasks),
|
|
211
|
+
"total": len(tasks)} for field in ("rework", "escalations")},
|
|
212
|
+
"checks_not_run": sum(task["checks"] == "not-run" for task in tasks),
|
|
213
|
+
"known_cost_by_currency": {key: str(value) for key, value in sorted(costs.items())},
|
|
214
|
+
"cost_coverage": {"known": known_costs, "total": len(attempts), "estimated": estimated},
|
|
215
|
+
"cost_per_correct_result": str(total / correct) if complete and correct else None,
|
|
216
|
+
"duration_seconds": sum(durations) if len(durations) == len(attempts) else None,
|
|
217
|
+
"duration_coverage": {"known": len(durations), "total": len(attempts)},
|
|
218
|
+
"tokens": {field: {"known_total": sum(a.get(field) or 0 for a in attempts),
|
|
219
|
+
"known_attempts": sum(a.get(field) is not None for a in attempts),
|
|
220
|
+
"total_attempts": len(attempts)} for field in TOKEN_FIELDS}})
|
|
221
|
+
return {"groups": results,
|
|
222
|
+
"note": "Descriptive totals only; include all failed/recovery attempts. Unknown values remain "
|
|
223
|
+
"unknown. Compare matched tasks and reviewed correctness before claiming savings."}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def analyze(records: list[dict]) -> dict:
|
|
227
|
+
"""Aggregate reviewed tasks per provider to inform routing without writing anything."""
|
|
228
|
+
providers: dict[str, dict] = {}
|
|
229
|
+
for record in records:
|
|
230
|
+
record = validate_record(record)
|
|
231
|
+
correct = record["outcome"] == "correct"
|
|
232
|
+
for attempt in record["attempts"]:
|
|
233
|
+
provider = attempt.get("provider")
|
|
234
|
+
if provider is None:
|
|
235
|
+
continue
|
|
236
|
+
entry = providers.setdefault(provider, {"tasks": set(), "correct_tasks": set(),
|
|
237
|
+
"attempts": 0, "cost": defaultdict(Decimal),
|
|
238
|
+
"cost_attempts": 0, "models": set(), "efforts": set(),
|
|
239
|
+
"dispositions": defaultdict(int), "known_dispositions": 0,
|
|
240
|
+
"estimated": 0})
|
|
241
|
+
entry["attempts"] += 1
|
|
242
|
+
identity = (record["experiment"], record["arm"], record["task_id"])
|
|
243
|
+
entry["tasks"].add(identity)
|
|
244
|
+
if correct:
|
|
245
|
+
entry["correct_tasks"].add(identity)
|
|
246
|
+
if attempt.get("model") is not None:
|
|
247
|
+
entry["models"].add(attempt["model"])
|
|
248
|
+
if attempt.get("effort") is not None:
|
|
249
|
+
entry["efforts"].add(attempt["effort"])
|
|
250
|
+
cost = attempt.get("cost")
|
|
251
|
+
if cost is not None:
|
|
252
|
+
entry["cost"][cost["currency"]] += Decimal(cost["amount"])
|
|
253
|
+
entry["cost_attempts"] += 1
|
|
254
|
+
entry["estimated"] += cost["source"] == "estimate"
|
|
255
|
+
disposition = attempt.get("disposition")
|
|
256
|
+
if disposition is not None:
|
|
257
|
+
entry["dispositions"][disposition] += 1
|
|
258
|
+
entry["known_dispositions"] += 1
|
|
259
|
+
results = []
|
|
260
|
+
for name in sorted(providers):
|
|
261
|
+
entry = providers[name]
|
|
262
|
+
tasks = len(entry["tasks"])
|
|
263
|
+
correct = len(entry["correct_tasks"])
|
|
264
|
+
complete = (entry["cost_attempts"] == entry["attempts"] and len(entry["cost"]) == 1
|
|
265
|
+
and not entry["estimated"])
|
|
266
|
+
total = next(iter(entry["cost"].values())) if complete else None
|
|
267
|
+
decisive = entry["dispositions"]["succeeded"] + entry["dispositions"]["failed"]
|
|
268
|
+
results.append({"provider": name, "attempts": entry["attempts"], "tasks": tasks,
|
|
269
|
+
"correct_tasks": correct, "success_rate": correct / tasks if tasks else None,
|
|
270
|
+
"known_cost_by_currency": {key: str(value) for key, value in sorted(entry["cost"].items())},
|
|
271
|
+
"cost_coverage": {"known": entry["cost_attempts"], "total": entry["attempts"],
|
|
272
|
+
"estimated": entry["estimated"]},
|
|
273
|
+
"cost_per_correct": str(total / correct) if complete and correct else None,
|
|
274
|
+
"attempt_dispositions": {key: entry["dispositions"][key] for key in ATTEMPT_DISPOSITIONS},
|
|
275
|
+
"attempt_disposition_coverage": {"known": entry["known_dispositions"],
|
|
276
|
+
"total": entry["attempts"]},
|
|
277
|
+
"attempt_success_rate": (entry["dispositions"]["succeeded"] / decisive
|
|
278
|
+
if decisive else None),
|
|
279
|
+
"models": sorted(entry["models"]), "efforts": sorted(entry["efforts"])})
|
|
280
|
+
return {"providers": results,
|
|
281
|
+
"note": "Descriptive per-provider totals from reviewed records; a task identity is experiment, arm, "
|
|
282
|
+
"and task_id, and each provider used in its attempts receives that attribution. Schema 2 "
|
|
283
|
+
"attempt dispositions describe execution, not causation. Compare matched tasks and reviewed "
|
|
284
|
+
"correctness before changing selection.json."}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: list[str] | None = None) -> int:
|
|
288
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
289
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
290
|
+
record_parser = sub.add_parser("record", help="Preview or record one reviewed task and all its attempts")
|
|
291
|
+
record_parser.add_argument("project", type=Path)
|
|
292
|
+
record_parser.add_argument("--from-json", type=Path, required=True)
|
|
293
|
+
record_parser.add_argument("--apply", action="store_true")
|
|
294
|
+
summary_parser = sub.add_parser("summary", help="Summarize the local journal without writing")
|
|
295
|
+
summary_parser.add_argument("project", type=Path)
|
|
296
|
+
analyze_parser = sub.add_parser("analyze", help="Aggregate per provider to inform routing, without writing")
|
|
297
|
+
analyze_parser.add_argument("project", type=Path)
|
|
298
|
+
compare_parser = sub.add_parser("compare", help="Compare matched reviewed trials with actual costs")
|
|
299
|
+
compare_parser.add_argument("project", type=Path)
|
|
300
|
+
compare_parser.add_argument("--experiment", required=True)
|
|
301
|
+
compare_parser.add_argument("--baseline", default="baseline")
|
|
302
|
+
compare_parser.add_argument("--candidate", default="kit-fixed")
|
|
303
|
+
stacks_parser = sub.add_parser("stacks", help="Summarize by declared technology stack")
|
|
304
|
+
stacks_parser.add_argument("project", type=Path)
|
|
305
|
+
propose_parser = sub.add_parser("propose", help="Preview or save evidence; never apply routing changes")
|
|
306
|
+
propose_parser.add_argument("project", type=Path)
|
|
307
|
+
propose_parser.add_argument("--stack", choices=STACKS, required=True)
|
|
308
|
+
propose_parser.add_argument("--task-class", required=True)
|
|
309
|
+
propose_parser.add_argument("--level", type=int, required=True)
|
|
310
|
+
propose_parser.add_argument("--capability", action="append", default=[])
|
|
311
|
+
propose_parser.add_argument("--minimum", type=int, default=20)
|
|
312
|
+
propose_parser.add_argument("--quality-floor", type=float, default=0.8)
|
|
313
|
+
propose_parser.add_argument("--apply", action="store_true")
|
|
314
|
+
restore_parser = sub.add_parser("restore", help="Explicitly restore a proposal's prior selection with backup")
|
|
315
|
+
restore_parser.add_argument("project", type=Path)
|
|
316
|
+
restore_parser.add_argument("--proposal", required=True)
|
|
317
|
+
restore_parser.add_argument("--apply", action="store_true")
|
|
318
|
+
args = parser.parse_args(argv)
|
|
319
|
+
try:
|
|
320
|
+
if args.command == "record":
|
|
321
|
+
record = json.loads(args.from_json.read_text(encoding="utf-8"))
|
|
322
|
+
result = record_task(args.project, record, apply=args.apply)
|
|
323
|
+
elif args.command == "analyze":
|
|
324
|
+
result = analyze(read_records(args.project))
|
|
325
|
+
elif args.command in {"compare", "stacks", "propose", "restore"}:
|
|
326
|
+
import learning
|
|
327
|
+
if args.command == "compare":
|
|
328
|
+
result = learning.compare(read_records(args.project), args.experiment, args.baseline, args.candidate)
|
|
329
|
+
elif args.command == "stacks":
|
|
330
|
+
result = learning.by_stack(read_records(args.project))
|
|
331
|
+
elif args.command == "restore":
|
|
332
|
+
result = learning.restore_selection(args.project, args.proposal, apply=args.apply)
|
|
333
|
+
else:
|
|
334
|
+
proposal = {"stack": args.stack, "task_class": args.task_class, "level": args.level,
|
|
335
|
+
"capabilities": args.capability, "minimum": args.minimum,
|
|
336
|
+
"quality_floor": args.quality_floor,
|
|
337
|
+
"candidates": learning.candidates(read_records(args.project), stack=args.stack,
|
|
338
|
+
task_class=args.task_class, level=args.level, capabilities=args.capability,
|
|
339
|
+
minimum=args.minimum, quality_floor=args.quality_floor)}
|
|
340
|
+
result = learning.save_proposal(args.project, proposal, apply=args.apply)
|
|
341
|
+
else:
|
|
342
|
+
result = summarize(read_records(args.project))
|
|
343
|
+
print(json.dumps(result, indent=2, allow_nan=False))
|
|
344
|
+
return 0
|
|
345
|
+
except (OSError, ValueError) as exc:
|
|
346
|
+
print(f"Measurement refused: {exc}", file=sys.stderr)
|
|
347
|
+
return 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
raise SystemExit(main())
|