Specs & Plans
Fase 81 — jw-meeting-scheduler: solver CP-SAT para asignaciones midweek + weekend
Fecha: 2026-06-17 Estado: Diseño en revisión Owner: Elias Tier: 2 (alto impacto operativo congregacional) Capa: A — agéntica + nueva capa de scheduling Depende de: F11 workbook scraper, F19 JW Library integration, F26
student_part_helper, F43 tracing, F51models_organized(clave), F57jw-meeting-mediamulti-congregación, F65 meta-orchestrator, F77 principios YAML Documento padre: nuevo overview F81–F82 (a redactar) Predecesor conceptual: F26 produjo el guion del estudiante; F81 produce a quién se le entrega ese guion
Motivación
El cuerpo de ancianos de una congregación enfrenta cada semana ~40 slots de asignación entre la reunión midweek (Treasures + Apply Yourself + Living as Christians + CBS) y la reunión weekend (presidente, oración, orador público, conductor Atalaya, lector). Cada slot tiene su propia matriz de elegibilidad:
- Género: presidente, orador público, plegaria pública y conductor solo hermanos bautizados; lectura bíblica solo hermanos; AYF demonstrations requieren pareja del mismo género.
- Privilegio: presidente, TGW Talk, oración pública, conductor Atalaya y conductor CBS típicamente reservadas a ancianos/MS.
- Estatus: nadie en disciplina (
Disfellowshipped) en partes públicas;Irregularcon restricciones según política local. - Disponibilidad:
timeAway[](vacaciones, asignaciones especiales, embarazo, salud). - Rotación: gap mínimo entre dos asignaciones del mismo tipo a la misma persona (default 60 días bible_reading, 45 días student_part_AYF).
- Balance: máximo N asignaciones por persona por mes para evitar sobrecarga.
- Aulas: main hall + aux_class_1 + aux_class_2 (cuando aplica) — cada aula tiene su cuadro de estudiantes.
- Parejas: estudiante AYF + ayudante deben ser del mismo género para demostraciones.
- Idioma: si la congregación es bilingüe (frecuente en LATAM/USA), algunos slots requieren idioma específico.
Hoy esa resolución la hace a mano un coordinador V&M cada semana o se apoya en herramientas externas (organized-app web, Hourglass desktop). El toolkit ya tiene:
- Modelos completos (
models_organized.AssignmentCode,SchedWeekType,MidweekMeeting,WeekendMeeting,PersonType) portados desws2apps/organized-app(F51). - Descubrimiento del programa semanal (
workbook_helper, F11) que parsea el folleto vigente bimensual. - Plantillas de partes estudiantiles (
student_part_helper, F26) que producen el guion del estudiante. - Presenter multi-congregación (
jw-meeting-media, F57.16) que reproduce los medios sincronizados. - Cifrado PII (
field_report.py,study_progress.py) con Fernet + PBKDF2.
Lo que falta es exactamente la pieza que cierra el loop: un solver que, dado el programa de la semana, el roster de la congregación y un YAML de restricciones, proponga la SchedWeekType completa para que el coordinador la revise y publique. Esta fase entrega ese solver y todo lo necesario para integrarlo con organized-app como importador y con la pila MCP/REST/CLI/Tauri existente.
Objetivos
- Solver CP-SAT (OR-Tools) que resuelve programa midweek + weekend completo (~40 slots) en <2s p95 sobre roster de 60+ personas activas.
- Importador
organized-appJSON backup que poblaPersonRecord[]ySchedWeek[]históricas, con dry-run + diff antes de cualquier escritura. - Store SQLite local cifrado (Fernet + PBKDF2) en
~/.jw-agent-toolkit/congregations/<congregation_id>/, fuera del second-brain. - Tabla histórica
assignment_historycon timestamps CRDT-style, fuente de verdad para constraints de rotación. AssignmentConstraintsper-congregación definidas en YAML versionable; validadas con Pydantic; cargadas a runtime y aplicadas al solver.- Modo sugerencia + confirmación humana: el output es una
SchedWeekType“propuesta” + delta vs “actual” + razón por slot; el coordinador acepta/edita/rechaza slot por slot. No autónomo. - Multi-congregación: igual patrón que
jw-meeting-media(F57.16), aislamiento porcongregation_id. - Determinismo reproducible: misma semilla + mismo estado del store → mismas sugerencias. Tests reproducen con
seed=42. - Infactibilidad como output de primera clase: si el solver no encuentra solución, devuelve
unfilled_slots[]coninfeasibility_reasonestructurado por slot (no excepción). - Cobertura programa completo: midweek (Chairman, Opening Prayer, TGW Talk, TGW Gems, Bible Reading, AYF Part 1–4 con student+assistant, LC Part 1–3, CBS Conductor + Reader, Closing Prayer) + weekend (Chairman, Opening Prayer, Speaker Part 1/2, Substitute, WT Conductor + Reader, Closing Prayer).
- Integración limpia con F26: cada slot AYF propuesto puede invocar
student_part_helperpara generar el guion sugerido al estudiante asignado.
No-objetivos (boundaries vinculantes)
- No genera ni firma el formulario S-89 oficial. Es responsabilidad del superintendente del CCC + coordinador.
- No publica la
SchedWeekTypepropuesta sin confirmación humana explícita por slot. - No sobrescribe ediciones manuales del coordinador (patrón anti-sobrescritura idéntico a
jw_brain/wiki/obsidian_writer.py:24,39,43): cadaAssignmentCongregationtieneupdatedAtCRDT; el solver solo escribe en slots cuyoupdatedAtes nulo o anterior al import last-run. - No sincroniza entre dispositivos. El sync entre el laptop del coordinador y los dispositivos del cuerpo de ancianos queda fuera de esta fase (el importador
organized-app+ un exporter es la vía manual aceptada). - No califica calidad de presentaciones (esto requeriría rating del estudiante, que sin opt-in explícito es problemático éticamente y operativamente con
field_report.pystyle policy). - No predice aptitud con ML. Datasets por congregación son demasiado pequeños (60–200 personas) para que un modelo predictivo aporte algo sobre reglas explícitas, y abre riesgo de bias no auditable.
- No mete datos privados en
jw-brain. El second-brain es canon público compartible; el roster de congregación es PII privada. Capas distintas, stores distintos. - No mete LLM en el camino crítico del solver. Es un problema de constraint satisfaction puro; un LLM añade ruido y no determinismo donde hay reglas duras claras.
- No reemplaza a
organized-appni compite con él. Si el coordinador quiere usar la web app de sws2apps, perfecto: este toolkit le importa la backup y le sugiere asignaciones; el coordinador exporta aorganized-appformato compatible. - No soporta congregaciones con más de 3 aulas auxiliares en v1 (cubrimos main + aux_1 + aux_2, que es el caso del 99% de congregaciones).
Decisión 1: tipo de solver
Opción A — Rule-based determinista (greedy)
Orden de slots por dificultad descendente; para cada slot, ordenar personas elegibles por score (recencia + balance + skill) descendente y elegir top-1.
Pros: cero deps, código simple, latencia <100ms. Contras: fácil quedar infactible y no saber por qué; no maneja restricciones cruzadas (e.g. “si A es Speaker Part 1, B no puede ser Substitute por ser cónyuge”); el orden de slots afecta la solución y es difícil tunear.
Opción B — CP-SAT (OR-Tools)
Modelar como problema de Constraint Programming: variables booleanas x[person, slot, week], restricciones duras como Add(...), restricciones blandas como Minimize(weighted_sum).
Pros:
- Solver maduro y libre (Apache 2.0); usado en producción por Google.
- Infeasibility certificate:
solver.ResponseStats()+model.Validate()reportan qué restricciones colisionaron. - Deterministic con
params.random_seed. - Maneja constraints cruzados nativamente.
- Performance: ~40 slots × ~60 personas ≈ 2400 variables binarias se resuelve en <1s.
Contras: dep ~30MB; curva de aprendizaje del DSL CpModel; debug requiere experiencia con el solver.
Opción C — LLM-assisted
Prompt a Claude/GPT con el roster + constraints + workbook → devuelve sugerencia JSON.
Pros: “explica” sugerencias en prosa al coordinador; flexible si las reglas cambian. Contras: no determinístico; sin garantía de constraint duro (el LLM puede asignar hermana a parte de presidente); opaco para auditar; coste por inferencia; inadecuado para un dominio con reglas duras explícitas.
Decisión: Opción B — CP-SAT
Justificación:
- Es un problema clásico de constraint satisfaction con reglas duras claras. CP-SAT está hecho exactamente para esto.
- Explicabilidad determinista > explicabilidad en prosa: el coordinador necesita saber “Juan no puede tomar este slot porque tuvo bible_reading hace 23 días < gap mínimo 60”, no una narrativa LLM.
- Infeasibility certificate es la feature crítica: cuando hay menos personas elegibles que slots, el coordinador necesita saberlo con razón estructurada para reasignar manualmente.
- El stack ya empuja determinismo (F26 fija
today, F39 NLI provider swap, F49 second-brain content-hash) — CP-SAT encaja. - El LLM downstream puede narrar la sugerencia en prosa al coordinador (Tauri UI), pero no debe generar la asignación.
Decisión 2: ubicación del store de datos
Opción A — En jw-brain como BrainDomain “congregation-roster”
Pros: GraphRAG queries naturales (“partes del hermano Juan en últimos 6 meses”, “publicadores que nunca han tenido reading”).
Contras: jw-brain está diseñado para canon público compartible (Biblia, publicaciones JW, conceptos doctrinales). Mezclar PII de congregación rompe el modelo conceptual y el contrato multi-tenant del backend (DuckDB/Neo4j).
Opción B — SQLite local cifrado en ~/.jw-agent-toolkit/congregations/<id>/
Mismo patrón que jw_core.ministry.field_report y jw_agents.study_progress.
Pros:
- Aislamiento estricto canon público ↔ PII privada.
- Fernet + PBKDF2 ya estandarizado en el repo.
- Migration paths simples (SQLite schema versioning).
- Backup trivial (copiar carpeta).
- Multi-congregación gratis (subcarpeta por
congregation_id).
Contras: queries GraphRAG no salen “gratis”; si en el futuro se quiere “encontrar al estudiante con historial similar a X”, hay que hacerlo a mano con SQL.
Decisión: Opción B — SQLite local cifrado
Justificación: la inviolabilidad del modelo “canon público en jw-brain” pesa más que la conveniencia de queries GraphRAG en el roster. El patrón field_report.py + study_progress.py ya está validado en el repo y los usuarios lo conocen.
Decisión 3: estrategia de importación
Opción A — Solo manual (CLI/Tauri form)
Pros: cero deps externas. Contras: alta fricción inicial (60–200 personas a tipear).
Opción B — Solo organized-app JSON backup
Pros: la mayoría del cuerpo de ancianos ya usa organized-app; los schemas Pydantic v2 ya están portados (F51).
Contras: deja fuera congregaciones que no usan organized-app.
Opción C — Híbrido (importador organized-app first, edición manual sobre el snapshot)
Decisión: Opción C. Comportamiento:
- Primer arranque sin store:
jw scheduler import <organized-app.json>puebla el store SQLite cifrado. - Tras el import, el coordinador puede editar cualquier campo (
jw scheduler person edit "Juan Pérez" --add-privilege MS --add-assignment AYF_Part1_Student). - Re-import futuros respetan el
Timestamped[T]CRDT: si una entrada local tieneupdatedAtmás reciente que la importada, se conserva la local. - Sin
organized-app: alta manual completa via CLI o Tauri form.
Decisión 4: granularidad de restricciones
AssignmentConstraints viven en ~/.jw-agent-toolkit/congregations/<id>/constraints.yaml. Cada congregación tiene su YAML; multi-congregación viene “gratis” del aislamiento por carpeta. El schema Pydantic valida al cargar y rechaza configuraciones imposibles (e.g. gap_minimum_days < 0).
Decisión 5: modo de operación
Sugerencia con confirmación humana, no autónomo. Output: ProposedSchedWeek que extiende SchedWeekType añadiendo:
class ProposedSchedWeek(BaseModel):
week_of: str # ISO Monday YYYY-MM-DD
congregation_id: str
base: SchedWeekType # estructura autoridad única
slot_confidence: dict[str, float] # 0.0–1.0 per assignment_field
slot_rationale: dict[str, str] # razón legible per slot
unfilled_slots: list[UnfilledSlot] # con infeasibility_reason
rotation_warnings: list[RotationWarning] # soft constraint violations
seed: int # reproducibility
solver_stats: SolverStats # OR-Tools ResponseStats
El coordinador revisa, edita, confirma. Solo entonces se llama commit_schedule() que produce el SchedWeekType final y lo persiste en assignment_history.
Arquitectura
┌─────────────────────────────────────────────────────────────────────┐
│ ENTRADA │
├─────────────────────────────────────────────────────────────────────┤
│ organized-app backup │ workbook_helper (F11) │
│ (PersonType[], hist) │ (WorkbookWeek con slots semana N) │
│ │ │ │ │
└───────────┼───────────────┴──────────────┼────────────────────────────┘
│ │
F81.0 importer F81.3 program loader
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ STORE (~/.jw-agent-toolkit/congregations/<id>/) │
├─────────────────────────────────────────────────────────────────────┤
│ people.db (SQLite + Fernet) → PersonRecord │
│ history.db (SQLite + Fernet) → AssignmentHistory CRDT │
│ constraints.yaml (Pydantic schema) → AssignmentConstraints │
│ congregation.toml (metadata) → name, languages, week_kind │
└─────────────────────────────────────────────────────────────────────┘
│
▼ F81.3 solver
┌─────────────────────────────────────────────────────────────────────┐
│ CP-SAT MODEL BUILDER │
├─────────────────────────────────────────────────────────────────────┤
│ Variables: │
│ x[p, s, w] ∈ {0,1} person p assigned to slot s in week w │
│ │
│ Hard constraints (Add(...) — must hold): │
│ ∀s: Σ_p x[p,s,w] = 1 slot has exactly 1 │
│ ∀p,w: Σ_s x[p,s,w] ≤ 1 person at most 1/week │
│ gender_compatible(p, s) │
│ privilege_compatible(p, s) │
│ status_active(p) │
│ available(p, week_date) │
│ pair_same_gender(student, assistant) │
│ reading_baptized_brother_only │
│ │
│ Soft constraints (penalty Minimize(...)): │
│ rotation_gap(p, s, last_assigned_date) │
│ balance_per_month(p) ≤ N │
│ pair_experienced_with_novice │
│ distribute_across_aulas │
│ skill_level_match(p, s) │
│ │
│ Random seed: params.random_seed = config.seed │
└─────────────────────────────────────────────────────────────────────┘
│
▼ F81.4 agente
┌─────────────────────────────────────────────────────────────────────┐
│ assignment_generator (jw-agents) │
├─────────────────────────────────────────────────────────────────────┤
│ AgentResult: │
│ findings = [Finding per slot suggested] │
│ metadata = ProposedSchedWeek │
│ @fidelity_wrap(principles=[PF030-no-double-assignment, hard]) │
│ Tracing F43: CustomEvent("slot_decision", payload={...}) │
└─────────────────────────────────────────────────────────────────────┘
│
▼ F81.5 wire-up
┌─────────────────────────────────────────────────────────────────────┐
│ SURFACES │
├─────────────────────────────────────────────────────────────────────┤
│ CLI: jw scheduler {import, suggest, confirm, history} │
│ MCP: meeting_suggest_assignments, meeting_commit_schedule │
│ REST: POST /api/v1/scheduler/{suggest,confirm} │
│ Tauri: src/routes/scheduler/ (F81.6, post-MVP) │
└─────────────────────────────────────────────────────────────────────┘
Contratos de tipos
# packages/jw-meeting-scheduler/src/jw_meeting_scheduler/models.py
from pydantic import BaseModel, Field
from typing import Literal
from datetime import date
from jw_core.models_organized.assignment import AssignmentCode, AssignmentFieldMidweekType
from jw_core.models_organized.schedule import SchedWeekType
Gender = Literal["male", "female"]
Privilege = Literal["elder", "ministerial_servant", "publisher", "unbaptized_publisher"]
Status = Literal["active", "irregular", "inactive", "disfellowshipped"]
SkillLevel = Literal[1, 2, 3, 4, 5] # 1=novice, 5=expert
class TimeAway(BaseModel):
start: date
end: date
reason: str = ""
class PersonRecord(BaseModel):
person_id: str = Field(pattern=r"^[a-z0-9_-]{3,64}$")
# Encrypted via jw_core.privacy.encryption.FieldEncryptor → returns str
# (base64 Fernet token or cleartext if JW_PRIVACY_KEY unset; no-op fallback).
display_name_ciphered: str
gender: Gender # derived: male.value→"male", female.value→"female"
status: Status = "active" # derived from publisher_baptized/unbaptized.active + statusHistory
is_midweek_student: bool = False # mirrors person_data.midweek_meeting_student.active.value
privileges: list[Privilege] = [] # derived from privileges[] PrivilegeHistoryEntry (active only)
eligible_assignments: list[AssignmentCode] = [] # flattened from assignments[].values
skill_level: dict[AssignmentCode, SkillLevel] = {}
languages: list[str] = ["en"]
time_away: list[TimeAway] = [] # from person_data.timeAway[]
last_updated: str # ISO; max(person_data.*.updatedAt) seen at import time
imported_from: Literal["organized_app", "manual"] = "manual"
class AssignmentHistoryEntry(BaseModel):
entry_id: str
person_id: str
assignment_field: str # e.g. "MM_AYFPart1_Student_A"
assignment_code: AssignmentCode
meeting_date: date
confirmed: bool = False
confirmed_at: str | None = None
cancelled: bool = False
cancellation_reason: str = ""
aula: Literal["main_hall", "aux_class_1", "aux_class_2"] = "main_hall"
updated_at: str # CRDT timestamp
class AssignmentConstraints(BaseModel):
"""Per-congregation rules. Loaded from constraints.yaml."""
congregation_id: str
gap_minimum_days: dict[AssignmentCode, int] = Field(default_factory=lambda: {
AssignmentCode.MM_BibleReading: 60,
AssignmentCode.MM_AYFPart1_Student: 45,
AssignmentCode.MM_AYFPart2_Student: 45,
AssignmentCode.MM_AYFPart3_Student: 45,
AssignmentCode.MM_AYFPart4_Student: 45,
AssignmentCode.MM_TGWTalk: 90,
AssignmentCode.WM_Speaker: 90,
})
max_assignments_per_month: int = 3
pair_experienced_with_novice: bool = True
require_brother_for_reading: bool = True
allow_overlapping_assistant_in_aula: bool = False
languages_active: list[str] = ["en"]
aulas_active: list[str] = ["main_hall"]
weights: dict[str, float] = Field(default_factory=lambda: {
"rotation_gap": 10.0,
"balance_per_month": 5.0,
"skill_match": 2.0,
"novice_pairing": 3.0,
"aula_distribution": 1.0,
})
class UnfilledSlot(BaseModel):
assignment_field: str
infeasibility_reason: Literal[
"no_eligible_person",
"all_eligible_in_timeaway",
"all_eligible_violate_rotation",
"gender_constraint_no_match",
"privilege_constraint_no_match",
"pair_no_valid_combination",
]
candidates_considered: int
rejected_with_reasons: list[dict[str, str]] # [{person_id, reason}, ...]
class RotationWarning(BaseModel):
person_id: str
assignment_field: str
days_since_last: int
gap_minimum: int
severity: Literal["soft", "violated"]
class SolverStats(BaseModel):
status: Literal["OPTIMAL", "FEASIBLE", "INFEASIBLE", "MODEL_INVALID", "UNKNOWN"]
wall_time_ms: int
branches: int
conflicts: int
booleans: int
class ProposedSchedWeek(BaseModel):
week_of: str
congregation_id: str
base: SchedWeekType
slot_confidence: dict[str, float] = {}
slot_rationale: dict[str, str] = {}
unfilled_slots: list[UnfilledSlot] = []
rotation_warnings: list[RotationWarning] = []
seed: int
solver_stats: SolverStats
generated_at: str
API pública
# packages/jw-meeting-scheduler/src/jw_meeting_scheduler/__init__.py
from jw_meeting_scheduler.importer import import_organized_app_backup
from jw_meeting_scheduler.store import (
PersonStore,
HistoryStore,
open_congregation,
)
from jw_meeting_scheduler.solver import suggest_assignments
from jw_meeting_scheduler.models import (
PersonRecord,
AssignmentHistoryEntry,
AssignmentConstraints,
ProposedSchedWeek,
UnfilledSlot,
RotationWarning,
SolverStats,
)
# Llamada principal
def suggest_assignments(
*,
congregation_id: str,
week_of: date,
workbook: WorkbookWeek,
people: list[PersonRecord],
history: list[AssignmentHistoryEntry],
constraints: AssignmentConstraints,
seed: int = 42,
timeout_seconds: float = 10.0,
) -> ProposedSchedWeek:
"""Resolve midweek + weekend assignments for the given week.
Returns a ProposedSchedWeek with the SchedWeekType base populated
where feasible plus per-slot rationale, unfilled slots with
infeasibility reason, rotation warnings, and solver stats.
Never raises on infeasibility — that's a regular outcome reported
in `unfilled_slots`. Raises only on schema validation errors
(e.g. constraint YAML malformed) or solver internal errors.
"""
CLI
# Importar congregación desde organized-app backup
jw scheduler import --backup organized-backup-2026-06-17.json \
--congregation kingdom-hall-central
# Listar miembros importados
jw scheduler people list --congregation kingdom-hall-central
# Editar persona manualmente
jw scheduler person edit "juan-perez" \
--add-privilege ms \
--add-assignment AYF_Part1_Student \
--skill-level AYF_Part1_Student=4
# Sugerir asignaciones para una semana
jw scheduler suggest --week 2026-07-06 \
--congregation kingdom-hall-central \
--export proposed.json
# Confirmar slot por slot (interactivo Rich)
jw scheduler confirm --week 2026-07-06 \
--congregation kingdom-hall-central \
--from proposed.json
# Ver historial de un publicador
jw scheduler history --person juan-perez \
--congregation kingdom-hall-central \
--months 6
# Validar YAML de constraints
jw scheduler constraints lint --congregation kingdom-hall-central
MCP tools
meeting_suggest_assignments(congregation_id: str, week_of: str, seed: int = 42) → dict— devuelveProposedSchedWeek.model_dump().meeting_commit_schedule(congregation_id: str, week_of: str, proposed: dict, confirmed_slots: list[str]) → dict— persiste solo los slots confirmados alassignment_history.meeting_list_people(congregation_id: str) → list[dict]— sin nombres descifrados; devuelveperson_id,privileges,eligible_assignments.meeting_get_history(congregation_id: str, person_id: str, months: int = 6) → list[dict].
REST endpoints (jw-mcp rest_api.py)
POST /api/v1/scheduler/suggest
body: { congregation_id, week_of (YYYY-MM-DD), seed?: int }
resp: ProposedSchedWeek
POST /api/v1/scheduler/confirm
body: { congregation_id, week_of, slot_confirmations: list[SlotConfirm] }
resp: { committed: int, skipped: int, errors: list[str] }
GET /api/v1/scheduler/people
query: congregation_id
resp: list[PersonRecord] (sin display_name descifrado, retorna alias)
GET /api/v1/scheduler/history
query: congregation_id, person_id, months
resp: list[AssignmentHistoryEntry]
Fase F81.0 — Importador organized-app (1 semana)
Tareas:
- Lector del JSON backup: estructura
{persons: [...], schedules: [...], congregation: {...}}. - Mapeo
PersonType(organized-app, schema real verificado enpackages/jw-core/src/jw_core/models_organized/person.py) →PersonRecord(scheduler). Acceso real a campos:person.person_data.person_display_name.value→FieldEncryptor.encrypt(value)→display_name_ciphered.person.person_data.male.valueyperson.person_data.female.value→ derivargender(“male”|“female”|“unknown” si ambos False/True).person.person_data.assignments[*].values(flatten + dedupe) →eligible_assignments: list[AssignmentCode].person.person_data.timeAway[*](filtrardeleted=False) →time_away[].person.person_data.publisher_baptized.active.value+publisher_unbaptized.active.value+statusHistory[*]→status.person.person_data.midweek_meeting_student.active.value→is_midweek_student.person.person_data.privileges[*](PrivilegeHistoryEntry, filterdeleted=Falseyend_datefutura/vacía) →privileges.max(person.person_data.*.updatedAt)para campos Timestamped →last_updatedCRDT seed.
- Mapeo
SchedWeek(organized-app) →AssignmentHistoryEntry[](uno por slot poblado). - Dry-run mode:
--dry-runmuestra qué se importaría sin tocar el store. - Diff mode: si ya hay store previo, mostrar
(person, field, old, new)y respetarupdated_atCRDT. - Tests con fixtures: 5 backups sintéticos cubriendo casos edge (sin schedules, solo persons, schedules sin persons, CRDT conflicts).
Criterios de éxito:
- 100% de los campos
PersonTypemapean aPersonRecordo se loguean como ignorados con razón. - Idempotencia: re-import del mismo backup no produce cambios en el store.
- Performance: import de 200 personas + 24 semanas en <3s.
Fase F81.1 — Store SQLite cifrado + history (1 semana)
Tareas:
- Schema SQL (
people.db):persons (person_id PK, display_name_encrypted BLOB, gender, status, languages_json, last_updated, imported_from).person_privileges (person_id FK, privilege).person_eligible_assignments (person_id FK, assignment_code).person_time_away (person_id FK, start_date, end_date, reason).person_skill (person_id FK, assignment_code, skill_level).
- Schema SQL (
history.db):assignment_history (entry_id PK, person_id, assignment_field, assignment_code, meeting_date, aula, confirmed, confirmed_at, cancelled, cancellation_reason, updated_at).- Índices en
(person_id, assignment_code, meeting_date DESC)para queries de rotación rápidas.
PersonStorecon métodosupsert(record),get(person_id),list_eligible_for(assignment_code, gender, language, on_date),decrypt_display_name(person_id, passphrase).HistoryStoreconrecord(entry),days_since_last(person_id, assignment_code, ref_date),assignments_in_month(person_id, year_month).- Reutilizar
jw_core.privacy.encryption.FieldEncryptor(no instanciarcryptography.Fernetdirecto). LeeJW_PRIVACY_KEY; si vacío, no-op + warning idénticos al patrónfield_report.py. Para passphrase manual en CLI usarderive_key_from_password(passphrase, salt=congregation_id.encode()). Output cifrado esstr(base64) — NObytes— para consistencia con field_report. - Migration system: schema version stored in
_metatable. - Tests: round-trip, concurrent reads, CRDT conflict resolution, migration path.
Criterios de éxito:
list_eligible_for(MM_BibleReading, gender="male", language="es", on_date=2026-07-06)retorna en <50ms sobre 200 personas.- Cifrado at-rest verificable: lectura directa del
.dbno reveladisplay_name. - Migration de schema v1 → v2 sin pérdida de datos (test).
Fase F81.2 — AssignmentConstraints YAML (3 días)
Tareas:
- Schema Pydantic
AssignmentConstraints(ya definido arriba). - Loader:
load_constraints(congregation_id) → AssignmentConstraintslee~/.jw-agent-toolkit/congregations/<id>/constraints.yaml. - Validador semántico:
gap_minimum_days[code] ≥ 7,max_assignments_per_month ≥ 1,weightsno-negativos. - Template generator:
jw scheduler constraints init --congregation <id>produce un YAML con defaults comentados. - Linter:
jw scheduler constraints lintvalida sintaxis + semántica. - Tests: 3 YAMLs válidos (mínimo, completo, multi-idioma), 5 inválidos (cada uno con un error distinto).
Criterios de éxito:
- YAMLs malformados rechazados con mensaje de error claro (línea + columna + razón).
- Defaults razonables para una congregación promedio sin override.
Fase F81.3 — Solver CP-SAT (2 semanas)
Tareas:
- Crear
solver/builder.pyconbuild_model(workbook, people, history, constraints, seed) → CpModel:- Variables booleanas
x[p, s]por persona elegible × slot. - Hard constraints (lista completa arriba).
- Soft constraints como
IntVarde penalización sumados enMinimize().
- Variables booleanas
solver/runner.pyconsolve(model, timeout) → SolverResult:cp_model.CpSolver()conparams.random_seed = seedyparams.max_time_in_seconds = timeout.- Si status
INFEASIBLE: extraerunfilled_slots[]via análisis del modelo (qué constraints fueron incompatibles).
solver/explainer.pyconexplain_slot(slot, model, solution, history, constraints) → str:- Genera rationale legible por slot (“Juan asignado por: skill 4, gap 67d > min 60d, balance 1/3 mensual”).
solver/infeasibility.pycondiagnose_unfilled(slot, eligible, history, constraints) → UnfilledSlot:- Si 0 elegibles por género/privilegio:
no_eligible_person. - Si todos elegibles violan rotation:
all_eligible_violate_rotation. - etc.
- Si 0 elegibles por género/privilegio:
- Tests goldens: 10 escenarios con
(workbook, people, history, constraints)fijo y solución esperada.
Criterios de éxito:
- ~40 slots × 60 personas resuelve en <2s p95 en M4 Max.
- Determinismo: 100 corridas con mismo seed → mismas asignaciones (test property-based con hypothesis).
- Infactibilidad reportada con razón estructurada en ≥4 escenarios distintos.
- Determinismo es robusto a orden de inserción en
people(tie-breaker explícito en el solver).
Fase F81.4 — Agente assignment_generator (1 semana)
Tareas:
packages/jw-agents/src/jw_agents/assignment_generator.py:async def assignment_generator( congregation_id: str, week_of: date, *, workbook_client: WOLClient | None = None, seed: int = 42, ) -> AgentResult: """Compose ProposedSchedWeek for week_of in congregation."""- Internamente: carga workbook via
workbook_helper(F11), carga store, llamasuggest_assignments(). - Cada slot llena un
Findingconmetadata = {assignment_field, person_id, aula, confidence, rationale}. @fidelity_wrap(principles=[PF030], on_fail="reject")con principio:PF030-no-double-assignment(hard): nadie tiene >1 slot en la misma semana.PF031-respect-gender-constraint(hard): reading/speaker solo hermanos.PF032-respect-privilege(soft): warning si publicador sin MS/elder en slot reservado.
- Tracing F43: emite
CustomEvent("solver_started"...),CustomEvent("slot_decision"...)por slot,CustomEvent("solver_completed"...). - Tests: 5 goldens E2E con fixture de congregación.
Criterios de éxito:
assignment_generatorproduceAgentResultcon N findings = N slots filled + metadataProposedSchedWeek.- Fidelity tier 2 (regex+principles) rechaza outputs que violen PF030/PF031.
- 0 regresiones en tests existentes de
jw_agents.
Fase F81.5 — CLI + MCP + REST wire-up (3 días)
Tareas:
- CLI:
packages/jw-cli/src/jw_cli/commands/scheduler.py— Typer subapp conimport,people,person,suggest,confirm,history,constraints. - MCP: registrar 4 tools nuevas en
jw_mcp.server(lazy-loaded). - REST: añadir 4 endpoints en
jw_mcp.rest_api. - Documentar en
docs/guias/meeting-scheduler.md.
Criterios de éxito:
uv run jw scheduler suggest --week 2026-07-06 --congregation testcorre en <3s end-to-end (incluye fetch de workbook desde caché).- MCP tool
meeting_suggest_assignmentsaparece enuv run jw-mcp --list-tools. - REST endpoint responde 200 con
ProposedSchedWeekJSON valid.
Fase F81.6 — Tauri UI (post-MVP, 1 semana)
Tareas:
- Nuevo módulo
apps/desktop/src/routes/scheduler/:- Vista “Sugerencia” con tabla de slots, persona asignada, confidence, rationale.
- Click en slot → modal de override con lista de candidatos alternativos.
- Botón “Confirmar todos” / “Confirmar este slot”.
- Sección “Slots sin asignar” prominente.
- Llama REST
/api/v1/scheduler/suggesty/api/v1/scheduler/confirm. - Validar que el flujo del coordinador es completable en <5 minutos.
Criterios de éxito:
- Coordinador prueba flow completo (importar → sugerir → editar 2 slots → confirmar) en <5 min.
- UI no expone
display_namecifrado raw (descifra in-memory con passphrase prompted).
Stack técnico
- OR-Tools (
ortools>=9.10) — CP-SAT solver. Apache 2.0. Wheels para macOS/Linux/Windows. - Cryptography (
cryptography>=42) — Fernet (ya en repo). - PyYAML (
pyyaml>=6) — constraints config. - SQLite — embedded, ya en stdlib.
- Pydantic v2 — modelos.
- Typer + Rich — CLI (ya en repo).
- FastMCP — MCP server (ya en repo).
- FastAPI — REST (ya en repo).
- jw-core
models_organized— schemas autoridad única. - jw-agents
fidelity_wrap— tier 2 enforcement. - jw-agents
tracing— CustomEvent observability.
Métricas de éxito globales
| Métrica | Baseline | Target F81 |
|---|---|---|
| Slots midweek+weekend cubiertos por slot solver | 0 | ≥38 de ~40 en congregación tipo |
| Latencia solver p95 (M4 Max) | n/a | <2s |
| Latencia solver p95 (5090) | n/a | <500ms |
| Determinismo: mismo seed → misma solución | n/a | 100% (property-based) |
| Infactibilidades reportadas con razón estructurada | n/a | 100% (no excepciones silenciosas) |
| Tests verdes | 2 716 | ≥2 800 al final |
| Cobertura nuevo package | n/a | ≥85% line coverage |
| 0 regresiones en tests existentes | sí | sí |
Riesgos y mitigaciones
-
OR-Tools curva de aprendizaje del DSL — el equipo no ha usado CP-SAT antes en el monorepo.
- Mitigación: tests goldens primero (TDD); helper
build_model()que abstrae el DSL; cuadernonotebooks/scheduler_explorer.ipynbpara iterar.
- Mitigación: tests goldens primero (TDD); helper
-
Solver infeasible “silencioso” — CP-SAT puede devolver UNKNOWN sin razón clara.
- Mitigación:
diagnose_unfilled()ejecuta sub-modelos por slot para aislar la causa; nunca devolvemosunfilled_slots=[]sin diagnóstico.
- Mitigación:
-
Datos
organized-appdesactualizados — si la backup tiene varios meses, los privilegios no reflejan estado actual.- Mitigación: dry-run + diff antes de overwrite; campo
last_updatedpor entrada con respeto CRDT.
- Mitigación: dry-run + diff antes de overwrite; campo
-
Pérdida de Fernet passphrase — sin la passphrase, el
display_name_encryptedes irrecuperable.- Mitigación: la passphrase no se almacena en el repo ni en variables de entorno por defecto; documento
docs/guias/meeting-scheduler-recovery.mdcon procedimiento (re-import desde organized-app reseteando passphrase).
- Mitigación: la passphrase no se almacena en el repo ni en variables de entorno por defecto; documento
-
Constraints mal escritos en YAML — coordinador edita
constraints.yamly rompe el solver.- Mitigación: Pydantic validator strict;
jw scheduler constraints lintantes de cualquiersuggest; defaults conservadores.
- Mitigación: Pydantic validator strict;
-
Sesgo en orden de inserción — el primer hermano en
people.dbqueda “favorecido” por tie-breaker.- Mitigación: tie-breaker explícito en CP-SAT (random_seed → tie-break por hash determinista de
person_id); test property-based con permutaciones depeople.
- Mitigación: tie-breaker explícito en CP-SAT (random_seed → tie-break por hash determinista de
-
Sensibilidad de datos — nombres + privilegios + asistencia son PII de congregación.
- Mitigación: cifrado at-rest; MCP/REST nunca devuelven
display_namedescifrado por defecto (alias only); flag opt-in--decryptrequiere passphrase.
- Mitigación: cifrado at-rest; MCP/REST nunca devuelven
-
Cambio de estructura del programa JW — si JW añade una parte AYF Part 5 o renombra TGW Gems,
models_organizednecesita actualización.- Mitigación: F81 declara contrato vía
AssignmentCodeIntEnum; cualquier cambio upstream se detecta por test de schema; migration de constraints YAML automatizable.
- Mitigación: F81 declara contrato vía
-
Congregaciones bilingües/multilingües — algunos slots requieren orador en idioma específico, el constraint multiplica complejidad.
- Mitigación:
languages_activepor congregación;language_match(person, slot)como hard constraint cuando aplica; v1 testea congregación monolingüe + bilingüe.
- Mitigación:
-
Ediciones manuales del coordinador machacadas en re-import — el coordinador edita “Juan ahora es MS”, re-importa backup viejo, pierde el edit.
- Mitigación: CRDT
updated_atpor entrada; importador respetalocal > importedcuandolocal.updated_at > imported.updated_at.
- Mitigación: CRDT
-
Solver lento en congregaciones grandes (300+ personas, multi-idioma, multi-aula).
- Mitigación: timeout configurable (default 10s) con fallback a
FEASIBLEno óptimo; warm-start con solución previa cuando aplica.
- Mitigación: timeout configurable (default 10s) con fallback a
-
F26
student_part_helperdesincronizado — el script generado no matchea el slot asignado (e.g.bible_readingstudent_part_helper genera guion pero el slot real esMM_AYFPart1_Student).- Mitigación: contrato explícito
assignment_code → kinden F26 (ya cubierto por su contrato); integración test E2E “suggest → invoke F26 → expect Finding[]”.
- Mitigación: contrato explícito
Gaps y dependencias
- Bloqueador F0:
models_organized(F51) ya está; sin esto F81 no arranca. - Bloqueador F3: workbook scraper (F11) ya está; necesario para conocer cuántos slots tiene cada semana (Week Type, asambleas, etc.).
- No bloqueador, útil: si el usuario tiene cassettes
pytest-recordingde un workbook real, los goldens del solver pueden cargarlo directo. - No bloqueador, post-MVP: S-89 PDF generator. Sería F81.7 si surge demanda.
- No bloqueador, futuro: sync entre dispositivos del cuerpo de ancianos. Out of scope hasta que existan los requisitos.
Próximos pasos inmediatos
- Aprobación del spec (este documento) por el owner.
- Plan de implementación Fase F81.0 (importer) vía
superpowers:writing-plans. - Scaffold del package con
create-jw-agent(F42):uvx create-jw-agent meeting-scheduler --type=agent --lang=es. - Cassettes de prueba: solicitar al usuario un backup
organized-appJSON anonimizado o sintético para fixtures. - Decisión multi-idioma del usuario: ¿la congregación de prueba es monolingüe o bilingüe? Define cobertura de tests inicial.
Referencias
- OR-Tools CP-SAT — https://developers.google.com/optimization/cp/cp_solver
- OR-Tools Python tutorial — https://developers.google.com/optimization/introduction/python
sws2apps/organized-apprepo — https://github.com/sws2apps/organized-app- F26
student_part_helper—docs/superpowers/specs/2026-05-30-fase-26-student-parts-design.md - F49 second-brain —
docs/superpowers/specs/2026-06-01-fase-49-second-brain-design.md - F51
models_organizedport —packages/jw-core/src/jw_core/models_organized/ - F57
jw-meeting-mediamulti-congregation —packages/jw-meeting-media/ - F77 fidelity principles —
packages/jw-eval/src/jw_eval/principles/ - Folleto Vida y Ministerio (referencia operativa, no se reproduce contenido) —
mwb - Hourglass scheduler (commercial reference, no port) — https://hourglassgroupscheduler.com/
- Anti-overwrite pattern referencia —
packages/jw-brain/src/jw_brain/wiki/obsidian_writer.py:24,39,43
Edit this page on docs/superpowers/specs/2026-06-17-fase-81-meeting-scheduler-design.md