Skip to content

Commit 2a1fc28

Browse files
committed
feat(edgevision): intake de datasets reales con frontera predictiva hard-pinned
- migracion 059 edgevision_dataset_batches (metadata sin blobs, QA gates, attestation) - api/edgevision-datasets.ts admin-only (GET/POST/PATCH, rate limit, allowlist de campos) - seam api/_edgevision-baseline.ts disabled-by-default: umbrales de evidencia por capacidad, promoteCapabilityBaseline registra provenance y nunca activa nada; endpoint /api/edgevision-baseline responde 409 con explicacion de politica - seccion admin "Lotes de dataset EdgeVision" en /uni (solo rol admin) - contrato CI scripts/edgevision-qa-smoke.mjs pinea schema, API admin-only, seam inerte y predictiveBaselineAvailable:false (step 40 en quality.yml) - spec Playwright tests/edgevision-datasets.spec.ts (mock auth admin, flujo 409) - docs: appendix de promocion en SEAFOOD-GRADE-A.md, procedimiento de intake en UNIVISION_REAL_IMAGE_QA.md, verificacion (d) y conteo de contratos en RUNBOOK.md
1 parent 6f8a8cd commit 2a1fc28

14 files changed

Lines changed: 548 additions & 3 deletions

‎.github/workflows/quality.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ jobs:
112112
run: node scripts/public-health-smoke.mjs
113113
- name: Pilot telemetry contract
114114
run: node scripts/pilot-telemetry-smoke.mjs
115+
- name: EdgeVision QA gate
116+
run: node scripts/edgevision-qa-smoke.mjs
115117
- name: Endpoint SQL contracts (ephemeral Neon)
116118
env:
117119
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}

‎api/_edgevision-baseline.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// EdgeVision baseline promotion seam — DISABLED BY DEFAULT, hard policy boundary.
2+
//
3+
// "Promoting a validated dataset to baseline" means: an admin acknowledges a
4+
// QA-validated intake batch as the evidence foundation for ONE planned
5+
// capability. It records provenance only. It never enables predictive output:
6+
// the Grade A boundary (`predictiveBaselineAvailable: false` in
7+
// api/_operational-intelligence.ts) stays pinned until Pescamar executes
8+
// Gate 5 and Gate 7 of docs/SEAFOOD-GRADE-A.md with real sign-off.
9+
//
10+
// This module is the single code seam for that future step. The only caller
11+
// is the admin-gated api/edgevision-baseline.ts endpoint, which stays inert
12+
// (409 with the policy explanation) until a validated batch meeting the
13+
// evidence thresholds below exists for the requested capability.
14+
15+
import { getSql } from "./_db.js";
16+
17+
export const EDGE_VISION_BASELINE_CAPABILITIES = ["count", "calibre", "size", "defects", "biomass", "anomaly"] as const;
18+
export type EdgeVisionBaselineCapability = (typeof EDGE_VISION_BASELINE_CAPABILITIES)[number];
19+
20+
// Evidence thresholds grounded in the vision QA docs: real captures only,
21+
// human-confirmed labels, multiple capture days. Numbers are minimum intake
22+
// gates for CONSIDERING a baseline — never an accuracy claim.
23+
export type BaselineEvidenceRequirement = {
24+
capability: EdgeVisionBaselineCapability;
25+
minimumImages: number;
26+
minimumOperatorConfirmedLabels: number;
27+
minimumCaptureDays: number;
28+
rationale: string;
29+
};
30+
31+
export const baselineEvidenceRequirements: readonly BaselineEvidenceRequirement[] = [
32+
{ capability: "count", minimumImages: 300, minimumOperatorConfirmedLabels: 100, minimumCaptureDays: 3, rationale: "Conteo requiere suficientes ventanas de proceso con conteo humano de referencia para estimar error por escena." },
33+
{ capability: "calibre", minimumImages: 400, minimumOperatorConfirmedLabels: 150, minimumCaptureDays: 3, rationale: "Calibre exige cobertura por clase de calibre con etiqueta confirmada por operador, no sólo distribución agregada." },
34+
{ capability: "size", minimumImages: 300, minimumOperatorConfirmedLabels: 100, minimumCaptureDays: 3, rationale: "Tamaño dimensional necesita referencias de escala en escena y confirmación humana de las medidas usadas." },
35+
{ capability: "defects", minimumImages: 500, minimumOperatorConfirmedLabels: 200, minimumCaptureDays: 5, rationale: "Defectos necesita instancias positivas de cada taxonomía aprobada; la ausencia de defecto nunca se toma como aprobación." },
36+
{ capability: "biomass", minimumImages: 200, minimumOperatorConfirmedLabels: 80, minimumCaptureDays: 3, rationale: "Biomasa sólo es estimable con peso de referencia observado en la misma ventana de captura." },
37+
{ capability: "anomaly", minimumImages: 400, minimumOperatorConfirmedLabels: 120, minimumCaptureDays: 5, rationale: "Anomalías requiere tanto condición nominal como desviaciones reales documentadas antes de automatizar alertas." },
38+
] as const;
39+
40+
export const POLICY_BOUNDARY_MESSAGE =
41+
"La frontera predictiva permanece cerrada: promover un dataset a línea de base sólo registra evidencia. Las capacidades predictivas se habilitan únicamente después de Gate 5 (baselines contrastados con muestra real acordada) y Gate 7 (sign-off PASS de Pescamar) documentados en docs/SEAFOOD-GRADE-A.md. Hasta entonces predictiveBaselineAvailable sigue siendo false.";
42+
43+
export type BaselinePromotionResult =
44+
| { promoted: false; code: "NO_VALIDATED_BATCH" | "INSUFFICIENT_EVIDENCE" | "ALREADY_PROMOTED"; reason: string }
45+
| { promoted: true; batchId: number; capability: EdgeVisionBaselineCapability; recordedAt: string };
46+
47+
type BatchRow = {
48+
id: number;
49+
plant_id: string;
50+
capability: EdgeVisionBaselineCapability;
51+
captured_from: string;
52+
captured_to: string;
53+
image_count: number;
54+
operator_confirmed_labels: number;
55+
qa_status: string;
56+
promoted_at: string | null;
57+
};
58+
59+
function captureDays(row: BatchRow): number {
60+
const from = new Date(row.captured_from).getTime();
61+
const to = new Date(row.captured_to).getTime();
62+
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return 0;
63+
return Math.floor((to - from) / 86_400_000) + 1;
64+
}
65+
66+
export function assessBaselineEvidence(row: BatchRow): { ok: boolean; gaps: string[] } {
67+
const requirement = baselineEvidenceRequirements.find((item) => item.capability === row.capability);
68+
if (!requirement) return { ok: false, gaps: ["capacidad sin requisito de línea de base definido"] };
69+
const gaps: string[] = [];
70+
if (row.qa_status !== "validated") gaps.push("el lote no está qa_status=validated");
71+
if (row.image_count < requirement.minimumImages) gaps.push(`image_count ${row.image_count} < ${requirement.minimumImages}`);
72+
if (row.operator_confirmed_labels < requirement.minimumOperatorConfirmedLabels) gaps.push(`operator_confirmed_labels ${row.operator_confirmed_labels} < ${requirement.minimumOperatorConfirmedLabels}`);
73+
const days = captureDays(row);
74+
if (days < requirement.minimumCaptureDays) gaps.push(`ventana de captura ${days} día(s) < ${requirement.minimumCaptureDays}`);
75+
return { ok: gaps.length === 0, gaps };
76+
}
77+
78+
// Promotion is a provenance record on the batch row (promoted_at/promoted_by).
79+
// It is the ONLY write this seam performs and it activates no model, no
80+
// forecast and no automated decision.
81+
export async function promoteCapabilityBaseline(capability: EdgeVisionBaselineCapability, requestedBy: string): Promise<BaselinePromotionResult> {
82+
const rows = await getSql()`
83+
select id, plant_id, capability, captured_from, captured_to, image_count, operator_confirmed_labels, qa_status, promoted_at
84+
from edgevision_dataset_batches
85+
where capability=${capability}
86+
order by id desc
87+
limit 20`;
88+
const batches = (Array.isArray(rows) ? rows : []) as BatchRow[];
89+
const candidates = batches.filter((row) => row.qa_status === "validated");
90+
if (!candidates.length)
91+
return { promoted: false, code: "NO_VALIDATED_BATCH", reason: `No existe ningún lote qa_status=validated para la capacidad '${capability}'. ${POLICY_BOUNDARY_MESSAGE}` };
92+
const eligible = candidates.filter((row) => assessBaselineEvidence(row).ok);
93+
if (!eligible.length) {
94+
const gaps = candidates.map((row) => `lote #${row.id}: ${assessBaselineEvidence(row).gaps.join("; ")}`).join(" | ");
95+
return { promoted: false, code: "INSUFFICIENT_EVIDENCE", reason: `Ningún lote validado cumple los umbrales de evidencia para '${capability}'. ${gaps}. ${POLICY_BOUNDARY_MESSAGE}` };
96+
}
97+
const target = eligible[0];
98+
if (target.promoted_at)
99+
return { promoted: false, code: "ALREADY_PROMOTED", reason: `El lote #${target.id} ya fue promovido como línea de base de '${capability}' el ${target.promoted_at}. ${POLICY_BOUNDARY_MESSAGE}` };
100+
const updated = await getSql()`
101+
update edgevision_dataset_batches
102+
set promoted_at=now(), promoted_by=${requestedBy}::uuid
103+
where id=${target.id} and promoted_at is null
104+
returning id, promoted_at`;
105+
const row = Array.isArray(updated) ? (updated[0] as { id: number; promoted_at: string } | undefined) : undefined;
106+
if (!row) return { promoted: false, code: "ALREADY_PROMOTED", reason: `El lote #${target.id} ya fue promovido por otra sesión. ${POLICY_BOUNDARY_MESSAGE}` };
107+
return { promoted: true, batchId: row.id, capability, recordedAt: row.promoted_at };
108+
}

‎api/_migration-manifest.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const expectedMigrations=[
2-
'001_core.sql','002_settlement_workflow.sql','003_operator_auth.sql','004_reception_plant_evidence.sql','005_auth_abuse_audit.sql','006_historical_production.sql','007_live_lot_memory.sql','008_dispatches_sales.sql','009_sales_orders_inventory_daily_close.sql','010_transformation_costs.sql','011_reception_evidence_files.sql','012_plant_identity_links.sql','013_fix_create_lot_sale.sql','014_reception_evidence_ownership.sql','015_production_lines.sql','016_operator_audit_identity.sql','017_operational_operator_identity.sql','020_whatsapp_intelligence.sql','021_whatsapp_directory_seed.sql','022_sea_urchin_process_control.sql','023_product_labels.sql','024_label_release_gate.sql','025_partners_profitability_inventory.sql','026_historical_intelligence.sql','027_historical_record_eligibility.sql','028_uni_vision_station.sql','029_uni_vision_source_image_hash.sql','030_production_support_evidence.sql','031_production_support_resolutions.sql','032_production_support_blocks.sql','033_plant_execution_foundation.sql','034_label_engine.sql','035_pallets.sql','036_cold_chain.sql','037_regulatory_holds.sql','038_regulatory_pallet_membership_freeze.sql','039_cold_asset_active_run_exclusion.sql','040_cold_sensor_station_scope.sql','041_schema_migration_baseline.sql','042_japan_export_release_gate.sql','043_japan_dispatch_fail_closed.sql','044_sea_urchin_sequence_fail_closed.sql','045_japan_cold_chain_fail_closed.sql','046_lot_operational_lifecycle.sql','047_reconcile_post_baseline_migration_registry.sql','048_reception_vision_provenance.sql','049_reception_vision_file_provenance.sql','050_uni_vision_quality_feedback.sql','051_sea_urchin_external_references.sql','052_sea_urchin_external_reference_reviews.sql','053_gs1_identity_registry.sql','054_design_assets.sql','055_ml_human_feedback.sql','056_pilot_events.sql','057_pilot_heartbeat.sql','058_operator_password_rotation.sql'
2+
'001_core.sql','002_settlement_workflow.sql','003_operator_auth.sql','004_reception_plant_evidence.sql','005_auth_abuse_audit.sql','006_historical_production.sql','007_live_lot_memory.sql','008_dispatches_sales.sql','009_sales_orders_inventory_daily_close.sql','010_transformation_costs.sql','011_reception_evidence_files.sql','012_plant_identity_links.sql','013_fix_create_lot_sale.sql','014_reception_evidence_ownership.sql','015_production_lines.sql','016_operator_audit_identity.sql','017_operational_operator_identity.sql','020_whatsapp_intelligence.sql','021_whatsapp_directory_seed.sql','022_sea_urchin_process_control.sql','023_product_labels.sql','024_label_release_gate.sql','025_partners_profitability_inventory.sql','026_historical_intelligence.sql','027_historical_record_eligibility.sql','028_uni_vision_station.sql','029_uni_vision_source_image_hash.sql','030_production_support_evidence.sql','031_production_support_resolutions.sql','032_production_support_blocks.sql','033_plant_execution_foundation.sql','034_label_engine.sql','035_pallets.sql','036_cold_chain.sql','037_regulatory_holds.sql','038_regulatory_pallet_membership_freeze.sql','039_cold_asset_active_run_exclusion.sql','040_cold_sensor_station_scope.sql','041_schema_migration_baseline.sql','042_japan_export_release_gate.sql','043_japan_dispatch_fail_closed.sql','044_sea_urchin_sequence_fail_closed.sql','045_japan_cold_chain_fail_closed.sql','046_lot_operational_lifecycle.sql','047_reconcile_post_baseline_migration_registry.sql','048_reception_vision_provenance.sql','049_reception_vision_file_provenance.sql','050_uni_vision_quality_feedback.sql','051_sea_urchin_external_references.sql','052_sea_urchin_external_reference_reviews.sql','053_gs1_identity_registry.sql','054_design_assets.sql','055_ml_human_feedback.sql','056_pilot_events.sql','057_pilot_heartbeat.sql','058_operator_password_rotation.sql','059_edgevision_dataset_batches.sql'
33
] as const
44

55
export const migrationLandmarks=['parties','receptions','operators','historical_production_records','canonical_production_support_blocks','plant_stations','packing_units','label_templates','pallets','cold_runs','regulatory_holds','japan_export_release_evidence','lot_lifecycle_events','sea_urchin_external_references','gs1_identity_links'] as const

‎api/edgevision-baseline.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {requireOperator} from './_auth.js'
2+
import {allowClientIp} from './_rate-limit.js'
3+
import {EDGE_VISION_BASELINE_CAPABILITIES,POLICY_BOUNDARY_MESSAGE,promoteCapabilityBaseline,type EdgeVisionBaselineCapability} from './_edgevision-baseline.js'
4+
5+
type Request={method?:string;body?:unknown;headers?:Record<string,string|string[]|undefined>}
6+
type Response={status:(code:number)=>Response;setHeader:(name:string,value:string)=>void;json:(body:unknown)=>void}
7+
type Input={capability?:unknown}
8+
const text=(value:unknown,max=120)=>String(value??'').trim().slice(0,max)
9+
10+
export default async function handler(req:Request,res:Response){
11+
res.setHeader('Cache-Control','no-store')
12+
if(req.method!=='POST'){res.setHeader('Allow','POST');return res.status(405).json({ok:false,error:'Método no permitido'})}
13+
try{
14+
const operator=await requireOperator(req,['admin'])
15+
if(!operator)return res.status(401).json({ok:false,error:'Sesión de administrador requerida'})
16+
if(!allowClientIp(req,60_000,10))return res.status(429).json({ok:false,error:'Demasiadas solicitudes de línea de base EdgeVision por minuto'})
17+
const input=(req.body??{}) as Input
18+
const capability=text(input.capability,40) as EdgeVisionBaselineCapability
19+
if(!(EDGE_VISION_BASELINE_CAPABILITIES as readonly string[]).includes(capability))return res.status(400).json({ok:false,error:'Capacidad inválida'})
20+
const result=await promoteCapabilityBaseline(capability,operator.id)
21+
if(!result.promoted){
22+
// Inert seam by design: while no validated batch meets the evidence
23+
// thresholds the request is refused with the policy explanation and
24+
// the predictive boundary stays hard (predictiveBaselineAvailable:false).
25+
return res.status(409).json({ok:false,promoted:false,code:result.code,reason:result.reason,policyBoundary:POLICY_BOUNDARY_MESSAGE})
26+
}
27+
return res.status(200).json({ok:true,promoted:true,batchId:result.batchId,capability:result.capability,recordedAt:result.recordedAt,note:'Provenance registrado. La frontera predictiva sigue cerrada hasta Gate 5 y Gate 7 de docs/SEAFOOD-GRADE-A.md.'})
28+
}catch(error){
29+
console.error('edgevision baseline promotion failed',error)
30+
if(error instanceof Error&&error.message.includes('edgevision_dataset_batches'))return res.status(503).json({ok:false,error:'Esquema EdgeVision pendiente de migración (059_edgevision_dataset_batches)'})
31+
return res.status(500).json({ok:false,error:'No fue posible evaluar la línea de base EdgeVision'})
32+
}
33+
}

0 commit comments

Comments
 (0)