llm-dev-core 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/ARCHITECTURE.md +8 -7
- llm_dev_core-0.5.0/CHANGELOG.md +83 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/PKG-INFO +1 -1
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/README.md +3 -3
- llm_dev_core-0.5.0/docs/decisions/0003-secure-base-contract.md +66 -0
- llm_dev_core-0.5.0/docs/decisions/0004-test-kit-dataset-contract.md +71 -0
- llm_dev_core-0.5.0/docs/metrics/README.md +8 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/registry.py +2 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/tests/test_schema_validate.py +15 -2
- llm_dev_core-0.5.0/packages/secure-base/pyproject.toml +15 -0
- llm_dev_core-0.5.0/packages/secure-base/src/secure_base/__init__.py +14 -0
- llm_dev_core-0.5.0/packages/secure-base/src/secure_base/detectors.py +52 -0
- llm_dev_core-0.5.0/packages/secure-base/src/secure_base/findings.py +9 -0
- llm_dev_core-0.5.0/packages/secure-base/src/secure_base/profile.py +27 -0
- llm_dev_core-0.5.0/packages/secure-base/src/secure_base/redact.py +36 -0
- llm_dev_core-0.5.0/packages/secure-base/tests/test_secure_base.py +74 -0
- llm_dev_core-0.5.0/packages/test-kit/pyproject.toml +21 -0
- llm_dev_core-0.5.0/packages/test-kit/src/test_kit/__init__.py +17 -0
- llm_dev_core-0.5.0/packages/test-kit/src/test_kit/criteria.py +61 -0
- llm_dev_core-0.5.0/packages/test-kit/src/test_kit/dataset.py +63 -0
- llm_dev_core-0.5.0/packages/test-kit/src/test_kit/runner.py +95 -0
- llm_dev_core-0.5.0/packages/test-kit/tests/test_test_kit.py +170 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/pyproject.toml +3 -1
- llm_dev_core-0.3.0/CHANGELOG.md +0 -35
- llm_dev_core-0.3.0/docs/metrics/README.md +0 -7
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.github/workflows/ci.yml +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.github/workflows/publish.yml +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.gitignore +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.python-version +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/Makefile +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/decisions/0000-stack.md +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/decisions/0001-llm-client-contract.md +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/decisions/0002-schema-validate-contract.md +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/runbooks/README.md +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/pyproject.toml +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/scripts/record_tape_opencode.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/__init__.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/cache.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/client.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/models.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/pricing.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/provider.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/__init__.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/openai_compat.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/opencode_cli.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/replay.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/span.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/__init__.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/cassettes/complete-opencode--big-pickle-2.jsonl +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/conftest.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_openai_compat.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_pricing_cache.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_real_tape.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_replay.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_span.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/pyproject.toml +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/__init__.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/validate.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/scripts/check_consumers.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/scripts/collect_metrics.py +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/scripts/compat_check.sh +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/consumer-readme.md +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/core-consumer.yml +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/eval-dataset.jsonl +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/prompt.md +0 -0
- {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/retrofit-issue.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ARCHITECTURE.md — llm-dev-core
|
|
2
2
|
|
|
3
|
-
> **Estado:** v0.
|
|
3
|
+
> **Estado:** v0.4 (seed revisado) · **Última revisión:** 2026-09-22
|
|
4
4
|
> **Audiencia:** yo en 52 semanas, cualquiera que revise este repo, y entrevistadores técnicos.
|
|
5
5
|
|
|
6
6
|
---
|
|
@@ -46,6 +46,7 @@ Las métricas de este proyecto no son aspiracionales. Cada una tiene una fuente
|
|
|
46
46
|
| Superficie nueva ≤ 30% | Manifest del consumidor | `new_surface` y `reused_modules` declarados y revisados |
|
|
47
47
|
| Llamadas LLM vía `llm-client` | Código | Lint anti-imports directos: el build falla si un repo importa el SDK fuera de `llm-client` |
|
|
48
48
|
| Prompts en el registry | Archivos de prompts | Lint anti-prompts inline + frontmatter obligatorio desde sem. 2 |
|
|
49
|
+
| Todo prompt nuevo tiene eval (desde sem. 5) | Frontmatter `eval` + dataset | `make validate-consumer` rompe si el prompt referencia un eval que no existe / con dataset vacío |
|
|
49
50
|
| Evals en CI desde sem. 5 | CI del consumidor | Job `eval-smoke` obligatorio en la plantilla |
|
|
50
51
|
| Retrofittings documentados | Issues/PRs | Label `retrofit` + plantilla antes/después |
|
|
51
52
|
| Compat check | CI del core | Workflow `compat-last-5` en cada merge a `main` |
|
|
@@ -131,7 +132,7 @@ La reparación tiene **presupuesto**: máximo 2 intentos y un cap de costo por m
|
|
|
131
132
|
|
|
132
133
|
```yaml
|
|
133
134
|
id: commit-message-generator
|
|
134
|
-
version: 0.
|
|
135
|
+
version: 0.4.0
|
|
135
136
|
owner: llm-dev-core
|
|
136
137
|
model_family: gpt-4o-mini
|
|
137
138
|
schema: CommitMessage
|
|
@@ -339,11 +340,11 @@ La regla es simple: si no puedes pagar el eval de forma repetible, el eval está
|
|
|
339
340
|
|
|
340
341
|
## 10. Estado actual
|
|
341
342
|
|
|
342
|
-
- **Versión:** v0.
|
|
343
|
-
- **Módulos existentes:** `packages/llm-client` v0.1.0 (seed sem. 2) — `complete`/`stream`, retry+backoff+jitter, reparación con tope, cap de costo, cache, span de 20 campos, record/replay nativo, transportes `opencode` (sesión local sin API key) y `openai-compatible` (HTTP). `packages/schema-validate` v0.1.0 (seed sem. 3) — validación Pydantic de la salida LLM (ADR-2): JSON con/sin caretas, errores campo a campo, registro `SchemaId → modelo`, `parsed` en `ValidationResult`; compone con `llm-client` vía el hook `validator
|
|
344
|
-
- **Consumidores:** `Proyectos/commit-cli` (sem. 2) — CLI que propone mensajes de commit desde `git diff`; `Proyectos/release-scribe` (sem. 3) — release notes JSON validadas por `schema-validate` desde `git log
|
|
345
|
-
- **Empaquetado:** una sola dist `llm-dev-core
|
|
346
|
-
- **Enforcement:** ADR-0, ADR-1 (`llm-client-contract`)
|
|
343
|
+
- **Versión:** v0.5
|
|
344
|
+
- **Módulos existentes:** `packages/llm-client` v0.1.0 (seed sem. 2) — `complete`/`stream`, retry+backoff+jitter, reparación con tope, cap de costo, cache, span de 20 campos, record/replay nativo, transportes `opencode` (sesión local sin API key) y `openai-compatible` (HTTP). `packages/schema-validate` v0.1.0 (seed sem. 3) — validación Pydantic de la salida LLM (ADR-2): JSON con/sin caretas, errores campo a campo, registro `SchemaId → modelo`, `parsed` en `ValidationResult`; compone con `llm-client` vía el hook `validator` (que respeta el contrato `schema=None → ok` desde v0.5). `packages/secure-base` v0.1.0 (seed sem. 4) — utilidad de máscara (ADR-3): `detect`/`redact`/`sanitize_for_prompt`/`assert_redacted` + `SecurityProfile`; máscara unidireccional y determinista (placeholder `[REDACTED:<tipo>:<n>]`), 9 detectores, hoja de la seguridad (no depende de `llm-client` ni `schema-validate`); compone en los renderers, antes de que el texto cruce al proveedor. `packages/test-kit` v0.1.0 (seed sem. 5) — los tests de prompts son datasets (ADR-4): `EvalDataset.from_jsonl` (casos `{prompt_id, prompt_version, input, expected, criteria}`), tres criterios deterministas (`deterministic_match`, `json_match`, `schema_match`), runner agnóstico del transporte (`judge: EvalCase → CompletionResult` inyectado), modo `smoke`/`full`, umbral y costo acumulado (§8.1).
|
|
345
|
+
- **Consumidores:** `Proyectos/commit-cli` (sem. 2) — CLI que propone mensajes de commit desde `git diff`; `Proyectos/release-scribe` (sem. 3) — release notes JSON validadas por `schema-validate` desde `git log`; `Proyectos/sec-check` (sem. 4) — escáner de secretos con triaje LLM: redacta con `secure-base` y aborta si `assert_redacted` no es vacío, reporte `sec-findings-v1` validado por `schema-validate`; `Proyectos/bench-runner` (sem. 5) — CLI de evals con datasets congelados y umbral (exit 0/1/2) cuyo propio prompt `regression-report-generator` (triaje de regresión en `modo json`/`modo texto`) nace evaluado: dogfood del seed, reusa `llm-client` + `schema-validate` + `test-kit`, cassettes reales para CI determinista (D4). Los cuatro dependen de la dist unificada `llm-dev-core` (editable). La métrica de reutilización real se medirá en §10.1 desde el corte de semanal.
|
|
346
|
+
- **Empaquetado:** una sola dist `llm-dev-core` (D1) publicada en PyPI: `llm_client`, `schema_validate`, `secure_base` y `test_kit` top-level. La publicación es automática desde tag `v*` vía trusted publishing OIDC (workflow `publish.yml`, sin tokens guardados).
|
|
347
|
+
- **Enforcement:** ADR-0, ADR-1 (`llm-client-contract`), ADR-2 (`schema-validate-contract`), ADR-3 (`secure-base-contract`) y ADR-4 (`test-kit-dataset-contract`) en `docs/decisions/`, plantillas en `templates/`, `make validate` (estructura + tests) y `make validate-consumer CONSUMER=../<repo>`.
|
|
347
348
|
- **Pasos por semana según el plan:** se mantiene el calendario semanal (estructura C1–C18 de §5, armonizada en la sem. 3): `schema-validate` (sem. 3), `secure-base` (sem. 4), `test-kit` (sem. 5), `web-api-base` (sem. 6), `ci-pack` (sem. 7), `cache-ratelimit` (sem. 8), `bot-base` (sem. 9), `parser-io` (sem. 10), `docs-gen` (sem. 12), ... hasta `cost-obs` (sem. 39).
|
|
348
349
|
- **Próximo hito:** v1.0 en la semana 13, con 12 consumidores reales detrás.
|
|
349
350
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
La versión es la del paquete `llm-dev-core`. El historial comienza con **v1.0 (semana 13)** — hasta entonces el core es `0.x` y puede romperse sin aviso (D3).
|
|
4
|
+
|
|
5
|
+
## v0.5 — 2026-09-22 (test-kit seed)
|
|
6
|
+
|
|
7
|
+
- Semana 5: nace `packages/test-kit` v0.1.0 dentro de un consumidor real (`bench-runner`).
|
|
8
|
+
- Contrato ADR-4: los tests de un prompt son datasets — `EvalDataset.from_jsonl` con casos
|
|
9
|
+
`{prompt_id, prompt_version, input, expected, criteria}`; a partir de v0.5 un fichero inexistente
|
|
10
|
+
se reporta como `DatasetError` (exit 2), no como traceback.
|
|
11
|
+
- Tres criterios deterministas: `deterministic_match` (colapsa espacios, opción `ignore_case`),
|
|
12
|
+
`json_match` (JSON con/sin caretas) y `schema_match` (estructura validada vía `schema-validate`,
|
|
13
|
+
`expected` opcional).
|
|
14
|
+
- Runner agnóstico del transporte: el consumidor inyecta `judge: EvalCase → CompletionResult`; el
|
|
15
|
+
runner suma `pass_rate`, compara contra `threshold` y acumula `cost_usd`. Modos `smoke`/`full` (§8.1).
|
|
16
|
+
- Cuarto consumidor: `Proyectos/bench-runner` (sem. 5) — CLI `bench-runner` que evalúa prompts contra
|
|
17
|
+
datasets congelados (exit 0 verde, 1 regresión, 2 dataset inválido). Su propio prompt
|
|
18
|
+
`regression-report-generator` (triaje de regresión, `modo json` para reportes estructurados y
|
|
19
|
+
`modo texto` para veredicto en línea) nace evaluado: dogfood del seed. 4 casos, cassette real
|
|
20
|
+
grabado, replay determinista en CI (D4) que hasta reproduce la reparación grabada.
|
|
21
|
+
- `schema-validate`: el hook `validator` respeta el contrato `schema=None → ok` (necesario para que
|
|
22
|
+
`modo texto` no fuerce reparaciones JSON al validar salidas sin schema).
|
|
23
|
+
- Regla nueva en vigor desde la sem. 5: todo prompt nuevo del core/consumidores debe tener evaluación
|
|
24
|
+
(frontmatter `eval` + dataset no vacío), enforced por `make validate-consumer`.
|
|
25
|
+
- Wheel unificado (D1): `llm-dev-core 0.5.0` empaqueta `llm_client` + `schema_validate` + `secure_base`
|
|
26
|
+
+ `test_kit` top-level. `make validate` y `validate-consumer` (4 consumidores) en verde; 49 tests.
|
|
27
|
+
|
|
28
|
+
## v0.4 — 2026-09-22 (secure-base seed)
|
|
29
|
+
|
|
30
|
+
- Semana 4: nace `packages/secure-base` v0.1.0 dentro de un consumidor real (`sec-check`).
|
|
31
|
+
- Contrato ADR-3: `detect`/`redact`/`sanitize_for_prompt`/`assert_redacted` + `SecurityProfile` (declaración
|
|
32
|
+
de tipos para el lint anti-secretos de `ci-pack`, sem. 7).
|
|
33
|
+
- Máscara unidireccional y determinista: placeholder `[REDACTED:<tipo>:<n>]`; `Finding.match` es preview
|
|
34
|
+
truncado (24 chars), nunca el secreto completo. 9 detectores (email, phone, ipv4, credit_card, aws_access_key,
|
|
35
|
+
github_token, private_key, bearer, url_userinfo).
|
|
36
|
+
- `secure-base` es hoja (no depende de `llm-client` ni de `schema-validate`); se compone en el renderer del
|
|
37
|
+
consumidor, antes de que el texto cruce al proveedor.
|
|
38
|
+
- Tercer consumidor: `Proyectos/sec-check` (sem. 4) — escáner de secretos con triaje LLM: detecta en `git diff`
|
|
39
|
+
o archivo, redacta con `secure-base`, aborta si `assert_redacted` no es vacío, y solo el texto enmascarado viaja
|
|
40
|
+
al LLM (`opencode` local o replay). Reporte `sec-findings-v1` validado con `schema-validate`; exit 0 sin secretos,
|
|
41
|
+
1 con secretos, 2 inválido. Tape real grabado con 3 hallazgos críticos, replay en verde.
|
|
42
|
+
- Wheel unificado (D1): `llm-dev-core 0.4.0` ahora empaqueta `llm_client` + `schema_validate` + `secure_base`
|
|
43
|
+
top-level. `make validate` y `validate-consumer` (3 consumidores) en verde; 37 tests.
|
|
44
|
+
- Publicado `llm-dev-core 0.4.0` en PyPI (trusted publishing, tag `v0.4.0`).
|
|
45
|
+
- Nota del cómo: el primer tag `v0.4.0` se creó antes del bump de versión en `pyproject.toml`, así que la
|
|
46
|
+
primera ejecución de `publish.yml` subió el wheel `0.3.0` ya existente (409 en PyPI). Se corrigió bumping
|
|
47
|
+
a `0.4.0` y reconstruyendo el tag sobre ese commit (queda un run fallido en Actions y un mensaje de commit
|
|
48
|
+
de "0.4" que no incluía el bump; el estado final es correcto). Lección: el tag de publicación se crea
|
|
49
|
+
**después** de confirmar la versión en `pyproject.toml`.
|
|
50
|
+
|
|
51
|
+
## v0.3 — 2026-09-21 (schema-validate seed + dist unificada)
|
|
52
|
+
|
|
53
|
+
- Semana 3: nace `packages/schema-validate` v0.1.0 dentro de un consumidor real (`release-scribe`).
|
|
54
|
+
- Contrato ADR-2: validación Pydantic de la salida LLM (JSON con/sin caretas, errores campo a campo),
|
|
55
|
+
registro `SchemaId → modelo`, `parsed` poblando `CompletionResult.parsed`.
|
|
56
|
+
- Reparación: el lazo/presupuesto/cap sigue en `llm-client` (hoja); `schema-validate` alimenta los
|
|
57
|
+
errores vía el hook `validator`. Ajuste menor ADR-1: `ValidationResult.parsed`.
|
|
58
|
+
- Tape real grabado de `opencode run` sin API key (replica validada con `parsed`).
|
|
59
|
+
- **Empaquetado unificado (D1):** el core pasa a publicarse como una sola dist `llm-dev-core` (0.3.0)
|
|
60
|
+
con `llm_client` y `schema_validate` top-level; los consumidores dependen de `llm-dev-core ^0.3`, nunca
|
|
61
|
+
de módulos sueltos. `commit-cli` migra a la dist unificada.
|
|
62
|
+
- Segundo consumidor: `Proyectos/release-scribe` (sem. 3) — release notes JSON validadas desde `git log`;
|
|
63
|
+
reutiliza `llm-client` + `schema-validate`.
|
|
64
|
+
- Calendario armonizado (§5 vs. §10): `schema-validate` (3), `secure-base` (4), `test-kit` (5),
|
|
65
|
+
`web-api-base` (6), `ci-pack` (7), `cache-ratelimit` (8), `bot-base` (9), … hasta `cost-obs` (39).
|
|
66
|
+
Deuda aprobada en el review: la sem-2 quería publicar ya en PyPI; al unificar el empaquetado, la primera
|
|
67
|
+
publicación real pasa a ser esta v0.3.
|
|
68
|
+
- Publicado `llm-dev-core 0.3.0` en PyPI (trusted publishing OIDC, workflow `publish.yml` en tags `v*`):
|
|
69
|
+
primera release real del core; instalable y verificado desde el registro (D1).
|
|
70
|
+
|
|
71
|
+
## v0.2 — 2026-09-20 (llm-client seed)
|
|
72
|
+
|
|
73
|
+
- Semana 2: nace `packages/llm-client` v0.1.0 dentro de un consumidor real (`commit-cli`).
|
|
74
|
+
- Contrato ADR-1 aceptada: `complete`/`stream`, retry+backoff+jitter, reparación con tope, cap de costo, cache, span de 20 campos.
|
|
75
|
+
- Record/replay nativo: cassettes sin API key (`ReplayProvider`); tape real grabado con el transport `opencode` (sesión local, sin key).
|
|
76
|
+
- Transportes: `opencode` (`opencode run --format json`) y `openai-compatible` (HTTP).
|
|
77
|
+
- Workspace uv activado (`members = ["packages/*"]`); grupo dev de pytest. `make validate` ahora también corre los tests.
|
|
78
|
+
- Primer consumidor: `Proyectos/commit-cli` (único módulo del core reutilizado a la fecha: `llm-client`). La métrica de reutilización real se medirá en §10.1 desde el corte de semanal.
|
|
79
|
+
|
|
80
|
+
## v0.1 — 2026-09-20 (seed)
|
|
81
|
+
|
|
82
|
+
- Semana 1: documento `ARCHITECTURE.md`, ADR-0 (stack Python 3.12+), contrato mínimo de `llm-client`, plantillas de consumidor.
|
|
83
|
+
- Enforcement base (§3): `scripts/check_consumers.py` implementado (modo `--self` y de consumidor), `make validate` y workflow `ci.yml`. Pendiente hasta sem. 6: lint anti-imports, evals en CI y página de evidencia (ci-pack).
|
|
@@ -6,15 +6,15 @@ Núcleo versionado y publicado del que dependen 52 proyectos en 52 semanas: un s
|
|
|
6
6
|
|
|
7
7
|
Toda llamada a un LLM pasa por `llm-client`, toda salida no confiable pasa por `schema-validate`, todo prompt vive en `prompt-registry`. Ver `ARCHITECTURE.md` para la tesis, las decisiones (D1–D12) y las métricas verificables.
|
|
8
8
|
|
|
9
|
-
- **Estado:** v0.
|
|
9
|
+
- **Estado:** v0.5 · Semana 5
|
|
10
10
|
- **Stack:** Python 3.12+ (ADR-0, `docs/decisions/0000-stack.md`)
|
|
11
11
|
- **Estructura:** `packages/` (módulos), `templates/` (consumidor clonable), `scripts/` (verificación), `docs/`
|
|
12
|
-
- **Empaquetado:** una sola dist `llm-dev-core` (D1): `llm_client` y `
|
|
12
|
+
- **Empaquetado:** una sola dist `llm-dev-core` (D1): `llm_client`, `schema_validate`, `secure_base` y `test_kit` top-level; los consumidores dependen de `llm-dev-core ^0.x`, nunca de módulos sueltos.
|
|
13
13
|
|
|
14
14
|
## Desarrollo
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
uv sync # instala el workspace
|
|
18
18
|
make validate # invariantes: estructura, plantillas, ADR-0, sintaxis
|
|
19
|
-
uv run pytest # tests (
|
|
19
|
+
uv run pytest # tests (49 a la fecha)
|
|
20
20
|
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Contrato mínimo — `secure-base`
|
|
2
|
+
|
|
3
|
+
- **Fecha:** 2026-09-22
|
|
4
|
+
- **Estado:** aceptada
|
|
5
|
+
- **Origen:** §3.1 en `ARCHITECTURE.md`
|
|
6
|
+
- **Implementada en:** semana 4 (`packages/secure-base` v0.1.0, nacida dentro de `sec-check`)
|
|
7
|
+
|
|
8
|
+
Este documento es el contrato escrito contra el que se implementa la semana 4. Define la utilidad de
|
|
9
|
+
máscara que exige §3.1.1 y el perfil de declaración que alimentará los lints de `ci-pack` (sem. 7).
|
|
10
|
+
|
|
11
|
+
## 1. API conceptual
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
detect(text) -> list[Finding] # hallazgos tipados (sin mutar texto)
|
|
15
|
+
redact(text) -> RedactResult # texto enmascarado + hallazgos
|
|
16
|
+
sanitize_for_prompt(text) -> RedactResult # alias semántico para renderers
|
|
17
|
+
assert_redacted(text, profile?) -> list[Finding] # violaciones de §3.1
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
class Finding(BaseModel):
|
|
22
|
+
type: str # email | phone | ipv4 | credit_card | aws_access_key | github_token | private_key | bearer | url_userinfo
|
|
23
|
+
match: str # preview truncado (24 chars) — NUNCA el secreto completo
|
|
24
|
+
offset: int # offset del primer carácter
|
|
25
|
+
length: int # para reemplazos deterministas
|
|
26
|
+
line: int # 1-based
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 2. Reglas de la máscara
|
|
30
|
+
|
|
31
|
+
- **Unidireccional:** el secreto crudo no se conserva en ningún lugar del resultado. No hay operación inversa.
|
|
32
|
+
- **Determinista:** misma entrada → misma salida (mismo texto y mismos placeholders).
|
|
33
|
+
- **Placeholder:** `[REDACTED:<type>:<n>]`, con `n` por tipo (1-based, en orden de aparición).
|
|
34
|
+
- **`Finding.match` es un preview truncado**: suficiente para triaje, insuficiente para re-inyectar el secreto.
|
|
35
|
+
- **`detect` es no destructivo**: el scanner se usa antes de decidir qué se envía; `redact` produce la salida
|
|
36
|
+
que sí puede cruzar el límite del prompt.
|
|
37
|
+
|
|
38
|
+
## 3. Perfil y lints (§3.1.1 → ci-pack)
|
|
39
|
+
|
|
40
|
+
- `SecurityProfile(data_types: list[str])`: qué tipos declara manejar el consumidor.
|
|
41
|
+
- `assert_redacted(text, profile) -> list[Finding]`: si el resultado **no está vacío**, el texto aún contiene
|
|
42
|
+
secretos en claro declarados por el perfil → violación de la regla 1 de §3.1.
|
|
43
|
+
- El enforcement operativo (que el build falle si un prompt de producción llega con secretos) se implementa
|
|
44
|
+
en `ci-pack` (sem. 7); `secure-base` es el motor que ese lint usaría.
|
|
45
|
+
|
|
46
|
+
## 4. Composición con el resto del core
|
|
47
|
+
|
|
48
|
+
- `secure-base` **no depende de `llm-client`** ni de `schema-validate`: es la hoja de la seguridad. Se compone
|
|
49
|
+
en el renderer del consumidor (antes de construir los mensajes) y en el preámbulo del flujo.
|
|
50
|
+
- `llm-client` redacta `<variable>` en sus spans por contrato (ADR-1, §1); `secure-base` hace lo mismo aguas
|
|
51
|
+
arriba, sobre el payload que podría entrar a un prompt.
|
|
52
|
+
|
|
53
|
+
## 5. Qué NO contrata este documento
|
|
54
|
+
|
|
55
|
+
- No define el lint anti-prompts (sem. 7, `ci-pack`): solo su motor de detección.
|
|
56
|
+
- No reemplaza el manejo de secretos de la máquina del desarrollador: no es un gestor de claves.
|
|
57
|
+
- No garantiza detección perfecta: los patrones son heurísticos; la prueba de que algo no se filtró es
|
|
58
|
+
estructural (el texto que sale de `redact` no contiene ningún hallazgo).
|
|
59
|
+
|
|
60
|
+
## 6. Criterio de aceptación del contrato (semana 4)
|
|
61
|
+
|
|
62
|
+
- [x] `detect` encuentra los 9 tipos en un texto de prueba y no delira con código limpio.
|
|
63
|
+
- [x] `redact` es unidireccional y determinista: nada del secreto crudo sobrevive; mismo input → mismo output.
|
|
64
|
+
- [x] `sanitize_for_prompt` deja `assert_redacted(...) == []` (el texto que cruza al proveedor está limpio).
|
|
65
|
+
- [x] `assert_redacted(text, profile)` filtra por los tipos declarados.
|
|
66
|
+
- [x] Primer consumidor real: `sec-check` (sem. 4), que redacta diffs antes de enviarlos al LLM.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Contrato mínimo — `test-kit`
|
|
2
|
+
|
|
3
|
+
- **Fecha:** 2026-09-22
|
|
4
|
+
- **Estado:** aceptada
|
|
5
|
+
- **Origen:** D7 (ARCHITECTURE.md), §8.1, ADR-1 §109
|
|
6
|
+
- **Implementada en:** semana 5 (`packages/test-kit` v0.1.0, nacida dentro de `bench-runner`)
|
|
7
|
+
|
|
8
|
+
Los tests de un sistema LLM no son assertions: son **datasets**. Este ADR fija el formato de dataset y el
|
|
9
|
+
motor de evaluación determinista que todos los consumidores usarán desde la semana 5.
|
|
10
|
+
|
|
11
|
+
## 1. Dataset: formato de archivo (JSONL)
|
|
12
|
+
|
|
13
|
+
```jsonl
|
|
14
|
+
{"prompt_id": "…", "prompt_version": "0.1.0", "input": {"…": "…"},
|
|
15
|
+
"expected": <sólido esperado o texto>, "criteria": {"type": "…", "schema": "…", "note": "…"}}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- Una línea = un caso (`EvalCase`). Un archivo = un prompt (`EvalDataset.from_jsonl`).
|
|
19
|
+
- Todos los casos de un dataset comparten `prompt_id` y `prompt_version` (error si se mezclan).
|
|
20
|
+
- `input` son las variables que el renderer del consumidor formatea.
|
|
21
|
+
- `expected` depende del criterio (ver §2).
|
|
22
|
+
- Archivos viven en `evals/` del consumidor y se referencian desde el `frontmatter.eval` del prompt
|
|
23
|
+
(ya exigido por `check_consumers.py`).
|
|
24
|
+
|
|
25
|
+
## 2. Criterios de evaluación (v0.1 — deterministas)
|
|
26
|
+
|
|
27
|
+
| type | `expected` | pasa si |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `deterministic_match` | texto | la salida normalizada (espacios + opcional `ignore_case`) es idéntica |
|
|
30
|
+
| `json_match` | dict o list | el JSON parseado de la salida es `==` (con/sin fenced ```json```) |
|
|
31
|
+
| `schema_match` | dict (opcional) | la salida valida contra el schema (`schema:` en el criterio) **y** si hay `expected`, el objeto parseado coincide |
|
|
32
|
+
|
|
33
|
+
Los tres son puros: mismo input → mismo veredicto. Costo de ejecución ≈ 0 en CI porque corren sobre
|
|
34
|
+
cassettes de `ReplayProvider` (ADR-1, D4) — el build nunca golpea APIs vivas.
|
|
35
|
+
|
|
36
|
+
## 3. Runner y umbral (`test_kit.run`)
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
run(dataset, judge, *, mode="smoke", threshold=1.0) -> EvalReport
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `judge: EvalCase -> CompletionResult` lo cablea el **consumidor** (LlmClient + su renderer + replay o record).
|
|
43
|
+
El runner es agnóstico del transporte: no sabe si ejecuta replay o record.
|
|
44
|
+
- `mode`: `smoke` (diario, barato, dataset corto) vs `full` (releases, cambios de prompt/modelo). §8.1.
|
|
45
|
+
- `threshold`: fracción [0,1] de casos que deben pasar. `EvalReport.threshold_ok` rompe el CI si no se llega.
|
|
46
|
+
- `EvalReport` agrega: total/passed/pass_rate, `cost_usd` (suma de los resultados), y un caso por ítem.
|
|
47
|
+
|
|
48
|
+
## 4. Horizontalidad y dependencias
|
|
49
|
+
|
|
50
|
+
- `test-kit` depende de `llm-client` (para `CompletionResult`) y de `schema-validate` (para `extract_json`).
|
|
51
|
+
**Jamás al revés** (ARCHITECTURE:214): el cliente no sabe qué es un dataset.
|
|
52
|
+
- La calibración (D7) NO está en este contrato: un criterio no determinista (LLM-as-judge) necesitaría
|
|
53
|
+
~20 casos de ground truth etiquetados a mano para detectar bias. Queda como hook futuro (`criterion.type`
|
|
54
|
+
se extiende sin romper el resto); el `LLM_JUDGE` se documenta como pendiente, no se implementa esta semana.
|
|
55
|
+
- El *formato de cassette* que ADR-1 delega en test-kit es el que ya existe en `ReplayProvider`: este módulo
|
|
56
|
+
no reimplementa el formato, lo consume.
|
|
57
|
+
|
|
58
|
+
## 5. Qué NO contrata
|
|
59
|
+
|
|
60
|
+
- No decide el umbral por proyecto (eso es del `core-consumer.yml`, quemado en CI por cada consumidor).
|
|
61
|
+
- No define qué prompt tiene dataset: `check_consumers.py` obliga que el `frontmatter.eval` exista (sem. 5+).
|
|
62
|
+
- No es un runner de CI: `ci-pack` (sem. 7) conectará `run()` a jobs. Aquí ya está el primitivo.
|
|
63
|
+
|
|
64
|
+
## 6. Criterios de aceptación (semana 5)
|
|
65
|
+
|
|
66
|
+
- [x] `EvalDataset.from_jsonl` valida JSON, rechaza casos mal formados y evita mezclar prompt_ids.
|
|
67
|
+
- [x] `deterministic_match` y `json_match` pasan con fenced JSON y normalización de espacios/caso.
|
|
68
|
+
- [x] `schema_match` valida contra un modelo del `SchemaRegistry` y reporta errores campo a campo.
|
|
69
|
+
- [x] `run` calcula pass_rate, `threshold_ok` y suma `cost_usd`; distingue `smoke`/`full`.
|
|
70
|
+
- [x] Primer consumidor real: `bench-runner` (sem. 5) — su propio prompt nace con dataset en `evals/` y tapas reales.
|
|
71
|
+
- [x] El dataset de `bench-runner` combina los tres criterios: `schema_match` + `json_match` + `deterministic_match`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Métricas
|
|
2
|
+
|
|
3
|
+
Datos crudos y agregados de la página de evidencia (§10.1).
|
|
4
|
+
|
|
5
|
+
Estado actual: sin datos. Semanas 1–5 tienen consumidores (`commit-cli`, `release-scribe`, `sec-check`,
|
|
6
|
+
`bench-runner`) pero aún no hay llamadas agregadas ni corriendo en CI. El andamiaje
|
|
7
|
+
(`scripts/collect_metrics.py`) se implementa en la semana 6 con `ci-pack`; a partir de ahí
|
|
8
|
+
`docs/metrics/` se regenera en cada merge.
|
{llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/registry.py
RENAMED
|
@@ -28,6 +28,8 @@ class SchemaRegistry:
|
|
|
28
28
|
model = self.get(schema_id)
|
|
29
29
|
|
|
30
30
|
def validator(text: str, schema: str | None = None) -> ValidationResult:
|
|
31
|
+
if schema is None:
|
|
32
|
+
return ValidationResult(ok=True, errors=[], parsed=None)
|
|
31
33
|
return validate_text(model, text)
|
|
32
34
|
|
|
33
35
|
return validator
|
{llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/tests/test_schema_validate.py
RENAMED
|
@@ -78,7 +78,7 @@ def test_registry_make_validator_via_factory() -> None:
|
|
|
78
78
|
def test_default_registry_register() -> None:
|
|
79
79
|
register("legacy", ReleaseNotes)
|
|
80
80
|
validator = make_validator("legacy")
|
|
81
|
-
assert validator("x").ok is False
|
|
81
|
+
assert validator("x", "legacy").ok is False
|
|
82
82
|
|
|
83
83
|
|
|
84
84
|
class SequenceProvider:
|
|
@@ -161,4 +161,17 @@ def test_llm_client_validation_failed_after_cap() -> None:
|
|
|
161
161
|
span = spans[0]
|
|
162
162
|
assert span.status == "failed"
|
|
163
163
|
assert span.repaired_attempts == 2
|
|
164
|
-
assert span.error_type == "validation"
|
|
164
|
+
assert span.error_type == "validation"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_validator_skips_when_schema_is_none() -> None:
|
|
168
|
+
"""Contrato validar-por-contrato: sin schema, el validador no repara (modo texto)."""
|
|
169
|
+
registry = SchemaRegistry()
|
|
170
|
+
registry.register("release-notes-v1", ReleaseNotes)
|
|
171
|
+
validator = registry.make_validator("release-notes-v1")
|
|
172
|
+
|
|
173
|
+
result = validator("una linea de texto sin json", None)
|
|
174
|
+
|
|
175
|
+
assert result.ok is True
|
|
176
|
+
assert result.errors == []
|
|
177
|
+
assert result.parsed is None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "llm-dev-secure-base"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "secure-base: secretos y datos sensibles nunca entran en prompts sin redacción (§3.1)."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"pydantic>=2.7",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["hatchling"]
|
|
12
|
+
build-backend = "hatchling.build"
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/secure_base"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .findings import Finding
|
|
2
|
+
from .profile import DATA_TYPE_NAMES, SecurityProfile, assert_redacted
|
|
3
|
+
from .redact import RedactResult, detect, redact, sanitize_for_prompt
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"DATA_TYPE_NAMES",
|
|
7
|
+
"Finding",
|
|
8
|
+
"RedactResult",
|
|
9
|
+
"SecurityProfile",
|
|
10
|
+
"assert_redacted",
|
|
11
|
+
"detect",
|
|
12
|
+
"redact",
|
|
13
|
+
"sanitize_for_prompt",
|
|
14
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .findings import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _preview(raw: str, limit: int = 24) -> str:
|
|
10
|
+
match = raw[:limit]
|
|
11
|
+
return match + "…" if len(raw) > limit else match
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Detector:
|
|
16
|
+
type: str
|
|
17
|
+
pattern: re.Pattern[str]
|
|
18
|
+
|
|
19
|
+
def scan(self, text: str) -> list[Finding]:
|
|
20
|
+
findings: list[Finding] = []
|
|
21
|
+
for m in self.pattern.finditer(text):
|
|
22
|
+
raw = m.group(0)
|
|
23
|
+
findings.append(
|
|
24
|
+
Finding(
|
|
25
|
+
type=self.type,
|
|
26
|
+
match=_preview(raw),
|
|
27
|
+
offset=m.start(),
|
|
28
|
+
length=len(raw),
|
|
29
|
+
line=text.count("\n", 0, m.start()) + 1,
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return findings
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DETECTORS: tuple[Detector, ...] = (
|
|
36
|
+
Detector("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
|
|
37
|
+
Detector("phone", re.compile(r"(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")),
|
|
38
|
+
Detector("ipv4", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
|
39
|
+
Detector("credit_card", re.compile(r"\b(?:\d{4}[ -]?){3}\d{4}\b")),
|
|
40
|
+
Detector("aws_access_key", re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")),
|
|
41
|
+
Detector("github_token", re.compile(r"\b(?:ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}\b")),
|
|
42
|
+
Detector("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----")),
|
|
43
|
+
Detector("bearer", re.compile(r"\bBearer [A-Za-z0-9._~+/-]+=*\b")),
|
|
44
|
+
Detector("url_userinfo", re.compile(r"(?i)\b\w+://[\w.-]+:[^@\s]+@")),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scan(text: str) -> list[Finding]:
|
|
49
|
+
findings: list[Finding] = []
|
|
50
|
+
for detector in DETECTORS:
|
|
51
|
+
findings.extend(detector.scan(text))
|
|
52
|
+
return sorted(findings, key=lambda f: (f.offset, f.type))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from .detectors import scan
|
|
6
|
+
from .findings import Finding
|
|
7
|
+
|
|
8
|
+
DATA_TYPE_NAMES = ("email", "phone", "ipv4", "credit_card", "aws_access_key", "github_token", "private_key", "bearer", "url_userinfo")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecurityProfile(BaseModel):
|
|
12
|
+
"""Qué tipos de datos declara manejar el consumidor (§3.1 y futuro lint de ci-pack)."""
|
|
13
|
+
|
|
14
|
+
data_types: list[str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def assert_redacted(text: str, profile: SecurityProfile | None = None) -> list[Finding]:
|
|
18
|
+
"""Devuelve hallazgos que siguen en claro en `text`.
|
|
19
|
+
|
|
20
|
+
Si el texto se va a enviar a un proveedor externo, un resultado no vacío es una violación.
|
|
21
|
+
Con `profile`, filtra a los tipos declarados por el consumidor.
|
|
22
|
+
"""
|
|
23
|
+
findings = scan(text)
|
|
24
|
+
if profile is None:
|
|
25
|
+
return findings
|
|
26
|
+
declared = set(profile.data_types)
|
|
27
|
+
return [f for f in findings if f.type in declared]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from .detectors import scan
|
|
6
|
+
from .findings import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RedactResult(BaseModel):
|
|
10
|
+
text: str
|
|
11
|
+
findings: list[Finding]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def detect(text: str) -> list[Finding]:
|
|
15
|
+
"""Detecta secretos/PII sin redactar. Alimenta reportes y lints."""
|
|
16
|
+
return scan(text)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def redact(text: str) -> RedactResult:
|
|
20
|
+
"""Enmascara de forma unidireccional y determinista: el secreto crudo no queda en el texto."""
|
|
21
|
+
findings = scan(text)
|
|
22
|
+
counters: dict[str, int] = {}
|
|
23
|
+
pieces: list[str] = []
|
|
24
|
+
cursor = 0
|
|
25
|
+
for f in findings:
|
|
26
|
+
pieces.append(text[cursor : f.offset])
|
|
27
|
+
counters[f.type] = counters.get(f.type, 0) + 1
|
|
28
|
+
pieces.append(f"[REDACTED:{f.type}:{counters[f.type]}]")
|
|
29
|
+
cursor = f.offset + f.length
|
|
30
|
+
pieces.append(text[cursor:])
|
|
31
|
+
return RedactResult(text="".join(pieces), findings=findings)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sanitize_for_prompt(text: str) -> RedactResult:
|
|
35
|
+
"""Alias semántico para el renderer: nada sensible entra al prompt."""
|
|
36
|
+
return redact(text)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from secure_base import SecurityProfile, assert_redacted, detect, redact, sanitize_for_prompt
|
|
4
|
+
|
|
5
|
+
SAMPLE = """prueba con persona@ejemplo.com y también conta@otra.co
|
|
6
|
+
token ghp_AbCdEfGhIjKlMnOpQrStUvWxYz123456
|
|
7
|
+
aws AKIAIOSFODNN7EXAMPLE en plena línea
|
|
8
|
+
privada:
|
|
9
|
+
-----BEGIN RSA PRIVATE KEY-----
|
|
10
|
+
incluye https://usuario:pass@host.com/path
|
|
11
|
+
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc
|
|
12
|
+
tel +57 300 123 4567 y tarjeta 4111 1111 1111 1111
|
|
13
|
+
ip 192.168.0.1 cliente"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_detect_types_positive() -> None:
|
|
17
|
+
findings = detect(SAMPLE)
|
|
18
|
+
types = {f.type for f in findings}
|
|
19
|
+
assert {
|
|
20
|
+
"email",
|
|
21
|
+
"github_token",
|
|
22
|
+
"aws_access_key",
|
|
23
|
+
"private_key",
|
|
24
|
+
"url_userinfo",
|
|
25
|
+
"bearer",
|
|
26
|
+
"phone",
|
|
27
|
+
"credit_card",
|
|
28
|
+
"ipv4",
|
|
29
|
+
} == types
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_no_false_positive_on_plain_text() -> None:
|
|
33
|
+
findings = detect("solo código normal sin secretos: return None")
|
|
34
|
+
assert findings == []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_redact_is_one_way_and_deterministic() -> None:
|
|
38
|
+
result = redact(SAMPLE)
|
|
39
|
+
assert "persona@ejemplo.com" not in result.text
|
|
40
|
+
assert "ghp_" not in result.text
|
|
41
|
+
assert "AKIA" not in result.text
|
|
42
|
+
assert "usuario:pass" not in result.text
|
|
43
|
+
assert "Bearer eyJ" not in result.text
|
|
44
|
+
second = redact(SAMPLE)
|
|
45
|
+
assert result.text == second.text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_redact_keeps_placeholders_and_structure() -> None:
|
|
49
|
+
result = redact(SAMPLE)
|
|
50
|
+
assert "[REDACTED:email:1]" in result.text
|
|
51
|
+
assert "[REDACTED:private_key:1]" in result.text
|
|
52
|
+
assert result.findings
|
|
53
|
+
assert result.findings[0].line >= 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_sanitize_for_prompt_never_leaves_raw_secrets() -> None:
|
|
57
|
+
clean = sanitize_for_prompt(SAMPLE)
|
|
58
|
+
assert assert_redacted(clean.text) == []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_assert_redacted_filters_by_profile() -> None:
|
|
62
|
+
profile = SecurityProfile(data_types=["email"])
|
|
63
|
+
findings = assert_redacted(SAMPLE, profile)
|
|
64
|
+
assert findings and all(f.type == "email" for f in findings)
|
|
65
|
+
|
|
66
|
+
ok = assert_redacted(redact(SAMPLE).text, profile)
|
|
67
|
+
assert ok == []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_detect_reports_offset_and_line() -> None:
|
|
71
|
+
findings = detect(SAMPLE)
|
|
72
|
+
email = next(f for f in findings if f.type == "email")
|
|
73
|
+
assert email.offset == SAMPLE.find("persona@ejemplo.com")
|
|
74
|
+
assert SAMPLE.count("\n", 0, email.offset) + 1 == 1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "llm-dev-test-kit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "test-kit: en desarrollo LLM, los tests son datasets (D7, semana 5)."
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"llm-dev-client",
|
|
8
|
+
"llm-dev-schema-validate",
|
|
9
|
+
"pydantic>=2.7",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[tool.uv.sources]
|
|
13
|
+
llm-dev-client = { workspace = true }
|
|
14
|
+
llm-dev-schema-validate = { workspace = true }
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/test_kit"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .criteria import CriterionResult, deterministic_match, json_match, schema_match
|
|
2
|
+
from .dataset import Criterion, DatasetError, EvalCase, EvalDataset
|
|
3
|
+
from .runner import CaseResult, EvalReport, run
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"CaseResult",
|
|
7
|
+
"Criterion",
|
|
8
|
+
"CriterionResult",
|
|
9
|
+
"DatasetError",
|
|
10
|
+
"EvalCase",
|
|
11
|
+
"EvalDataset",
|
|
12
|
+
"EvalReport",
|
|
13
|
+
"deterministic_match",
|
|
14
|
+
"json_match",
|
|
15
|
+
"run",
|
|
16
|
+
"schema_match",
|
|
17
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from llm_client import ValidationResult
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from schema_validate import extract_json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CriterionResult(BaseModel):
|
|
12
|
+
passed: bool
|
|
13
|
+
detail: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize(text: str, *, ignore_case: bool = False) -> str:
|
|
17
|
+
"""Colapsa espacios y normaliza el caso para comparaciones deterministas."""
|
|
18
|
+
collapsed = " ".join(re.split(r"\s+", str(text).strip()))
|
|
19
|
+
return collapsed.lower() if ignore_case else collapsed
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def deterministic_match(expected, raw_text: str, *, ignore_case: bool = False) -> CriterionResult:
|
|
23
|
+
exp = normalize(expected, ignore_case=ignore_case)
|
|
24
|
+
got = normalize(raw_text, ignore_case=ignore_case)
|
|
25
|
+
return CriterionResult(
|
|
26
|
+
passed=exp == got,
|
|
27
|
+
detail="" if exp == got else f"esperado {exp!r} ≠ salida {got!r}",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def json_match(expected, raw_text: str) -> CriterionResult:
|
|
32
|
+
try:
|
|
33
|
+
got = extract_json(raw_text)
|
|
34
|
+
except (ValueError, TypeError) as exc:
|
|
35
|
+
return CriterionResult(passed=False, detail=f"JSON inválido: {exc}")
|
|
36
|
+
passed = got == expected
|
|
37
|
+
return CriterionResult(
|
|
38
|
+
passed=passed,
|
|
39
|
+
detail="" if passed else f"JSON ≠ esperado (salida={got!r})",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def schema_match(expected, raw_text: str, validation: ValidationResult) -> CriterionResult:
|
|
44
|
+
if not validation.ok:
|
|
45
|
+
return CriterionResult(passed=False, detail="; ".join(validation.errors))
|
|
46
|
+
if expected is not None:
|
|
47
|
+
parsed = validation.parsed
|
|
48
|
+
passed = _deep_equal(parsed, expected)
|
|
49
|
+
return CriterionResult(
|
|
50
|
+
passed=passed,
|
|
51
|
+
detail="" if passed else f"estructura válida pero ≠ expected ({parsed!r})",
|
|
52
|
+
)
|
|
53
|
+
return CriterionResult(passed=True, detail="")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _deep_equal(a, b) -> bool:
|
|
57
|
+
if isinstance(a, BaseModel):
|
|
58
|
+
a = a.model_dump()
|
|
59
|
+
if isinstance(b, BaseModel):
|
|
60
|
+
b = b.model_dump()
|
|
61
|
+
return a == b
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DatasetError(ValueError):
|
|
11
|
+
"""Dataset de eval mal formado: JSON inválido, campos faltantes o prompt_ids mezclados."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Criterion(BaseModel):
|
|
15
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
16
|
+
|
|
17
|
+
type: Literal["deterministic_match", "json_match", "schema_match"]
|
|
18
|
+
schema_id: str | None = Field(default=None, validation_alias="schema", serialization_alias="schema")
|
|
19
|
+
ignore_case: bool = False
|
|
20
|
+
note: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EvalCase(BaseModel):
|
|
24
|
+
prompt_id: str
|
|
25
|
+
prompt_version: str
|
|
26
|
+
input: dict[str, Any]
|
|
27
|
+
expected: Any = None
|
|
28
|
+
criteria: Criterion
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EvalDataset(BaseModel):
|
|
32
|
+
prompt_id: str
|
|
33
|
+
prompt_version: str
|
|
34
|
+
cases: list[EvalCase] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_jsonl(cls, path: str | Path) -> "EvalDataset":
|
|
38
|
+
p = Path(path)
|
|
39
|
+
try:
|
|
40
|
+
lines = p.read_text().splitlines()
|
|
41
|
+
except FileNotFoundError as exc:
|
|
42
|
+
raise DatasetError(f"{p}: fichero no encontrado") from exc
|
|
43
|
+
cases: list[EvalCase] = []
|
|
44
|
+
for lineno, line in enumerate(lines, start=1):
|
|
45
|
+
if not line.strip():
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
raw = json.loads(line)
|
|
49
|
+
except json.JSONDecodeError as exc:
|
|
50
|
+
raise DatasetError(f"{p}:{lineno}: JSON inválido: {exc}") from exc
|
|
51
|
+
cases.append(EvalCase.model_validate(raw))
|
|
52
|
+
|
|
53
|
+
if not cases:
|
|
54
|
+
raise DatasetError(f"{p}: dataset vacío")
|
|
55
|
+
|
|
56
|
+
first = cases[0]
|
|
57
|
+
for case in cases[1:]:
|
|
58
|
+
if (case.prompt_id, case.prompt_version) != (first.prompt_id, first.prompt_version):
|
|
59
|
+
raise DatasetError(
|
|
60
|
+
f"{p}: prompt_id/version mezclados (esperaba {first.prompt_id}@{first.prompt_version})"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return cls(prompt_id=first.prompt_id, prompt_version=first.prompt_version, cases=cases)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
|
|
6
|
+
from llm_client import CompletionResult, ValidationResult
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from .criteria import CriterionResult, deterministic_match, json_match, schema_match
|
|
10
|
+
from .dataset import EvalCase, EvalDataset
|
|
11
|
+
|
|
12
|
+
Judge = Callable[[EvalCase], CompletionResult]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CaseResult(BaseModel):
|
|
16
|
+
index: int
|
|
17
|
+
passed: bool
|
|
18
|
+
detail: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EvalReport(BaseModel):
|
|
22
|
+
prompt_id: str
|
|
23
|
+
prompt_version: str
|
|
24
|
+
mode: str
|
|
25
|
+
threshold: float
|
|
26
|
+
total: int
|
|
27
|
+
passed: int
|
|
28
|
+
pass_rate: float
|
|
29
|
+
threshold_ok: bool
|
|
30
|
+
cost_usd: Decimal = Field(default_factory=Decimal)
|
|
31
|
+
cases: list[CaseResult] = Field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _apply(case: EvalCase, result: CompletionResult) -> tuple[CriterionResult, str]:
|
|
35
|
+
"""Devuelve el resultado del criterio y la nota del caso.
|
|
36
|
+
|
|
37
|
+
Segundo elemento: nota del criterio (si existe), que alimenta el reporte.
|
|
38
|
+
"""
|
|
39
|
+
criteria = case.criteria
|
|
40
|
+
note = criteria.note or ""
|
|
41
|
+
if criteria.type == "deterministic_match":
|
|
42
|
+
return deterministic_match(case.expected, result.raw_text, ignore_case=criteria.ignore_case), note
|
|
43
|
+
if criteria.type == "json_match":
|
|
44
|
+
return json_match(case.expected, result.raw_text), note
|
|
45
|
+
if criteria.type == "schema_match":
|
|
46
|
+
return schema_match(case.expected, result.raw_text, result.validation), note
|
|
47
|
+
raise ValueError(f"criteria desconocido: {criteria.type}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run(
|
|
51
|
+
dataset: EvalDataset,
|
|
52
|
+
judge: Judge,
|
|
53
|
+
*,
|
|
54
|
+
mode: str = "smoke",
|
|
55
|
+
threshold: float = 1.0,
|
|
56
|
+
) -> EvalReport:
|
|
57
|
+
"""Ejecuta un dataset de eval sobre el juez dado (cableado por el consumidor).
|
|
58
|
+
|
|
59
|
+
- `mode`: 'smoke' (diario, barato) o 'full' (releases, cambios de prompt/modelo).
|
|
60
|
+
El juez decide si usa replay (CI, determinista) o graba (record); el runner es agnóstico.
|
|
61
|
+
- `threshold`: fracción [0, 1] de casos que deben pasar para que el eval esté OK.
|
|
62
|
+
"""
|
|
63
|
+
cases: list[CaseResult] = []
|
|
64
|
+
passed = 0
|
|
65
|
+
cost = Decimal("0")
|
|
66
|
+
|
|
67
|
+
for index, case in enumerate(dataset.cases, start=1):
|
|
68
|
+
result = judge(case)
|
|
69
|
+
cost += result.cost_usd
|
|
70
|
+
criterion, note = _apply(case, result)
|
|
71
|
+
ok = criterion.passed
|
|
72
|
+
cases.append(
|
|
73
|
+
CaseResult(
|
|
74
|
+
index=index,
|
|
75
|
+
passed=ok,
|
|
76
|
+
detail=note or criterion.detail,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
if ok:
|
|
80
|
+
passed += 1
|
|
81
|
+
|
|
82
|
+
total = len(dataset.cases)
|
|
83
|
+
pass_rate = passed / total if total else 0.0
|
|
84
|
+
return EvalReport(
|
|
85
|
+
prompt_id=dataset.prompt_id,
|
|
86
|
+
prompt_version=dataset.prompt_version,
|
|
87
|
+
mode=mode,
|
|
88
|
+
threshold=threshold,
|
|
89
|
+
total=total,
|
|
90
|
+
passed=passed,
|
|
91
|
+
pass_rate=pass_rate,
|
|
92
|
+
threshold_ok=pass_rate >= threshold,
|
|
93
|
+
cost_usd=cost,
|
|
94
|
+
cases=cases,
|
|
95
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from llm_client import CompletionResult, TokenUsage, ValidationResult
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from test_kit import (
|
|
9
|
+
DatasetError,
|
|
10
|
+
EvalCase,
|
|
11
|
+
EvalDataset,
|
|
12
|
+
EvalReport,
|
|
13
|
+
deterministic_match,
|
|
14
|
+
json_match,
|
|
15
|
+
run,
|
|
16
|
+
schema_match,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
SCHEMA_ID = "eval-demo-v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Demo(BaseModel):
|
|
23
|
+
summary: str
|
|
24
|
+
ok: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _result(raw_text: str, *, cost: Decimal = Decimal("0"), validation: ValidationResult | None = None) -> CompletionResult:
|
|
28
|
+
return CompletionResult(
|
|
29
|
+
raw_text=raw_text,
|
|
30
|
+
parsed=Demo.model_validate(raw_text) if validation and validation.ok else None,
|
|
31
|
+
validation=validation or ValidationResult(ok=True),
|
|
32
|
+
usage=TokenUsage(input_tokens=1, output_tokens=1),
|
|
33
|
+
cost_usd=cost,
|
|
34
|
+
latency_ms=1,
|
|
35
|
+
prompt_id="x",
|
|
36
|
+
prompt_version="0.1.0",
|
|
37
|
+
model_alias="fast",
|
|
38
|
+
model="demo",
|
|
39
|
+
provider="stub",
|
|
40
|
+
span_id="s",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _case(criteria: dict, *, expected=None) -> EvalCase:
|
|
45
|
+
return EvalCase(
|
|
46
|
+
prompt_id="prompt-demo",
|
|
47
|
+
prompt_version="0.1.0",
|
|
48
|
+
input={},
|
|
49
|
+
expected=expected,
|
|
50
|
+
criteria=criteria,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _dataset(tmp_path, lines: list[str]) -> EvalDataset:
|
|
55
|
+
path = tmp_path / "cases.jsonl"
|
|
56
|
+
path.write_text("\n".join(lines) + "\n")
|
|
57
|
+
return EvalDataset.from_jsonl(path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_from_jsonl_ok(tmp_path) -> None:
|
|
61
|
+
ds = _dataset(
|
|
62
|
+
tmp_path,
|
|
63
|
+
[
|
|
64
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"expected":"feat: x","criteria":{"type":"deterministic_match"}}',
|
|
65
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"expected":"feat: y","criteria":{"type":"deterministic_match"}}',
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
assert ds.prompt_id == "p"
|
|
69
|
+
assert len(ds.cases) == 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_from_jsonl_malformed(tmp_path) -> None:
|
|
73
|
+
path = tmp_path / "cases.jsonl"
|
|
74
|
+
path.write_text('{"prompt_id": broken\n')
|
|
75
|
+
with pytest.raises(DatasetError, match="JSON inválido"):
|
|
76
|
+
EvalDataset.from_jsonl(path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_from_jsonl_mixed_prompts(tmp_path) -> None:
|
|
80
|
+
with pytest.raises(DatasetError, match="mezclados"):
|
|
81
|
+
_dataset(
|
|
82
|
+
tmp_path,
|
|
83
|
+
[
|
|
84
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"criteria":{"type":"deterministic_match"}}',
|
|
85
|
+
'{"prompt_id":"q","prompt_version":"0.1.0","input":{},"criteria":{"type":"deterministic_match"}}',
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_from_jsonl_empty(tmp_path) -> None:
|
|
91
|
+
with pytest.raises(DatasetError, match="vacío"):
|
|
92
|
+
_dataset(tmp_path, [])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_from_jsonl_missing_file(tmp_path) -> None:
|
|
96
|
+
with pytest.raises(DatasetError, match="no encontrado"):
|
|
97
|
+
EvalDataset.from_jsonl(tmp_path / "no-existe.jsonl")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_deterministic_match_normalizes() -> None:
|
|
101
|
+
ok = deterministic_match("feat: add retry", " feat: add retry ")
|
|
102
|
+
assert ok.passed is True
|
|
103
|
+
case_sensitive = deterministic_match("Feat: x", "feat: x")
|
|
104
|
+
assert case_sensitive.passed is False
|
|
105
|
+
ignore = deterministic_match("Feat: x", "feat: x", ignore_case=True)
|
|
106
|
+
assert ignore.passed is True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_json_match_handles_fences() -> None:
|
|
110
|
+
result = json_match({"a": 1}, "responde con:\n```json\n{\"a\": 1}\n```\n")
|
|
111
|
+
assert result.passed is True
|
|
112
|
+
wrong = json_match({"a": 1}, "```json\n{\"a\": 2}\n```")
|
|
113
|
+
assert wrong.passed is False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_schema_match_valid_against_expected() -> None:
|
|
117
|
+
from test_kit.criteria import schema_match as sm
|
|
118
|
+
|
|
119
|
+
validation = ValidationResult(ok=True, parsed=Demo(summary="todo bien", ok=True))
|
|
120
|
+
result = sm({"summary": "todo bien", "ok": True}, '{"summary": "todo bien", "ok": true}', validation)
|
|
121
|
+
assert result.passed is True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_schema_match_reports_errors() -> None:
|
|
125
|
+
from test_kit.criteria import schema_match as sm
|
|
126
|
+
|
|
127
|
+
validation = ValidationResult(ok=False, errors=["summary: campo faltante"])
|
|
128
|
+
result = sm(None, '{"ok": true}', validation)
|
|
129
|
+
assert result.passed is False
|
|
130
|
+
assert "campo faltante" in result.detail
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_run_pass_threshold_and_cost(tmp_path) -> None:
|
|
134
|
+
ds = _dataset(
|
|
135
|
+
tmp_path,
|
|
136
|
+
[
|
|
137
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"expected":"feat: x","criteria":{"type":"deterministic_match"}}',
|
|
138
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"expected":"feat: y","criteria":{"type":"deterministic_match"}}',
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def judge(case: EvalCase) -> CompletionResult:
|
|
143
|
+
return _result(case.expected, cost=Decimal("0.01"))
|
|
144
|
+
|
|
145
|
+
report = run(ds, judge, mode="smoke", threshold=1.0)
|
|
146
|
+
assert isinstance(report, EvalReport)
|
|
147
|
+
assert report.total == 2
|
|
148
|
+
assert report.passed == 2
|
|
149
|
+
assert report.pass_rate == 1.0
|
|
150
|
+
assert report.threshold_ok is True
|
|
151
|
+
assert report.cost_usd == Decimal("0.02")
|
|
152
|
+
assert report.mode == "smoke"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_run_fails_below_threshold(tmp_path) -> None:
|
|
156
|
+
ds = _dataset(
|
|
157
|
+
tmp_path,
|
|
158
|
+
[
|
|
159
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"expected":"feat: x","criteria":{"type":"deterministic_match"}}',
|
|
160
|
+
'{"prompt_id":"p","prompt_version":"0.1.0","input":{},"expected":"feat: y","criteria":{"type":"deterministic_match"}}',
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def judge(case: EvalCase) -> CompletionResult:
|
|
165
|
+
return _result("respuesta incorrecta")
|
|
166
|
+
|
|
167
|
+
report = run(ds, judge, mode="full", threshold=1.0)
|
|
168
|
+
assert report.passed == 0
|
|
169
|
+
assert report.threshold_ok is False
|
|
170
|
+
assert report.pass_rate == 0.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "llm-dev-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Núcleo versionado del que dependen 52 proyectos en 52 semanas: un solo sistema acumulativo."
|
|
5
5
|
authors = [{ name = "binahco", email = "binahco.sas@gmail.com" }]
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -18,6 +18,8 @@ build-backend = "hatchling.build"
|
|
|
18
18
|
packages = [
|
|
19
19
|
"packages/llm-client/src/llm_client",
|
|
20
20
|
"packages/schema-validate/src/schema_validate",
|
|
21
|
+
"packages/secure-base/src/secure_base",
|
|
22
|
+
"packages/test-kit/src/test_kit",
|
|
21
23
|
]
|
|
22
24
|
|
|
23
25
|
[tool.uv.workspace]
|
llm_dev_core-0.3.0/CHANGELOG.md
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
La versión es la del paquete `llm-dev-core`. El historial comienza con **v1.0 (semana 13)** — hasta entonces el core es `0.x` y puede romperse sin aviso (D3).
|
|
4
|
-
|
|
5
|
-
## v0.3 — 2026-09-21 (schema-validate seed + dist unificada)
|
|
6
|
-
|
|
7
|
-
- Semana 3: nace `packages/schema-validate` v0.1.0 dentro de un consumidor real (`release-scribe`).
|
|
8
|
-
- Contrato ADR-2: validación Pydantic de la salida LLM (JSON con/sin caretas, errores campo a campo),
|
|
9
|
-
registro `SchemaId → modelo`, `parsed` poblando `CompletionResult.parsed`.
|
|
10
|
-
- Reparación: el lazo/presupuesto/cap sigue en `llm-client` (hoja); `schema-validate` alimenta los
|
|
11
|
-
errores vía el hook `validator`. Ajuste menor ADR-1: `ValidationResult.parsed`.
|
|
12
|
-
- Tape real grabado de `opencode run` sin API key (replica validada con `parsed`).
|
|
13
|
-
- **Empaquetado unificado (D1):** el core pasa a publicarse como una sola dist `llm-dev-core` (0.3.0)
|
|
14
|
-
con `llm_client` y `schema_validate` top-level; los consumidores dependen de `llm-dev-core ^0.3`, nunca
|
|
15
|
-
de módulos sueltos. `commit-cli` migra a la dist unificada.
|
|
16
|
-
- Segundo consumidor: `Proyectos/release-scribe` (sem. 3) — release notes JSON validadas desde `git log`;
|
|
17
|
-
reutiliza `llm-client` + `schema-validate`.
|
|
18
|
-
- Calendario armonizado (§5 vs. §10): `schema-validate` (3), `secure-base` (4), `test-kit` (5),
|
|
19
|
-
`web-api-base` (6), `ci-pack` (7), `cache-ratelimit` (8), `bot-base` (9), … hasta `cost-obs` (39).
|
|
20
|
-
Deuda aprobada en el review: la sem-2 quería publicar ya en PyPI; al unificar el empaquetado, la primera
|
|
21
|
-
publicación real pasa a ser esta v0.3.
|
|
22
|
-
|
|
23
|
-
## v0.2 — 2026-09-20 (llm-client seed)
|
|
24
|
-
|
|
25
|
-
- Semana 2: nace `packages/llm-client` v0.1.0 dentro de un consumidor real (`commit-cli`).
|
|
26
|
-
- Contrato ADR-1 aceptada: `complete`/`stream`, retry+backoff+jitter, reparación con tope, cap de costo, cache, span de 20 campos.
|
|
27
|
-
- Record/replay nativo: cassettes sin API key (`ReplayProvider`); tape real grabado con el transport `opencode` (sesión local, sin key).
|
|
28
|
-
- Transportes: `opencode` (`opencode run --format json`) y `openai-compatible` (HTTP).
|
|
29
|
-
- Workspace uv activado (`members = ["packages/*"]`); grupo dev de pytest. `make validate` ahora también corre los tests.
|
|
30
|
-
- Primer consumidor: `Proyectos/commit-cli` (único módulo del core reutilizado a la fecha: `llm-client`). La métrica de reutilización real se medirá en §10.1 desde el corte de semanal.
|
|
31
|
-
|
|
32
|
-
## v0.1 — 2026-09-20 (seed)
|
|
33
|
-
|
|
34
|
-
- Semana 1: documento `ARCHITECTURE.md`, ADR-0 (stack Python 3.12+), contrato mínimo de `llm-client`, plantillas de consumidor.
|
|
35
|
-
- Enforcement base (§3): `scripts/check_consumers.py` implementado (modo `--self` y de consumidor), `make validate` y workflow `ci.yml`. Pendiente hasta sem. 6: lint anti-imports, evals en CI y página de evidencia (ci-pack).
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
# Métricas
|
|
2
|
-
|
|
3
|
-
Datos crudos y agregados de la página de evidencia (§10.1).
|
|
4
|
-
|
|
5
|
-
Estado actual: sin datos. Semanas 1–3 tienen consumidores (`commit-cli`, `release-scribe`) pero aún no hay
|
|
6
|
-
llamadas agregadas ni corriendo en CI. El andamiaje (`scripts/collect_metrics.py`) se implementa en la
|
|
7
|
-
semana 6 con `ci-pack`; a partir de ahí `docs/metrics/` se regenera en cada merge.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/scripts/record_tape_opencode.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/__init__.py
RENAMED
|
File without changes
|
{llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/validate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|