dokimi 0.1.0__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.
- dokimi/__init__.py +41 -0
- dokimi/__main__.py +7 -0
- dokimi/_version.py +32 -0
- dokimi/agent_profiles/__init__.py +13 -0
- dokimi/agent_profiles/loader.py +101 -0
- dokimi/artifacts.py +201 -0
- dokimi/caching.py +367 -0
- dokimi/cli.py +123 -0
- dokimi/commands/__init__.py +4 -0
- dokimi/commands/answers.py +152 -0
- dokimi/commands/arms.py +719 -0
- dokimi/commands/baseline.py +276 -0
- dokimi/commands/benchmark.py +425 -0
- dokimi/commands/catalog.py +190 -0
- dokimi/commands/context.py +98 -0
- dokimi/commands/dashboard.py +65 -0
- dokimi/commands/dataset.py +39 -0
- dokimi/commands/evaluate.py +553 -0
- dokimi/commands/explore.py +177 -0
- dokimi/commands/ingest.py +51 -0
- dokimi/commands/judge_reliability.py +452 -0
- dokimi/commands/library.py +116 -0
- dokimi/commands/novelty.py +977 -0
- dokimi/commands/orchestrate.py +130 -0
- dokimi/commands/personas.py +128 -0
- dokimi/commands/questions.py +398 -0
- dokimi/commands/repo.py +332 -0
- dokimi/commands/repo_run.py +340 -0
- dokimi/commands/report.py +223 -0
- dokimi/commands/run.py +99 -0
- dokimi/commands/subjects.py +437 -0
- dokimi/commands/tasks.py +205 -0
- dokimi/commands/tools.py +174 -0
- dokimi/config/products.yaml +73 -0
- dokimi/config.py +89 -0
- dokimi/config_check.py +75 -0
- dokimi/console.py +20 -0
- dokimi/context/__init__.py +10 -0
- dokimi/context/trace.py +237 -0
- dokimi/coverage.py +333 -0
- dokimi/dashboard/__init__.py +4 -0
- dokimi/dashboard/aggregator.py +223 -0
- dokimi/dashboard/markdown_renderer.py +132 -0
- dokimi/datasets_export.py +196 -0
- dokimi/defaults.py +95 -0
- dokimi/eval/__init__.py +10 -0
- dokimi/eval/agent_runner.py +339 -0
- dokimi/eval/answerer.py +482 -0
- dokimi/eval/arm_runner.py +633 -0
- dokimi/eval/arm_summary.py +262 -0
- dokimi/eval/binary_stats.py +235 -0
- dokimi/eval/cells.py +238 -0
- dokimi/eval/checklist.py +340 -0
- dokimi/eval/cross_judge.py +370 -0
- dokimi/eval/degrade.py +675 -0
- dokimi/eval/diagnoser.py +204 -0
- dokimi/eval/grounding.py +89 -0
- dokimi/eval/interactions.py +182 -0
- dokimi/eval/judge.py +713 -0
- dokimi/eval/judge_context.py +312 -0
- dokimi/eval/judge_sequential.py +176 -0
- dokimi/eval/judge_validity.py +482 -0
- dokimi/eval/judge_wording.py +243 -0
- dokimi/eval/matrix_rollup.py +132 -0
- dokimi/eval/pairwise.py +325 -0
- dokimi/eval/panel.py +588 -0
- dokimi/eval/plugin_delta.py +245 -0
- dokimi/eval/question_text.py +84 -0
- dokimi/eval/ragas_eval.py +370 -0
- dokimi/eval/reranker.py +166 -0
- dokimi/eval/stats.py +584 -0
- dokimi/eval/typed_scorer.py +298 -0
- dokimi/eval/validity_scoring.py +176 -0
- dokimi/explore/__init__.py +31 -0
- dokimi/explore/explorer.py +215 -0
- dokimi/explore/runner.py +161 -0
- dokimi/explore/tasks.py +206 -0
- dokimi/external/__init__.py +18 -0
- dokimi/external/catalog.py +300 -0
- dokimi/external/trigger_set.py +259 -0
- dokimi/gate/__init__.py +4 -0
- dokimi/gate/critical_bands.py +81 -0
- dokimi/gate/novelty_gate.py +599 -0
- dokimi/gate/regression.py +106 -0
- dokimi/gate/score_gate.py +52 -0
- dokimi/gate/trust_gate.py +295 -0
- dokimi/harnesses/__init__.py +25 -0
- dokimi/harnesses/agent_sandbox.py +539 -0
- dokimi/harnesses/benchflow.py +869 -0
- dokimi/harnesses/claude_json.py +174 -0
- dokimi/harnesses/command.py +279 -0
- dokimi/harnesses/container_agent.py +383 -0
- dokimi/harnesses/docker_solver.py +850 -0
- dokimi/harnesses/egress_proxy.py +410 -0
- dokimi/harnesses/exploration.py +451 -0
- dokimi/harnesses/models.py +127 -0
- dokimi/harnesses/operations.py +111 -0
- dokimi/harnesses/registry.py +164 -0
- dokimi/harnesses/runner.py +441 -0
- dokimi/harnesses/skill_treatment.py +298 -0
- dokimi/harnesses/solver_stream.py +131 -0
- dokimi/harnesses/tasks.py +630 -0
- dokimi/ingest/__init__.py +4 -0
- dokimi/ingest/chunker.py +26 -0
- dokimi/ingest/docling_loader.py +85 -0
- dokimi/ingest/loader.py +22 -0
- dokimi/intents.py +117 -0
- dokimi/intents.yaml +127 -0
- dokimi/llm.py +1112 -0
- dokimi/mcp/__init__.py +92 -0
- dokimi/mcp/context7.py +244 -0
- dokimi/mcp/factory.py +211 -0
- dokimi/mcp/local_markdown.py +146 -0
- dokimi/mcp/mcp_protocol.py +215 -0
- dokimi/mcp/okf_client.py +233 -0
- dokimi/mcp/url_client.py +173 -0
- dokimi/mcp/utils.py +67 -0
- dokimi/metrics/__init__.py +11 -0
- dokimi/metrics/coverage.py +27 -0
- dokimi/metrics/example_runner.py +239 -0
- dokimi/metrics/exploration.py +396 -0
- dokimi/metrics/freshness_lite.py +27 -0
- dokimi/metrics/readability.py +46 -0
- dokimi/metrics/usage.py +225 -0
- dokimi/novelty/__init__.py +53 -0
- dokimi/novelty/analyze.py +482 -0
- dokimi/novelty/atoms.py +536 -0
- dokimi/novelty/attribute.py +347 -0
- dokimi/novelty/batching.py +98 -0
- dokimi/novelty/bundle.py +994 -0
- dokimi/novelty/classify.py +347 -0
- dokimi/novelty/client.py +230 -0
- dokimi/novelty/items.py +220 -0
- dokimi/novelty/measure.py +539 -0
- dokimi/novelty/needs.py +242 -0
- dokimi/novelty/probes.py +474 -0
- dokimi/novelty/report.py +555 -0
- dokimi/novelty/retention.py +263 -0
- dokimi/novelty/routing.py +317 -0
- dokimi/novelty/shorten.py +334 -0
- dokimi/novelty/spans.py +315 -0
- dokimi/novelty/tiers.py +240 -0
- dokimi/orchestrator/__init__.py +8 -0
- dokimi/orchestrator/pipeline.py +714 -0
- dokimi/personas/__init__.py +9 -0
- dokimi/personas/analyzer.py +358 -0
- dokimi/personas/generator.py +239 -0
- dokimi/plugins.py +334 -0
- dokimi/process.py +766 -0
- dokimi/products.yaml +336 -0
- dokimi/questions/__init__.py +10 -0
- dokimi/questions/artifact.py +266 -0
- dokimi/questions/chunk_gen.py +262 -0
- dokimi/questions/llm_gen.py +347 -0
- dokimi/questions/normalizer.py +116 -0
- dokimi/questions/panel_reviewer.py +345 -0
- dokimi/questions/quality_analyzer.py +177 -0
- dokimi/questions/ragas_seed.py +192 -0
- dokimi/questions/refiner.py +318 -0
- dokimi/questions/validator.py +498 -0
- dokimi/registry.py +103 -0
- dokimi/report/__init__.py +22 -0
- dokimi/report/arms_report.py +302 -0
- dokimi/report/eval_report.py +478 -0
- dokimi/report/exploration_report.py +115 -0
- dokimi/report/generator.py +539 -0
- dokimi/report/json_report.py +15 -0
- dokimi/report/markdown_report.py +87 -0
- dokimi/report/matrix_rollup_report.py +57 -0
- dokimi/report/model_compare.py +1317 -0
- dokimi/report/plugin_delta_report.py +104 -0
- dokimi/report/subject_report.py +162 -0
- dokimi/report/task_runs_report.py +337 -0
- dokimi/repos/__init__.py +33 -0
- dokimi/repos/bases.py +57 -0
- dokimi/repos/compare.py +447 -0
- dokimi/repos/container.py +217 -0
- dokimi/repos/events.py +204 -0
- dokimi/repos/grading.py +253 -0
- dokimi/repos/harness_profile.py +192 -0
- dokimi/repos/l0/__init__.py +135 -0
- dokimi/repos/l0/adapters.py +475 -0
- dokimi/repos/l0/core.py +601 -0
- dokimi/repos/l0/markdown.py +68 -0
- dokimi/repos/manifest.py +541 -0
- dokimi/repos/profiles/claude-code.yaml +85 -0
- dokimi/repos/reach.py +454 -0
- dokimi/repos/report.py +137 -0
- dokimi/repos/runner.py +369 -0
- dokimi/repos/selfcheck.py +149 -0
- dokimi/repos/source.py +449 -0
- dokimi/repos/static.py +36 -0
- dokimi/repos/support/no_background.py +80 -0
- dokimi/repos/workspace.py +329 -0
- dokimi/runner/__init__.py +4 -0
- dokimi/runner/compare.py +70 -0
- dokimi/runner/manifest.py +106 -0
- dokimi/runner/run.py +165 -0
- dokimi/runner/spec.py +136 -0
- dokimi/runner/spec.schema.json +214 -0
- dokimi/schema_check.py +44 -0
- dokimi/schema_compat.py +355 -0
- dokimi/schemas/answers.v1.json +94 -0
- dokimi/schemas/arms.v1.json +400 -0
- dokimi/schemas/eval.v1.json +98 -0
- dokimi/schemas/explore_runs.v1.json +212 -0
- dokimi/schemas/guidance_reach.v1.json +106 -0
- dokimi/schemas/harness_profile.v1.json +63 -0
- dokimi/schemas/matrix_rollup.v1.json +105 -0
- dokimi/schemas/novelty.v1.json +485 -0
- dokimi/schemas/plugin_delta.v1.json +68 -0
- dokimi/schemas/questions.v1.json +93 -0
- dokimi/schemas/questions.v2.json +230 -0
- dokimi/schemas/repo_grade.v1.json +20 -0
- dokimi/schemas/repo_manifest.v1.json +80 -0
- dokimi/schemas/repo_readiness.v1.json +88 -0
- dokimi/schemas/repo_static.v1.json +23 -0
- dokimi/schemas/subject_scorecard.v1.json +109 -0
- dokimi/schemas/subject_scorecard.v2.json +266 -0
- dokimi/schemas/task_runs.v1.json +579 -0
- dokimi/schemas/tool_preference.v1.json +118 -0
- dokimi/schemas/tool_preference_groups.v1.json +76 -0
- dokimi/skills/__init__.py +8 -0
- dokimi/skills/loader.py +154 -0
- dokimi/subjects/__init__.py +10 -0
- dokimi/subjects/loader.py +215 -0
- dokimi/subjects/models.py +63 -0
- dokimi/subjects/novelty_track.py +123 -0
- dokimi/subjects/scorecard.py +173 -0
- dokimi/tool_preference.py +380 -0
- dokimi/treatments/__init__.py +56 -0
- dokimi/treatments/arms.py +464 -0
- dokimi/treatments/base.py +124 -0
- dokimi/treatments/factory.py +102 -0
- dokimi/treatments/specs.py +234 -0
- dokimi/treatments/tools.py +177 -0
- dokimi/utils.py +178 -0
- dokimi-0.1.0.dist-info/METADATA +402 -0
- dokimi-0.1.0.dist-info/RECORD +244 -0
- dokimi-0.1.0.dist-info/WHEEL +5 -0
- dokimi-0.1.0.dist-info/entry_points.txt +2 -0
- dokimi-0.1.0.dist-info/licenses/LICENSE +202 -0
- dokimi-0.1.0.dist-info/licenses/NOTICE +6 -0
- dokimi-0.1.0.dist-info/top_level.txt +1 -0
dokimi/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Copyright (C) 2026 Intel Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Dokimi: measure whether context improves a coding agent's answers and task outcomes.
|
|
5
|
+
|
|
6
|
+
The context under test is documentation, a skill, an MCP doc source or an agent
|
|
7
|
+
persona prompt.
|
|
8
|
+
|
|
9
|
+
The command line, `dokimi` (`python cli.py` in a source checkout), has its own
|
|
10
|
+
CLI reference; this reference covers the modules behind it. They group by
|
|
11
|
+
evaluation track, as `docs/architecture.md` lays out:
|
|
12
|
+
|
|
13
|
+
- Static documentation track: [`ingest`][dokimi.ingest] loads Markdown,
|
|
14
|
+
[`metrics`][dokimi.metrics] scores it, [`runner`][dokimi.runner] runs a
|
|
15
|
+
benchmark spec and compares snapshots, [`gate`][dokimi.gate] applies the
|
|
16
|
+
gates, [`report`][dokimi.report] writes the result.
|
|
17
|
+
- LLM evaluation track: [`personas`][dokimi.personas],
|
|
18
|
+
[`questions`][dokimi.questions], [`mcp`][dokimi.mcp] (doc-source clients),
|
|
19
|
+
[`eval`][dokimi.eval] (answering and judging),
|
|
20
|
+
[`dashboard`][dokimi.dashboard] and [`orchestrator`][dokimi.orchestrator].
|
|
21
|
+
- Treatment arms: [`treatments`][dokimi.treatments], with the content they
|
|
22
|
+
inject from [`skills`][dokimi.skills] and
|
|
23
|
+
[`agent_profiles`][dokimi.agent_profiles].
|
|
24
|
+
- Executable tasks: [`harnesses`][dokimi.harnesses] runs coding agents on task
|
|
25
|
+
directories; [`context`][dokimi.context] reads what their context window
|
|
26
|
+
held.
|
|
27
|
+
- Content novelty: [`novelty`][dokimi.novelty]. Repositories as subjects:
|
|
28
|
+
[`repos`][dokimi.repos]. Scorecards over time: [`subjects`][dokimi.subjects].
|
|
29
|
+
Exploration quality: [`explore`][dokimi.explore]. External catalogs:
|
|
30
|
+
[`external`][dokimi.external].
|
|
31
|
+
|
|
32
|
+
Shared plumbing sits in the top-level modules: [`artifacts`][dokimi.artifacts]
|
|
33
|
+
(versioned artifact schemas), [`llm`][dokimi.llm] (model calls),
|
|
34
|
+
[`config`][dokimi.config] and [`registry`][dokimi.registry].
|
|
35
|
+
|
|
36
|
+
The installed version is `dokimi.__version__`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from dokimi._version import __version__
|
|
40
|
+
|
|
41
|
+
__all__ = ["__version__"]
|
dokimi/__main__.py
ADDED
dokimi/_version.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Copyright (C) 2026 Intel Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""The dokimi version, single-sourced from ``project.version`` in pyproject.toml.
|
|
5
|
+
|
|
6
|
+
A source checkout reads pyproject.toml directly, so the version is right even
|
|
7
|
+
when the tree is used without installing it (``python cli.py``) or after a bump
|
|
8
|
+
that an editable install's metadata has not caught up with. An installed wheel
|
|
9
|
+
has no pyproject.toml next to the package and reads the distribution metadata.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import tomllib
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_version() -> str:
|
|
22
|
+
if _PYPROJECT.is_file():
|
|
23
|
+
project = tomllib.loads(_PYPROJECT.read_text(encoding="utf-8")).get("project", {})
|
|
24
|
+
if project.get("name") == "dokimi" and project.get("version"):
|
|
25
|
+
return str(project["version"])
|
|
26
|
+
try:
|
|
27
|
+
return metadata.version("dokimi")
|
|
28
|
+
except metadata.PackageNotFoundError:
|
|
29
|
+
return "0+unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__version__ = _read_version()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (C) 2026 Intel Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Agent persona prompts: load system prompts that shape the answering agent.
|
|
5
|
+
|
|
6
|
+
These are deliberately *not* called "personas" — in this codebase ``persona``
|
|
7
|
+
means a synthetic user who asks questions (``dokimi/personas``). An
|
|
8
|
+
agent profile is a property of the *answering* agent (its system prompt).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .loader import AgentProfile, load_agent_profile, discover_agent_profiles
|
|
12
|
+
|
|
13
|
+
__all__ = ["AgentProfile", "load_agent_profile", "discover_agent_profiles"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Copyright (C) 2026 Intel Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Load agent persona prompts (answering-agent system prompts).
|
|
5
|
+
|
|
6
|
+
An agent profile is a Markdown file with optional YAML frontmatter::
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
id: concise_expert
|
|
10
|
+
name: Concise Expert
|
|
11
|
+
description: Terse, code-first senior engineer.
|
|
12
|
+
---
|
|
13
|
+
You are a senior systems engineer. Answer tersely, lead with code, …
|
|
14
|
+
|
|
15
|
+
The body (frontmatter stripped) is the system prompt. When no frontmatter is
|
|
16
|
+
present the whole file is the system prompt and the id is derived from the
|
|
17
|
+
filename.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List
|
|
23
|
+
|
|
24
|
+
from dokimi.utils import parse_frontmatter
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AgentProfile:
|
|
29
|
+
"""A loaded agent persona prompt.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
id: Stable identifier (frontmatter ``id`` or the filename stem).
|
|
33
|
+
name: Human-readable name.
|
|
34
|
+
description: One-line summary of the profile.
|
|
35
|
+
system_prompt: The system prompt sent to the answering agent.
|
|
36
|
+
path: Source file path.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
system_prompt: str
|
|
43
|
+
path: Path
|
|
44
|
+
|
|
45
|
+
def as_prompt(self, max_chars: int = 12_000) -> str:
|
|
46
|
+
"""Render the prompt as it will be sent, capped at *max_chars*.
|
|
47
|
+
|
|
48
|
+
The cap mirrors :meth:`dokimi.skills.Skill.as_context` — see that
|
|
49
|
+
docstring for where the number came from (an inherited convention, not a tuned
|
|
50
|
+
value). The parity, not the figure, is what matters here: comparing the
|
|
51
|
+
system-prompt channel against the context channel is only meaningful if one
|
|
52
|
+
side cannot carry an unbounded payload the other would have had truncated.
|
|
53
|
+
Truncation is recorded by the caller rather than hidden — see
|
|
54
|
+
``AgentProfileTreatment``.
|
|
55
|
+
"""
|
|
56
|
+
prompt = self.system_prompt.strip()
|
|
57
|
+
if len(prompt) > max_chars:
|
|
58
|
+
return prompt[:max_chars].rstrip() + "\n…"
|
|
59
|
+
return prompt
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_agent_profile(path) -> AgentProfile:
|
|
63
|
+
"""Load a single agent profile from a Markdown file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: Path to the profile ``.md`` file.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
An :class:`AgentProfile`.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
FileNotFoundError: If the file does not exist.
|
|
73
|
+
ValueError: If the profile has no system-prompt body.
|
|
74
|
+
"""
|
|
75
|
+
p = Path(path)
|
|
76
|
+
if not p.is_file():
|
|
77
|
+
raise FileNotFoundError(f"Agent profile not found: {p}")
|
|
78
|
+
|
|
79
|
+
meta, body = parse_frontmatter(p.read_text(encoding="utf-8"))
|
|
80
|
+
system_prompt = body.strip()
|
|
81
|
+
if not system_prompt:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Agent profile '{p}' has an empty body; the body is the system prompt."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
pid = str(meta.get("id") or p.stem).strip()
|
|
87
|
+
if not pid:
|
|
88
|
+
raise ValueError(f"Agent profile '{p}' has an empty 'id' after normalization.")
|
|
89
|
+
return AgentProfile(
|
|
90
|
+
id=pid,
|
|
91
|
+
name=str(meta.get("name") or pid).strip(),
|
|
92
|
+
description=str(meta.get("description") or "").strip(),
|
|
93
|
+
system_prompt=system_prompt,
|
|
94
|
+
path=p,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def discover_agent_profiles(root) -> List[AgentProfile]:
|
|
99
|
+
"""Load every ``.md`` agent profile under *root*."""
|
|
100
|
+
root = Path(root)
|
|
101
|
+
return [load_agent_profile(p) for p in sorted(root.glob("*.md"))]
|
dokimi/artifacts.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Copyright (C) 2026 Intel Corporation
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Versioned, schema-validated I/O for produced artifacts.
|
|
5
|
+
|
|
6
|
+
The benchmark produces long-lived JSON artifact kinds: ``questions``,
|
|
7
|
+
``answers``, ``eval`` (LLM-judge scores), ``arms`` (treatment-arm comparisons),
|
|
8
|
+
``task_runs`` (executable task results), ``explore_runs`` (ExploreBench
|
|
9
|
+
localization results), ``novelty`` (what an artifact's content adds to one
|
|
10
|
+
model), ``guidance_reach`` (L1 reach probes) and ``repo_readiness`` (L0 claim
|
|
11
|
+
checks) and ``tool_preference`` (which of two equal tools a model picks, and
|
|
12
|
+
what moves it). This module stamps each with a ``schema_version`` and
|
|
13
|
+
validates it against the JSON Schema in ``dokimi/schemas/`` on save
|
|
14
|
+
and load, so fixtures and baselines stay forward-compatible and self-describing.
|
|
15
|
+
|
|
16
|
+
Every artifact of a registered kind is written through :func:`save_artifact`, partial
|
|
17
|
+
(incremental) saves included: a run that would write an invalid artifact fails on its first
|
|
18
|
+
save, not after the last paid model call. :func:`schema_errors` is the one place a JSON Schema
|
|
19
|
+
is applied, for artifacts and for validated *inputs* (manifests, profiles, specs) alike.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import threading
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
SCHEMA_DIR = Path(__file__).resolve().parent / "schemas"
|
|
34
|
+
|
|
35
|
+
# kind -> current schema_version string (also the schema filename stem).
|
|
36
|
+
SCHEMA_VERSIONS: dict[str, str] = {
|
|
37
|
+
"questions": "questions.v2",
|
|
38
|
+
"answers": "answers.v1",
|
|
39
|
+
"eval": "eval.v1",
|
|
40
|
+
"arms": "arms.v1",
|
|
41
|
+
"matrix_rollup": "matrix_rollup.v1",
|
|
42
|
+
"plugin_delta": "plugin_delta.v1",
|
|
43
|
+
"subject_scorecard": "subject_scorecard.v2",
|
|
44
|
+
"novelty": "novelty.v1",
|
|
45
|
+
"task_runs": "task_runs.v1",
|
|
46
|
+
"explore_runs": "explore_runs.v1",
|
|
47
|
+
"guidance_reach": "guidance_reach.v1",
|
|
48
|
+
"repo_readiness": "repo_readiness.v1",
|
|
49
|
+
"tool_preference": "tool_preference.v1",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# kind -> superseded versions still accepted on *load*, newest first. An artifact written under an
|
|
53
|
+
# older version is still a valid artifact and reading one must not fail; `stamp` always writes the
|
|
54
|
+
# current version, so nothing is written back at an old version. A reader that needs the current
|
|
55
|
+
# shape upgrades on read (questions.v1 `text` -> v2 `question`: dokimi.questions.artifact).
|
|
56
|
+
SUPERSEDED_VERSIONS: dict[str, tuple[str, ...]] = {
|
|
57
|
+
"questions": ("questions.v1",),
|
|
58
|
+
"subject_scorecard": ("subject_scorecard.v1",),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ArtifactValidationError(ValueError):
|
|
63
|
+
"""Raised when an artifact does not conform to its schema."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _schema_path(kind: str, version: str | None = None) -> Path:
|
|
67
|
+
if kind not in SCHEMA_VERSIONS:
|
|
68
|
+
raise KeyError(f"Unknown artifact kind: {kind!r}. Known: {sorted(SCHEMA_VERSIONS)}")
|
|
69
|
+
return SCHEMA_DIR / f"{version or SCHEMA_VERSIONS[kind]}.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def schema_version_for(kind: str, data: dict) -> str:
|
|
73
|
+
"""Which schema version ``data`` should be validated against.
|
|
74
|
+
|
|
75
|
+
The current version, unless the payload declares a superseded one for this kind — an artifact
|
|
76
|
+
written before a bump is still valid, so reading it must not fail. An unknown version string is
|
|
77
|
+
*not* honoured: it falls through to the current schema, whose ``const`` then rejects it, which
|
|
78
|
+
is how a typo or a future version stays an error instead of silently skipping validation.
|
|
79
|
+
|
|
80
|
+
An *unstamped* payload (no ``schema_version`` key: hand-written and older sets, e.g. curated
|
|
81
|
+
question files) is matched against the current version, then each superseded one, newest
|
|
82
|
+
first, and resolves to the first it satisfies; when it satisfies none, to the one it violates
|
|
83
|
+
least (the newest on a tie), so a set with one real fault reports that fault, not every
|
|
84
|
+
record as a wrong shape.
|
|
85
|
+
"""
|
|
86
|
+
if "schema_version" not in data and SUPERSEDED_VERSIONS.get(kind):
|
|
87
|
+
counts = {v: len(schema_errors(v, data))
|
|
88
|
+
for v in (SCHEMA_VERSIONS[kind], *SUPERSEDED_VERSIONS[kind])}
|
|
89
|
+
return min(counts, key=counts.__getitem__)
|
|
90
|
+
declared = data.get("schema_version")
|
|
91
|
+
if declared in SUPERSEDED_VERSIONS.get(kind, ()):
|
|
92
|
+
return str(declared)
|
|
93
|
+
return SCHEMA_VERSIONS[kind]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def schema_errors(schema: str | Path, instance: Any) -> list[str]:
|
|
97
|
+
"""Every way ``instance`` violates a JSON Schema (draft 2020-12); empty when it conforms.
|
|
98
|
+
|
|
99
|
+
``schema`` is a schema name in :data:`SCHEMA_DIR` (``"repo_static.v1"``) or the path of a
|
|
100
|
+
schema file elsewhere. Each problem reads ``<path>: <message>``, ``<path>`` being the
|
|
101
|
+
``/``-joined location in ``instance`` (``<root>`` for the top level), sorted by location.
|
|
102
|
+
An unreadable or malformed schema file raises ``OSError`` / ``json.JSONDecodeError``: that
|
|
103
|
+
is a packaging fault, not a problem with ``instance``.
|
|
104
|
+
"""
|
|
105
|
+
from jsonschema import Draft202012Validator
|
|
106
|
+
|
|
107
|
+
path = SCHEMA_DIR / f"{schema}.json" if isinstance(schema, str) else Path(schema)
|
|
108
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
109
|
+
return [
|
|
110
|
+
f"{'/'.join(map(str, e.path)) or '<root>'}: {e.message}"
|
|
111
|
+
for e in sorted(Draft202012Validator(document).iter_errors(instance),
|
|
112
|
+
key=lambda e: list(e.path))
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_artifact(kind: str, data: dict) -> None:
|
|
117
|
+
"""Validate ``data`` against the schema for ``kind`` (raises on failure)."""
|
|
118
|
+
errors = schema_errors(_schema_path(kind, schema_version_for(kind, data)), data)
|
|
119
|
+
if errors:
|
|
120
|
+
details = "\n".join(f" - {e}" for e in errors)
|
|
121
|
+
raise ArtifactValidationError(f"{kind} artifact validation failed:\n{details}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def stamp(kind: str, data: dict) -> dict:
|
|
125
|
+
"""Return ``data`` with its ``schema_version`` set for ``kind``."""
|
|
126
|
+
if kind not in SCHEMA_VERSIONS:
|
|
127
|
+
raise KeyError(f"Unknown artifact kind: {kind!r}")
|
|
128
|
+
data["schema_version"] = SCHEMA_VERSIONS[kind]
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_artifact(kind: str, data: dict, path: Path | str) -> dict:
|
|
133
|
+
"""Write ``data`` to ``path`` as a ``kind`` artifact; return exactly what was written.
|
|
134
|
+
|
|
135
|
+
- ``schema_version`` is set to the current version for ``kind`` and leads the document
|
|
136
|
+
(a value already in ``data`` is replaced); ``data`` itself is not mutated.
|
|
137
|
+
- The payload is validated first. :class:`ArtifactValidationError` means nothing was
|
|
138
|
+
written at ``path`` (a file already there is untouched); the rejected payload is kept
|
|
139
|
+
at ``<name>.rejected`` so a paid run is not lost to a schema problem. A later valid
|
|
140
|
+
save to ``path`` removes that ``<name>.rejected`` (only that sibling), so a leftover
|
|
141
|
+
always belongs to the latest attempt; if it cannot be removed the save still stands and
|
|
142
|
+
a warning is logged. ``KeyError`` means ``kind`` is not registered in
|
|
143
|
+
:data:`SCHEMA_VERSIONS`.
|
|
144
|
+
- The write is atomic (a ``<name>.<pid>.<thread>.tmp`` sibling, then ``os.replace``), so a
|
|
145
|
+
reader or a crash never sees half a file, and concurrent saves to one path (threads or
|
|
146
|
+
processes) each publish one whole payload; the last one wins. A failed write removes its
|
|
147
|
+
tmp file. Missing parent directories of ``path`` are created.
|
|
148
|
+
- A symlink at ``path`` is kept: the payload replaces the file it resolves to (the tmp file
|
|
149
|
+
sits beside that target, so the rename stays on one filesystem). A dangling link creates
|
|
150
|
+
its target when the target's directory exists; otherwise ``FileNotFoundError`` is raised
|
|
151
|
+
and nothing is written (the target's directories are *not* created). A link loop raises
|
|
152
|
+
``OSError``. ``<name>.rejected`` stays beside ``path``, not the target.
|
|
153
|
+
- JSON with ``indent=2``, ASCII-escaped, UTF-8.
|
|
154
|
+
"""
|
|
155
|
+
if kind not in SCHEMA_VERSIONS:
|
|
156
|
+
raise KeyError(f"Unknown artifact kind: {kind!r}")
|
|
157
|
+
payload = {"schema_version": SCHEMA_VERSIONS[kind],
|
|
158
|
+
**{k: v for k, v in data.items() if k != "schema_version"}}
|
|
159
|
+
path = Path(path)
|
|
160
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
text = json.dumps(payload, indent=2)
|
|
162
|
+
try:
|
|
163
|
+
validate_artifact(kind, payload)
|
|
164
|
+
except ArtifactValidationError as exc:
|
|
165
|
+
rejected = path.with_name(path.name + ".rejected")
|
|
166
|
+
rejected.write_text(text, encoding="utf-8")
|
|
167
|
+
raise ArtifactValidationError(f"{exc}\n(not written to {path}; payload kept at {rejected})") from exc
|
|
168
|
+
target = _write_target(path)
|
|
169
|
+
tmp = target.with_name(f"{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
170
|
+
try:
|
|
171
|
+
tmp.write_text(text, encoding="utf-8")
|
|
172
|
+
os.replace(tmp, target)
|
|
173
|
+
except BaseException:
|
|
174
|
+
tmp.unlink(missing_ok=True)
|
|
175
|
+
raise
|
|
176
|
+
stale = path.with_name(path.name + ".rejected")
|
|
177
|
+
try:
|
|
178
|
+
stale.unlink(missing_ok=True)
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
logger.warning("saved %s, but could not remove the stale rejected payload %s: %s", path, stale, exc)
|
|
181
|
+
return payload
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _write_target(path: Path) -> Path:
|
|
185
|
+
"""The file a save to ``path`` replaces: ``path`` itself, or what a symlink there resolves to."""
|
|
186
|
+
if not path.is_symlink():
|
|
187
|
+
return path
|
|
188
|
+
target = Path(os.path.realpath(path))
|
|
189
|
+
if target.is_symlink(): # realpath gives up on a loop and hands back a link
|
|
190
|
+
raise OSError(f"symlink {path} does not resolve to a file (link loop at {target})")
|
|
191
|
+
if not target.parent.is_dir():
|
|
192
|
+
raise FileNotFoundError(f"symlink {path} points into a missing directory: {target}")
|
|
193
|
+
return target
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_artifact(kind: str, path: Path, *, validate: bool = True) -> dict:
|
|
197
|
+
"""Read and (optionally) validate an artifact from ``path``."""
|
|
198
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
199
|
+
if validate:
|
|
200
|
+
validate_artifact(kind, data)
|
|
201
|
+
return data
|