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.
Files changed (66) hide show
  1. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/ARCHITECTURE.md +8 -7
  2. llm_dev_core-0.5.0/CHANGELOG.md +83 -0
  3. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/PKG-INFO +1 -1
  4. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/README.md +3 -3
  5. llm_dev_core-0.5.0/docs/decisions/0003-secure-base-contract.md +66 -0
  6. llm_dev_core-0.5.0/docs/decisions/0004-test-kit-dataset-contract.md +71 -0
  7. llm_dev_core-0.5.0/docs/metrics/README.md +8 -0
  8. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/registry.py +2 -0
  9. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/tests/test_schema_validate.py +15 -2
  10. llm_dev_core-0.5.0/packages/secure-base/pyproject.toml +15 -0
  11. llm_dev_core-0.5.0/packages/secure-base/src/secure_base/__init__.py +14 -0
  12. llm_dev_core-0.5.0/packages/secure-base/src/secure_base/detectors.py +52 -0
  13. llm_dev_core-0.5.0/packages/secure-base/src/secure_base/findings.py +9 -0
  14. llm_dev_core-0.5.0/packages/secure-base/src/secure_base/profile.py +27 -0
  15. llm_dev_core-0.5.0/packages/secure-base/src/secure_base/redact.py +36 -0
  16. llm_dev_core-0.5.0/packages/secure-base/tests/test_secure_base.py +74 -0
  17. llm_dev_core-0.5.0/packages/test-kit/pyproject.toml +21 -0
  18. llm_dev_core-0.5.0/packages/test-kit/src/test_kit/__init__.py +17 -0
  19. llm_dev_core-0.5.0/packages/test-kit/src/test_kit/criteria.py +61 -0
  20. llm_dev_core-0.5.0/packages/test-kit/src/test_kit/dataset.py +63 -0
  21. llm_dev_core-0.5.0/packages/test-kit/src/test_kit/runner.py +95 -0
  22. llm_dev_core-0.5.0/packages/test-kit/tests/test_test_kit.py +170 -0
  23. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/pyproject.toml +3 -1
  24. llm_dev_core-0.3.0/CHANGELOG.md +0 -35
  25. llm_dev_core-0.3.0/docs/metrics/README.md +0 -7
  26. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.github/workflows/ci.yml +0 -0
  27. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.github/workflows/publish.yml +0 -0
  28. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.gitignore +0 -0
  29. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/.python-version +0 -0
  30. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/Makefile +0 -0
  31. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/decisions/0000-stack.md +0 -0
  32. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/decisions/0001-llm-client-contract.md +0 -0
  33. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/decisions/0002-schema-validate-contract.md +0 -0
  34. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/docs/runbooks/README.md +0 -0
  35. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/pyproject.toml +0 -0
  36. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/scripts/record_tape_opencode.py +0 -0
  37. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/__init__.py +0 -0
  38. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/cache.py +0 -0
  39. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/client.py +0 -0
  40. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/models.py +0 -0
  41. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/pricing.py +0 -0
  42. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/provider.py +0 -0
  43. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/__init__.py +0 -0
  44. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/openai_compat.py +0 -0
  45. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/providers/opencode_cli.py +0 -0
  46. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/replay.py +0 -0
  47. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/src/llm_client/span.py +0 -0
  48. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/__init__.py +0 -0
  49. {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
  50. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/conftest.py +0 -0
  51. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_openai_compat.py +0 -0
  52. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_pricing_cache.py +0 -0
  53. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_real_tape.py +0 -0
  54. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_replay.py +0 -0
  55. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/llm-client/tests/test_span.py +0 -0
  56. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/pyproject.toml +0 -0
  57. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/__init__.py +0 -0
  58. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/packages/schema-validate/src/schema_validate/validate.py +0 -0
  59. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/scripts/check_consumers.py +0 -0
  60. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/scripts/collect_metrics.py +0 -0
  61. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/scripts/compat_check.sh +0 -0
  62. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/consumer-readme.md +0 -0
  63. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/core-consumer.yml +0 -0
  64. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/eval-dataset.jsonl +0 -0
  65. {llm_dev_core-0.3.0 → llm_dev_core-0.5.0}/templates/prompt.md +0 -0
  66. {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 (seed revisado) · **Última revisión:** 2026-09-21
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.3.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.3
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`. Ambos 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.
345
- - **Empaquetado:** una sola dist `llm-dev-core`, publicable vía `uv build`/`uv publish` con `llm_client` y `schema_validate` top-level (D1). Pendiente la primera publicación real en PyPI (se cierra en la sem. 3 con v0.3).
346
- - **Enforcement:** ADR-0, ADR-1 (`llm-client-contract`) y ADR-2 (`schema-validate-contract`) en `docs/decisions/`, plantillas en `templates/`, `make validate` (estructura + tests) y `make validate-consumer CONSUMER=../<repo>`.
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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: llm-dev-core
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Núcleo versionado del que dependen 52 proyectos en 52 semanas: un solo sistema acumulativo.
5
5
  Author-email: binahco <binahco.sas@gmail.com>
6
6
  Requires-Python: >=3.12
@@ -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.3 · Semana 3
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 `schema_validate` top-level; los consumidores dependen de `llm-dev-core ^0.x`, nunca de módulos sueltos.
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 (aún ninguno hasta sem. 2)
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.
@@ -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
@@ -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,9 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Finding(BaseModel):
5
+ type: str
6
+ match: str
7
+ offset: int
8
+ length: int
9
+ line: int
@@ -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.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]
@@ -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