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,247 @@
|
|
|
1
|
+
"""Matched actual-cost comparisons, quality floors, sample independence and proposal preservation."""
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
10
|
+
import adaptive
|
|
11
|
+
import learning
|
|
12
|
+
import metrics
|
|
13
|
+
import router
|
|
14
|
+
from test_metrics import task
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def measured(case="case-01", arm="baseline", model="large", amount="1.00", **overrides):
|
|
18
|
+
value = task(case + ":r1", schema=3, arm=arm)
|
|
19
|
+
value["metadata"] = {"case_id": case, "stack": "PYTHON", "task_class": "implement", "level": 2,
|
|
20
|
+
"revision": "a" * 40, "repetition": 1, "review": "manual", "capabilities": []}
|
|
21
|
+
value["attempts"][0].update(provider="fixture", model=model, effort="medium", disposition="succeeded")
|
|
22
|
+
value["attempts"][0]["cost"]["amount"] = amount
|
|
23
|
+
value.update(overrides)
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ComparisonsTests(unittest.TestCase):
|
|
28
|
+
def test_fixed_trial_savings_include_failure_and_recovery(self):
|
|
29
|
+
left, right = measured(), measured(arm="kit-fixed", amount="0.20", rework=1)
|
|
30
|
+
right["attempts"].append(copy.deepcopy(right["attempts"][0]))
|
|
31
|
+
result = learning.compare([left, right], "trial")
|
|
32
|
+
self.assertEqual(result["candidate_cost_per_correct"], "0.40")
|
|
33
|
+
self.assertEqual(result["savings_percent"], "60.0")
|
|
34
|
+
|
|
35
|
+
def test_unknown_or_confounded_trials_never_claim_savings(self):
|
|
36
|
+
mutations = [lambda r: r["attempts"][0].update(cost=None),
|
|
37
|
+
lambda r: r["attempts"][0]["cost"].update(source="estimate"),
|
|
38
|
+
lambda r: r["attempts"][0].update(model="other"),
|
|
39
|
+
lambda r: r["metadata"].update(revision=None),
|
|
40
|
+
lambda r: r.update(outcome="incorrect"),
|
|
41
|
+
lambda r: (r.update(outcome="incomplete"), r["metadata"].update(review="pending"))]
|
|
42
|
+
for mutate in mutations:
|
|
43
|
+
right = measured(arm="kit-fixed", amount="0.20")
|
|
44
|
+
mutate(right)
|
|
45
|
+
with self.subTest(mutate=mutate):
|
|
46
|
+
result = learning.compare([measured(), right], "trial")
|
|
47
|
+
self.assertIsNone(result["savings_percent"])
|
|
48
|
+
self.assertTrue(result["unknown_reasons"])
|
|
49
|
+
self.assertIsNone(learning.compare([measured()], "trial")["savings_percent"])
|
|
50
|
+
|
|
51
|
+
def test_zero_baseline_and_no_correct_results(self):
|
|
52
|
+
result = learning.compare([measured(amount="0"), measured(arm="kit-fixed", amount="0")], "trial")
|
|
53
|
+
self.assertIsNone(result["savings_percent"])
|
|
54
|
+
self.assertIn("Baseline cost is zero", result["unknown_reasons"])
|
|
55
|
+
|
|
56
|
+
def test_stacks_and_legacy_unknown(self):
|
|
57
|
+
report = learning.by_stack([measured(), task("old")])
|
|
58
|
+
self.assertEqual(set(report["stacks"]), {"PYTHON", "unknown"})
|
|
59
|
+
group = metrics.summarize([measured(rework=None, escalations=None)])["groups"][0]
|
|
60
|
+
self.assertIsNone(group["rework"])
|
|
61
|
+
self.assertEqual(group["intervention_coverage"]["rework"], {"known": 0, "total": 1})
|
|
62
|
+
with self.assertRaises(ValueError):
|
|
63
|
+
metrics.validate_record(task(rework=None))
|
|
64
|
+
|
|
65
|
+
def test_cross_experiment_identity_is_not_collapsed(self):
|
|
66
|
+
first, second = task(), task(experiment="other", outcome="incorrect")
|
|
67
|
+
result = metrics.analyze([first, second])["providers"][0]
|
|
68
|
+
self.assertEqual((result["tasks"], result["correct_tasks"]), (2, 1))
|
|
69
|
+
|
|
70
|
+
def test_estimates_cannot_be_actual_cost_per_correct(self):
|
|
71
|
+
value = measured()
|
|
72
|
+
value["attempts"][0]["cost"]["source"] = "estimate"
|
|
73
|
+
self.assertIsNone(metrics.summarize([value])["groups"][0]["cost_per_correct_result"])
|
|
74
|
+
self.assertIsNone(metrics.analyze([value])["providers"][0]["cost_per_correct"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LearningTests(unittest.TestCase):
|
|
78
|
+
def evidence(self):
|
|
79
|
+
return [measured(f"case-{n:02d}", arm=arm, model=model, amount=cost)
|
|
80
|
+
for n in range(20) for arm, model, cost in
|
|
81
|
+
(("kit-fixed", "large", "1"), ("kit-adaptive", "small", "0.20"))]
|
|
82
|
+
|
|
83
|
+
def recommend(self, rows):
|
|
84
|
+
return learning.recommend(rows, {"provider": "fixture", "model": "large", "effort": "medium"},
|
|
85
|
+
stack="PYTHON", task_class="implement", level=2, capabilities=[])
|
|
86
|
+
|
|
87
|
+
def test_cheaper_matched_model_with_equal_quality_is_recommended(self):
|
|
88
|
+
result = self.recommend(self.evidence())
|
|
89
|
+
self.assertEqual(result["model"], "small")
|
|
90
|
+
self.assertEqual(result["distinct_cases"], 20)
|
|
91
|
+
# Adding the instruction baseline must not duplicate the incumbent's model trials.
|
|
92
|
+
rows = self.evidence()
|
|
93
|
+
rows += [measured(f"case-{n:02d}", arm="baseline") for n in range(20)]
|
|
94
|
+
self.assertEqual(self.recommend(rows)["model"], "small")
|
|
95
|
+
for row in rows:
|
|
96
|
+
if row["arm"] == "kit-adaptive":
|
|
97
|
+
row["experiment"] = "different-task-pack"
|
|
98
|
+
result = self.recommend(rows)
|
|
99
|
+
self.assertTrue(result is None or result["model"] == "large")
|
|
100
|
+
|
|
101
|
+
def test_single_success_repeats_cannot_make_sufficient_evidence(self):
|
|
102
|
+
with self.assertRaises(ValueError):
|
|
103
|
+
learning.candidates([], stack="PYTHON", task_class="private task text", level=2, capabilities=[])
|
|
104
|
+
with self.assertRaises(ValueError):
|
|
105
|
+
learning.candidates([], stack="PYTHON", task_class="implement", level=99, capabilities=[])
|
|
106
|
+
rows = []
|
|
107
|
+
for n in range(30):
|
|
108
|
+
row = measured(arm="kit-fixed")
|
|
109
|
+
row["metadata"]["repetition"] = n + 1
|
|
110
|
+
row["task_id"] = f"case-01:r{n + 1}"
|
|
111
|
+
rows.append(row)
|
|
112
|
+
result = learning.candidates(rows, stack="PYTHON", task_class="implement", level=2, capabilities=[])[0]
|
|
113
|
+
self.assertEqual(result["distinct_cases"], 1)
|
|
114
|
+
self.assertFalse(result["eligible"])
|
|
115
|
+
|
|
116
|
+
def test_quality_loss_missing_cost_and_unequal_cases_block_downgrade(self):
|
|
117
|
+
for mutation in ("quality", "cost", "cases"):
|
|
118
|
+
rows = self.evidence()
|
|
119
|
+
row = next(r for r in rows if r["arm"] == "kit-adaptive")
|
|
120
|
+
if mutation == "quality":
|
|
121
|
+
row["outcome"] = "incorrect"
|
|
122
|
+
elif mutation == "cost":
|
|
123
|
+
row["attempts"][0]["cost"] = None
|
|
124
|
+
else:
|
|
125
|
+
row["metadata"]["case_id"] = "unmatched"
|
|
126
|
+
result = self.recommend(rows)
|
|
127
|
+
self.assertTrue(result is None or result["model"] == "large")
|
|
128
|
+
|
|
129
|
+
def test_multi_model_recovery_is_not_attributed_as_single_model_success(self):
|
|
130
|
+
row = measured(arm="kit-fixed")
|
|
131
|
+
other = copy.deepcopy(row["attempts"][0])
|
|
132
|
+
other["model"] = "escalation"
|
|
133
|
+
row["attempts"].append(other)
|
|
134
|
+
result = learning.candidates([row], stack="PYTHON", task_class="implement", level=2, capabilities=[])[0]
|
|
135
|
+
self.assertFalse(result["eligible"])
|
|
136
|
+
self.assertEqual(result["mixed_strategy_trials"], 1)
|
|
137
|
+
self.assertEqual(result["cost_per_correct"], "2.00")
|
|
138
|
+
|
|
139
|
+
def test_pending_and_reviewed_incomplete_trials_are_not_dropped(self):
|
|
140
|
+
rows = self.evidence()
|
|
141
|
+
row = next(r for r in rows if r["arm"] == "kit-adaptive")
|
|
142
|
+
row.update(outcome="incomplete")
|
|
143
|
+
row["metadata"]["review"] = "pending"
|
|
144
|
+
groups = learning.candidates(rows, stack="PYTHON", task_class="implement", level=2, capabilities=[])
|
|
145
|
+
small = next(g for g in groups if g["model"] == "small")
|
|
146
|
+
self.assertEqual((small["trials"], small["pending_reviews"]), (20, 1))
|
|
147
|
+
self.assertFalse(small["eligible"])
|
|
148
|
+
row["metadata"]["review"] = "manual"
|
|
149
|
+
small = next(g for g in learning.candidates(rows, stack="PYTHON", task_class="implement", level=2, capabilities=[]) if g["model"] == "small")
|
|
150
|
+
self.assertEqual((small["trials"], small["correct"]), (20, 19))
|
|
151
|
+
unknown = measured("unknown-model", arm="kit-adaptive", outcome="incorrect")
|
|
152
|
+
unknown["attempts"] = [{"disposition": "inconclusive"}]
|
|
153
|
+
groups = learning.candidates([*self.evidence(), unknown], stack="PYTHON", task_class="implement", level=2, capabilities=[])
|
|
154
|
+
self.assertTrue(all(g["unattributed_trials"] == 1 and not g["eligible"] for g in groups))
|
|
155
|
+
|
|
156
|
+
def test_immutable_history_and_explicit_rollback(self):
|
|
157
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
158
|
+
root = Path(folder).resolve()
|
|
159
|
+
path = root / "ai-kit/router/selection.json"
|
|
160
|
+
path.parent.mkdir(parents=True)
|
|
161
|
+
selection = {"schema": 1, "default_provider": None, "roles": {}, "local_models": {}}
|
|
162
|
+
path.write_text(json.dumps(selection), encoding="utf-8")
|
|
163
|
+
before = path.read_bytes()
|
|
164
|
+
preview = learning.save_proposal(root, {"candidates": []})
|
|
165
|
+
self.assertFalse(Path(preview["path"]).exists())
|
|
166
|
+
saved = learning.save_proposal(root, {"candidates": []}, apply=True)
|
|
167
|
+
self.assertEqual(path.read_bytes(), before)
|
|
168
|
+
self.assertEqual(saved, learning.save_proposal(root, {"candidates": []}, apply=True))
|
|
169
|
+
selection["default_provider"] = "fixture"
|
|
170
|
+
path.write_text(json.dumps(selection), encoding="utf-8")
|
|
171
|
+
self.assertTrue(learning.restore_selection(root, saved["proposal_id"])["changed"])
|
|
172
|
+
self.assertNotEqual(path.read_bytes(), before)
|
|
173
|
+
learning.restore_selection(root, saved["proposal_id"], apply=True)
|
|
174
|
+
self.assertEqual(path.read_bytes(), before)
|
|
175
|
+
history = Path(saved["path"])
|
|
176
|
+
history.write_text("{}", encoding="utf-8")
|
|
177
|
+
with self.assertRaisesRegex(ValueError, "modified"):
|
|
178
|
+
learning.restore_selection(root, saved["proposal_id"])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AdaptiveTests(unittest.TestCase):
|
|
182
|
+
def setUp(self):
|
|
183
|
+
self.providers = {"fixture": {"roles": {"cheap": {"model": "small"}, "work": {"model": "large"}},
|
|
184
|
+
"supported_efforts": {"cheap": ["low"], "work": ["low", "medium", "high"]}}}
|
|
185
|
+
self.selection = {"schema": 1, "default_provider": "fixture", "roles": {}, "local_models": {}}
|
|
186
|
+
|
|
187
|
+
def route(self, **kwargs):
|
|
188
|
+
return adaptive.recommend_route("implement a feature", self.selection, self.providers, **kwargs)
|
|
189
|
+
|
|
190
|
+
def test_modes_and_task_pin_precedence(self):
|
|
191
|
+
with self.assertRaises(ValueError):
|
|
192
|
+
self.route(mode="fixed")
|
|
193
|
+
config = {"mode": "fixed", "project_pin": {"provider": "fixture", "model": "large", "effort": "medium"},
|
|
194
|
+
"task_pins": {"case": {"provider": "fixture", "model": "small", "effort": None}}}
|
|
195
|
+
result = self.route(config=config, task_id="case")
|
|
196
|
+
self.assertEqual((result["model"], result["effort"]), ("small", None))
|
|
197
|
+
self.assertFalse(result["execution_confirmed"])
|
|
198
|
+
result = self.route(config=config, task_id="case", model="large", effort="high")
|
|
199
|
+
self.assertEqual((result["model"], result["effort"]), ("large", "high"))
|
|
200
|
+
result = self.route(config=config, task_id="case", effort="low")
|
|
201
|
+
self.assertEqual((result["model"], result["effort"]), ("small", "low"))
|
|
202
|
+
with self.assertRaises(ValueError):
|
|
203
|
+
self.route(mode="fixed", effort="low")
|
|
204
|
+
|
|
205
|
+
def test_profiles_and_existing_effort_are_preserved(self):
|
|
206
|
+
self.assertEqual(self.route(profile="economy")["effort"], "low")
|
|
207
|
+
quality = self.route(profile="quality")
|
|
208
|
+
self.assertEqual(quality["effort"], "high")
|
|
209
|
+
self.assertTrue(quality["independent_review"])
|
|
210
|
+
self.selection["roles"]["work"] = {"effort": "medium"}
|
|
211
|
+
self.assertEqual(self.route(profile="quality")["effort"], "medium")
|
|
212
|
+
|
|
213
|
+
def test_adaptive_only_recommends_from_comparable_declared_models(self):
|
|
214
|
+
rows = LearningTests().evidence()
|
|
215
|
+
result = self.route(mode="adaptive", records=rows, stack="PYTHON")
|
|
216
|
+
# Fixture cheap model has only low; measured medium is not a supported control.
|
|
217
|
+
self.assertEqual(result["model"], "large")
|
|
218
|
+
self.providers["fixture"]["supported_efforts"]["cheap"].append("medium")
|
|
219
|
+
result = self.route(mode="adaptive", records=rows, stack="PYTHON")
|
|
220
|
+
self.assertEqual(result["model"], "small")
|
|
221
|
+
self.assertEqual(result["recommendation_source"], "matched measurements")
|
|
222
|
+
self.assertFalse(result["execution_confirmed"])
|
|
223
|
+
self.providers["other"] = self.providers["fixture"]
|
|
224
|
+
for row in rows:
|
|
225
|
+
if row["arm"] == "kit-adaptive":
|
|
226
|
+
row["attempts"][0]["provider"] = "other"
|
|
227
|
+
self.assertEqual(self.route(mode="adaptive", records=rows, stack="PYTHON")["provider"], "fixture")
|
|
228
|
+
self.selection["roles"]["escalation"] = "other"
|
|
229
|
+
self.assertEqual(self.route(mode="adaptive", records=rows, stack="PYTHON")["provider"], "other")
|
|
230
|
+
|
|
231
|
+
def test_adaptive_missing_data_keeps_current_recommendation(self):
|
|
232
|
+
result = self.route(mode="adaptive")
|
|
233
|
+
self.assertEqual(result["model"], "large")
|
|
234
|
+
self.assertIn("Insufficient", result["adjustments"][-1])
|
|
235
|
+
|
|
236
|
+
def test_capabilities_must_be_declared_even_with_measured_success(self):
|
|
237
|
+
rows = LearningTests().evidence()
|
|
238
|
+
for row in rows:
|
|
239
|
+
row["metadata"]["capabilities"] = ["tool-use"]
|
|
240
|
+
self.providers["fixture"]["supported_efforts"]["cheap"].append("medium")
|
|
241
|
+
result = self.route(mode="adaptive", records=rows, stack="PYTHON", capabilities=["tool-use"])
|
|
242
|
+
self.assertEqual(result["model"], "large")
|
|
243
|
+
self.assertFalse(result["capabilities_verified"])
|
|
244
|
+
self.providers["fixture"]["roles"]["cheap"]["capabilities"] = ["tool-use"]
|
|
245
|
+
result = self.route(mode="adaptive", records=rows, stack="PYTHON", capabilities=["tool-use"])
|
|
246
|
+
self.assertEqual(result["model"], "small")
|
|
247
|
+
self.assertTrue(result["capabilities_verified"])
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Local dialogue normalization, explicit review, immutable exports and privacy boundaries."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import copy
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
15
|
+
import memory
|
|
16
|
+
import memory_import as importer
|
|
17
|
+
import memory_store as store
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dialogue(body, role="user"):
|
|
21
|
+
return importer.normalize([{"role": role, "content": body}], source_format="messages", raw=body.encode())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def branch_fixture():
|
|
25
|
+
def node(parent, body, role="user"):
|
|
26
|
+
return {"parent": parent, "message": {"author": {"role": role},
|
|
27
|
+
"content": {"content_type": "text", "parts": [body]}, "create_time": 123}}
|
|
28
|
+
return {"id": "conversation-1", "current_node": "b", "mapping": {
|
|
29
|
+
"root": {"parent": None, "message": None}, "a": node("root", "Fact stack = Laravel"),
|
|
30
|
+
"b": node("a", "Decision ui = Tailwind", "assistant"),
|
|
31
|
+
"other": node("a", "Unselected private branch")}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ImportTests(unittest.TestCase):
|
|
35
|
+
def setUp(self):
|
|
36
|
+
self.temp = tempfile.TemporaryDirectory(prefix="aikit-memory-import-")
|
|
37
|
+
self.addCleanup(self.temp.cleanup)
|
|
38
|
+
self.base = Path(self.temp.name).resolve()
|
|
39
|
+
|
|
40
|
+
def save(self, value, filename="source.json"):
|
|
41
|
+
path = self.base / filename
|
|
42
|
+
path.write_bytes(store.encode(value))
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
def test_chatgpt_selected_branch_is_in_parent_order_without_sibling_text(self):
|
|
46
|
+
value = importer.load(self.save(branch_fixture()))
|
|
47
|
+
self.assertEqual(value["format"], "chatgpt")
|
|
48
|
+
self.assertEqual([m["original_id"] for m in value["messages"]], ["a", "b"])
|
|
49
|
+
self.assertEqual(value["messages"][1]["role"], "assistant")
|
|
50
|
+
self.assertNotIn("Unselected private branch", json.dumps(value))
|
|
51
|
+
self.assertEqual(value["selection"], {"conversation_id": "conversation-1", "branch": "b"})
|
|
52
|
+
|
|
53
|
+
def test_collection_requires_explicit_conversation_and_branch_can_override(self):
|
|
54
|
+
other = copy.deepcopy(branch_fixture())
|
|
55
|
+
other["id"] = "conversation-2"
|
|
56
|
+
path = self.save([branch_fixture(), other])
|
|
57
|
+
with self.assertRaises(ValueError):
|
|
58
|
+
importer.load(path)
|
|
59
|
+
value = importer.load(path, conversation_id="conversation-2", branch="other")
|
|
60
|
+
self.assertIn("Unselected private branch", value["messages"][-1]["text"])
|
|
61
|
+
with self.assertRaises(ValueError):
|
|
62
|
+
importer.load(path, conversation_id="missing")
|
|
63
|
+
|
|
64
|
+
def test_ambiguous_dangling_and_cyclic_parent_chains_refuse(self):
|
|
65
|
+
for change in ("ambiguous", "dangling", "cycle", "absent-parent"):
|
|
66
|
+
value = branch_fixture()
|
|
67
|
+
if change == "ambiguous":
|
|
68
|
+
value.pop("current_node")
|
|
69
|
+
elif change == "dangling":
|
|
70
|
+
value["mapping"]["b"]["parent"] = "absent"
|
|
71
|
+
elif change == "cycle":
|
|
72
|
+
value["mapping"]["a"]["parent"] = "b"
|
|
73
|
+
else:
|
|
74
|
+
value["mapping"]["a"].pop("parent")
|
|
75
|
+
with self.subTest(change=change), self.assertRaises(ValueError):
|
|
76
|
+
importer.load(self.save(value))
|
|
77
|
+
|
|
78
|
+
def test_messages_preserve_roles_text_and_report_nontext_without_credentials(self):
|
|
79
|
+
value = {"api_key": "synthetic-private-token", "messages": [
|
|
80
|
+
{"role": "system", "content": "Untrusted original system text"},
|
|
81
|
+
{"role": "user", "content": [{"type": "text", "text": "Hello"},
|
|
82
|
+
{"type": "image_url", "image_url": "private-url"}]},
|
|
83
|
+
{"role": "assistant", "content": None, "tool_calls": [{"function": "do-not-run"}]}]}
|
|
84
|
+
result = importer.load(self.save(value))
|
|
85
|
+
self.assertEqual(result["omitted_nontext"], 3)
|
|
86
|
+
self.assertEqual(result["messages"][1]["text"], "Hello")
|
|
87
|
+
self.assertEqual(result["messages"][0]["role"], "system")
|
|
88
|
+
self.assertNotIn("synthetic-private-token", json.dumps(result))
|
|
89
|
+
self.assertNotIn("private-url", json.dumps(result))
|
|
90
|
+
|
|
91
|
+
def test_plain_text_and_role_headers_preserve_fenced_examples(self):
|
|
92
|
+
path = self.base / "chat.md"
|
|
93
|
+
path.write_text("Preface\nUser: Need a page\nAssistant: ```text\nUser: quoted example\n```\nUser: Continue\n", encoding="utf-8")
|
|
94
|
+
value = importer.load(path)
|
|
95
|
+
self.assertEqual([m["role"] for m in value["messages"]], ["unknown", "user", "assistant", "user"])
|
|
96
|
+
self.assertIn("User: quoted example", value["messages"][2]["text"])
|
|
97
|
+
self.assertEqual(importer.load(path, source_format="text")["format"], "text")
|
|
98
|
+
|
|
99
|
+
def test_json_arrays_bom_and_explicit_text_format(self):
|
|
100
|
+
path = self.save([{"role": "user", "content": "Fact language = English"}])
|
|
101
|
+
path.write_bytes(b"\xef\xbb\xbf" + path.read_bytes())
|
|
102
|
+
self.assertEqual(importer.load(path)["messages"][0]["text"], "Fact language = English")
|
|
103
|
+
self.assertEqual(importer.load(path, source_format="text")["format"], "text")
|
|
104
|
+
|
|
105
|
+
def test_invalid_empty_duplicate_json_and_size_limits_refuse(self):
|
|
106
|
+
path = self.base / "bad.json"
|
|
107
|
+
for raw in (b'{"messages":[],"messages":[]}', b'{"messages":NaN}', b'[]', b'{bad', b'"not messages"'):
|
|
108
|
+
path.write_bytes(raw)
|
|
109
|
+
with self.subTest(raw=raw), self.assertRaises(ValueError):
|
|
110
|
+
importer.load(path)
|
|
111
|
+
path.write_bytes(b"123456789")
|
|
112
|
+
with self.assertRaises(ValueError):
|
|
113
|
+
store.read_bytes(path, limit=8)
|
|
114
|
+
with self.assertRaises(ValueError):
|
|
115
|
+
importer.normalize([{"role": "user", "content": "x"}] * 4001, source_format="messages", raw=b"")
|
|
116
|
+
with self.assertRaises(ValueError):
|
|
117
|
+
importer.normalize([{"role": "user", "content": {}}], source_format="messages", raw=b"")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MergeTests(unittest.TestCase):
|
|
121
|
+
def sources(self):
|
|
122
|
+
return {"chatgpt": dialogue("Decision ui.framework = Tailwind\nFact stack = Laravel", "assistant"),
|
|
123
|
+
"openrouter": dialogue("Decision ui.framework = Bootstrap\nFact stack = Laravel")}
|
|
124
|
+
|
|
125
|
+
def test_conflicts_are_explicit_and_duplicates_keep_both_provenances(self):
|
|
126
|
+
merged = memory.merge(self.sources())
|
|
127
|
+
self.assertEqual(merged["accepted"], [])
|
|
128
|
+
self.assertEqual(merged["conflicts"][0]["key"], "ui.framework")
|
|
129
|
+
self.assertEqual({v["value"] for v in merged["conflicts"][0]["variants"]}, {"Tailwind", "Bootstrap"})
|
|
130
|
+
self.assertEqual(merged["pending"][0]["refs"], ["chatgpt:m0001", "openrouter:m0001"])
|
|
131
|
+
self.assertEqual(merged["duplicates"], 1)
|
|
132
|
+
|
|
133
|
+
def test_no_latest_message_or_assistant_claim_is_implicitly_approved(self):
|
|
134
|
+
sources = {"a": dialogue("Choose Tailwind.", "assistant"), "b": dialogue("Choose Bootstrap.", "assistant")}
|
|
135
|
+
merged = memory.merge(sources)
|
|
136
|
+
self.assertEqual(len(merged["pending"]), 2)
|
|
137
|
+
self.assertFalse(merged["accepted"])
|
|
138
|
+
self.assertFalse(merged["conflicts"]) # Free prose is explicitly pending semantic review.
|
|
139
|
+
|
|
140
|
+
def test_review_selects_a_variant_and_accepts_a_shared_fact(self):
|
|
141
|
+
sources = self.sources()
|
|
142
|
+
review = memory.review_template(sources)
|
|
143
|
+
for claim in review["claims"]:
|
|
144
|
+
if claim["key"] == "stack":
|
|
145
|
+
claim["status"] = "accepted"
|
|
146
|
+
chosen = next(c for c in review["claims"] if c["value"] == "Tailwind")
|
|
147
|
+
review["resolutions"] = [{"key": "ui.framework", "choice": chosen["id"], "reason": "Owner selected Tailwind."}]
|
|
148
|
+
merged = memory.merge(sources, review)
|
|
149
|
+
self.assertFalse(merged["pending"] or merged["conflicts"])
|
|
150
|
+
self.assertEqual({i["key"]: i["value"] for i in merged["accepted"]}, {"stack": "Laravel", "ui.framework": "Tailwind"})
|
|
151
|
+
|
|
152
|
+
def test_defer_remains_unresolved_and_drop_records_a_reason(self):
|
|
153
|
+
sources = self.sources()
|
|
154
|
+
review = memory.review_template(sources)
|
|
155
|
+
review["resolutions"] = [{"key": "ui.framework", "choice": "defer", "reason": "Need owner decision."},
|
|
156
|
+
{"key": "stack", "choice": "drop", "reason": "Out of scope."}]
|
|
157
|
+
merged = memory.merge(sources, review)
|
|
158
|
+
self.assertEqual(merged["conflicts"][0]["status"], "deferred")
|
|
159
|
+
self.assertEqual(merged["dismissed"][0]["key"], "stack")
|
|
160
|
+
|
|
161
|
+
def test_free_prose_can_be_reviewed_as_two_alternatives_under_one_key(self):
|
|
162
|
+
sources = {"a": dialogue("Use Tailwind."), "b": dialogue("Use Bootstrap.")}
|
|
163
|
+
review = memory.review_template(sources)
|
|
164
|
+
for claim in review["claims"]:
|
|
165
|
+
claim["key"] = "frontend.css"
|
|
166
|
+
claim["kind"] = "decision"
|
|
167
|
+
self.assertEqual(len(memory.merge(sources, review)["conflicts"]), 1)
|
|
168
|
+
|
|
169
|
+
def test_stale_missing_forged_or_ambiguous_review_refuses(self):
|
|
170
|
+
sources = self.sources()
|
|
171
|
+
original = memory.review_template(sources)
|
|
172
|
+
for fault in ("stale", "missing", "refs", "provenance", "duplicate", "resolution", "reason"):
|
|
173
|
+
review = copy.deepcopy(original)
|
|
174
|
+
if fault == "stale":
|
|
175
|
+
review["inputs"]["chatgpt"] = "0" * 64
|
|
176
|
+
elif fault == "missing":
|
|
177
|
+
review["claims"].pop()
|
|
178
|
+
elif fault == "refs":
|
|
179
|
+
review["claims"][0]["refs"] = ["absent:m0001"]
|
|
180
|
+
elif fault == "provenance":
|
|
181
|
+
review["claims"][0]["refs"] = ["openrouter:m0001"]
|
|
182
|
+
elif fault == "duplicate":
|
|
183
|
+
review["claims"].append(review["claims"][0])
|
|
184
|
+
else:
|
|
185
|
+
review["resolutions"] = [{"key": "ui.framework", "choice": "absent" if fault == "resolution" else "defer",
|
|
186
|
+
"reason": "Review" if fault == "resolution" else ""}]
|
|
187
|
+
with self.subTest(fault=fault), self.assertRaises(ValueError):
|
|
188
|
+
memory.merge(sources, review)
|
|
189
|
+
|
|
190
|
+
def test_code_and_original_system_text_stay_pending_notes(self):
|
|
191
|
+
sources = {"a": dialogue("```\nDecision ui = Tailwind\n```"),
|
|
192
|
+
"b": dialogue("Decision ui = Bootstrap", "system")}
|
|
193
|
+
result = memory.merge(sources)
|
|
194
|
+
self.assertTrue(all(i["key"].startswith("note.") for i in result["pending"]))
|
|
195
|
+
self.assertFalse(result["accepted"] or result["conflicts"])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class WorkspaceTests(unittest.TestCase):
|
|
199
|
+
def setUp(self):
|
|
200
|
+
self.temp = tempfile.TemporaryDirectory(prefix="aikit-memory-store-")
|
|
201
|
+
self.addCleanup(self.temp.cleanup)
|
|
202
|
+
self.base = Path(self.temp.name).resolve()
|
|
203
|
+
self.workspace = self.base / "private workspace"
|
|
204
|
+
|
|
205
|
+
def cli(self, *args):
|
|
206
|
+
output, error = io.StringIO(), io.StringIO()
|
|
207
|
+
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error):
|
|
208
|
+
code = memory.main(list(map(str, args)))
|
|
209
|
+
return code, json.loads(output.getvalue()) if output.getvalue() else None, error.getvalue()
|
|
210
|
+
|
|
211
|
+
def imports(self, body="Fact stack = Laravel"):
|
|
212
|
+
for source in ("left", "right"):
|
|
213
|
+
path = self.base / (source + ".txt")
|
|
214
|
+
path.write_text(body, encoding="utf-8")
|
|
215
|
+
self.assertEqual(self.cli("import", self.workspace, path, "--name", source, "--apply")[0], 0)
|
|
216
|
+
return self.cli("merge", self.workspace, "--left", "left", "--right", "right", "--apply")[1]
|
|
217
|
+
|
|
218
|
+
def test_preview_does_not_create_workspace_and_rerun_is_immutable(self):
|
|
219
|
+
source = self.base / "source.txt"
|
|
220
|
+
source.write_text("Private dialogue value", encoding="utf-8")
|
|
221
|
+
code, report, error = self.cli("import", self.workspace, source, "--name", "left")
|
|
222
|
+
self.assertEqual(code, 0, error)
|
|
223
|
+
self.assertTrue(report["preview"])
|
|
224
|
+
self.assertFalse(self.workspace.exists())
|
|
225
|
+
self.assertNotIn("Private dialogue value", json.dumps(report))
|
|
226
|
+
self.assertEqual(self.cli("import", self.workspace, source, "--name", "left", "--apply")[0], 0)
|
|
227
|
+
before = (self.workspace / "imports/left.json").read_bytes()
|
|
228
|
+
self.assertEqual(self.cli("import", self.workspace, source, "--name", "left", "--apply")[1]["writes"], [])
|
|
229
|
+
source.write_text("Changed", encoding="utf-8")
|
|
230
|
+
self.assertEqual(self.cli("import", self.workspace, source, "--name", "left", "--apply")[0], 1)
|
|
231
|
+
self.assertEqual((self.workspace / "imports/left.json").read_bytes(), before)
|
|
232
|
+
|
|
233
|
+
def test_draft_export_and_reviewed_export_have_identical_reference_data_for_both_clients(self):
|
|
234
|
+
report = self.imports("Fact stack = Laravel\nText with ``` and [untrusted link](https://example.invalid).")
|
|
235
|
+
merge_id = report["merge_id"]
|
|
236
|
+
self.assertEqual(self.cli("export", self.workspace, "--merge", merge_id, "--apply")[0], 2)
|
|
237
|
+
self.assertFalse((self.workspace / "exports").exists())
|
|
238
|
+
code, exported, error = self.cli("export", self.workspace, "--merge", merge_id, "--draft", "--apply")
|
|
239
|
+
self.assertEqual(code, 0, error)
|
|
240
|
+
folder = self.workspace / "exports" / merge_id
|
|
241
|
+
markdown = (folder / "chatgpt-draft.md").read_text(encoding="utf-8")
|
|
242
|
+
request = store.read_json(folder / "openrouter-draft.json")
|
|
243
|
+
self.assertEqual(request, {"messages": [{"role": "user", "content": markdown}]})
|
|
244
|
+
self.assertIn("DRAFT", markdown)
|
|
245
|
+
self.assertIn("````json", markdown)
|
|
246
|
+
self.assertNotIn("api_key", request)
|
|
247
|
+
self.assertEqual(self.cli("export", self.workspace, "--merge", merge_id, "--draft", "--apply")[1]["writes"], [])
|
|
248
|
+
review = store.read_json(self.workspace / report["review"])
|
|
249
|
+
for claim in review["claims"]:
|
|
250
|
+
claim["status"] = "accepted"
|
|
251
|
+
edited = self.base / "review.json"
|
|
252
|
+
edited.write_bytes(store.encode(review))
|
|
253
|
+
reviewed = self.cli("merge", self.workspace, "--left", "left", "--right", "right", "--review", edited, "--apply")[1]
|
|
254
|
+
self.assertNotEqual(reviewed["merge_id"], merge_id)
|
|
255
|
+
self.assertEqual(self.cli("export", self.workspace, "--merge", reviewed["merge_id"], "--apply")[0], 0)
|
|
256
|
+
self.assertTrue((folder / "chatgpt-draft.md").is_file())
|
|
257
|
+
|
|
258
|
+
def test_merge_and_export_previews_have_no_writes(self):
|
|
259
|
+
report = self.imports()
|
|
260
|
+
before = {p.relative_to(self.workspace): p.read_bytes() for p in self.workspace.rglob("*") if p.is_file()}
|
|
261
|
+
self.cli("merge", self.workspace, "--left", "left", "--right", "right")
|
|
262
|
+
self.cli("export", self.workspace, "--merge", report["merge_id"], "--draft")
|
|
263
|
+
after = {p.relative_to(self.workspace): p.read_bytes() for p in self.workspace.rglob("*") if p.is_file()}
|
|
264
|
+
self.assertEqual(before, after)
|
|
265
|
+
|
|
266
|
+
def test_changed_import_and_tampered_merge_are_refused_before_export(self):
|
|
267
|
+
report = self.imports()
|
|
268
|
+
path = self.workspace / "imports/left.json"
|
|
269
|
+
value = store.read_json(path)
|
|
270
|
+
original = path.read_bytes()
|
|
271
|
+
value["messages"][0]["text"] = "Changed source"
|
|
272
|
+
path.write_bytes(store.encode(value))
|
|
273
|
+
self.assertEqual(self.cli("export", self.workspace, "--merge", report["merge_id"], "--draft", "--apply")[0], 1)
|
|
274
|
+
path.write_bytes(original)
|
|
275
|
+
path = self.workspace / "merges" / (report["merge_id"] + ".json")
|
|
276
|
+
value = store.read_json(path)
|
|
277
|
+
value["accepted"] = [{"value": "Forged"}]
|
|
278
|
+
path.write_bytes(store.encode(value))
|
|
279
|
+
self.assertEqual(self.cli("export", self.workspace, "--merge", report["merge_id"], "--draft", "--apply")[0], 1)
|
|
280
|
+
self.assertFalse((self.workspace / "exports").exists())
|
|
281
|
+
|
|
282
|
+
def test_export_budget_refuses_without_truncation_or_partial_files(self):
|
|
283
|
+
report = self.imports()
|
|
284
|
+
code, _, _ = self.cli("export", self.workspace, "--merge", report["merge_id"], "--draft", "--max-bytes", 1, "--apply")
|
|
285
|
+
self.assertEqual(code, 1)
|
|
286
|
+
self.assertFalse((self.workspace / "exports").exists())
|
|
287
|
+
|
|
288
|
+
def test_invalid_names_unowned_workspaces_and_blocked_parents_preserve_files(self):
|
|
289
|
+
source = self.base / "source.txt"
|
|
290
|
+
source.write_text("Context", encoding="utf-8")
|
|
291
|
+
for source_name in ("../escape", "CON", "con", "name:stream", "MixedCase", "nul"):
|
|
292
|
+
self.assertEqual(self.cli("import", self.workspace, source, "--name", source_name, "--apply")[0], 1)
|
|
293
|
+
self.workspace.mkdir()
|
|
294
|
+
existing = self.workspace / "important.txt"
|
|
295
|
+
existing.write_bytes(b"Keep")
|
|
296
|
+
self.assertEqual(self.cli("import", self.workspace, source, "--name", "left", "--apply")[0], 1)
|
|
297
|
+
self.assertEqual(existing.read_bytes(), b"Keep")
|
|
298
|
+
blocked = self.base / "file-parent"
|
|
299
|
+
blocked.write_bytes(b"Keep parent")
|
|
300
|
+
self.assertEqual(self.cli("import", blocked / "child", source, "--name", "left", "--apply")[0], 1)
|
|
301
|
+
|
|
302
|
+
def test_link_and_junction_probes_refuse_before_writing(self):
|
|
303
|
+
with patch.object(store.install, "is_link", side_effect=lambda p: p == self.base):
|
|
304
|
+
with self.assertRaises(ValueError):
|
|
305
|
+
store.write(self.workspace, {"imports/a.json": b"{}"}, apply=True)
|
|
306
|
+
self.assertFalse(self.workspace.exists())
|
|
307
|
+
|
|
308
|
+
def test_real_symlink_is_refused_where_supported(self):
|
|
309
|
+
target = self.base / "real"
|
|
310
|
+
target.mkdir()
|
|
311
|
+
link = self.base / "link"
|
|
312
|
+
try:
|
|
313
|
+
link.symlink_to(target, target_is_directory=True)
|
|
314
|
+
except OSError as exc:
|
|
315
|
+
self.skipTest("Symlink unavailable: " + str(exc))
|
|
316
|
+
with self.assertRaises(ValueError):
|
|
317
|
+
store.write(link / "workspace", {"imports/a.json": b"{}"}, apply=True)
|
|
318
|
+
self.assertFalse((target / "workspace").exists())
|
|
319
|
+
|
|
320
|
+
def test_hardlinked_artifact_is_preserved_even_with_identical_bytes(self):
|
|
321
|
+
store.write(self.workspace, {"imports/a.json": b"{}"}, apply=True)
|
|
322
|
+
source = self.workspace / "imports/a.json"
|
|
323
|
+
try:
|
|
324
|
+
os.link(source, self.base / "alias")
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
self.skipTest("Hardlink unavailable: " + str(exc))
|
|
327
|
+
with self.assertRaises(ValueError):
|
|
328
|
+
store.write(self.workspace, {"imports/a.json": b"{}"}, apply=True)
|
|
329
|
+
self.assertEqual(source.read_bytes(), b"{}")
|
|
330
|
+
|
|
331
|
+
def test_racing_file_creation_cannot_be_overwritten(self):
|
|
332
|
+
original_open = os.open
|
|
333
|
+
def race(path, flags, *args, **kwargs):
|
|
334
|
+
if Path(path).name == "left.json":
|
|
335
|
+
Path(path).write_bytes(b"External concurrent bytes")
|
|
336
|
+
return original_open(path, flags, *args, **kwargs)
|
|
337
|
+
with patch.object(store.os, "open", side_effect=race), self.assertRaises(FileExistsError):
|
|
338
|
+
store.write(self.workspace, {"imports/left.json": b"{}"}, apply=True)
|
|
339
|
+
self.assertEqual((self.workspace / "imports/left.json").read_bytes(), b"External concurrent bytes")
|
|
340
|
+
|
|
341
|
+
@unittest.skipIf(os.name == "nt", "POSIX permissions and FIFO")
|
|
342
|
+
def test_private_permissions_and_fifo_refusal(self):
|
|
343
|
+
store.write(self.workspace, {"imports/a.json": b"{}"}, apply=True)
|
|
344
|
+
self.assertEqual(self.workspace.stat().st_mode & 0o777, 0o700)
|
|
345
|
+
self.assertEqual((self.workspace / "imports/a.json").stat().st_mode & 0o777, 0o600)
|
|
346
|
+
fifo = self.base / "pipe"
|
|
347
|
+
os.mkfifo(fifo)
|
|
348
|
+
with self.assertRaises(ValueError):
|
|
349
|
+
store.read_bytes(fifo)
|
|
350
|
+
|
|
351
|
+
def test_install_upgrade_preserves_private_memory_in_both_sharing_modes(self):
|
|
352
|
+
old_source = self.base / "prior kit"
|
|
353
|
+
shutil.copytree(memory.install.ROOT, old_source, ignore=shutil.ignore_patterns(".git", "__pycache__", "*.egg-info"))
|
|
354
|
+
for mode in ("private", "team"):
|
|
355
|
+
policy = old_source / "templates" / ("gitignore." + mode)
|
|
356
|
+
policy.write_bytes(policy.read_bytes().replace(b"/ai-kit/.memory/\r\n", b"").replace(b"/ai-kit/.memory/\n", b""))
|
|
357
|
+
project = self.base / ("project-" + mode)
|
|
358
|
+
plan = memory.install.build_plan(project, root=old_source, mode=mode)
|
|
359
|
+
self.assertTrue(memory.install.apply_plan(plan))
|
|
360
|
+
private = project / "ai-kit/.memory/session"
|
|
361
|
+
store.write(private, {"imports/a.json": b'{"private":"preserve exactly"}'}, apply=True)
|
|
362
|
+
saved = (private / "imports/a.json").read_bytes()
|
|
363
|
+
plan = memory.install.build_plan(project, mode=mode)
|
|
364
|
+
self.assertFalse(plan["conflicts"])
|
|
365
|
+
self.assertTrue(memory.install.apply_plan(plan))
|
|
366
|
+
self.assertEqual((private / "imports/a.json").read_bytes(), saved)
|
|
367
|
+
self.assertIn("/ai-kit/.memory/", (project / ".gitignore").read_text())
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
unittest.main()
|