Independent Project Not affiliated with, sponsored by, or endorsed by the Watch Tower Bible and Tract Society or Jehovah's Witnesses.
jw-agent-toolkit
ES

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, F51 models_organized (clave), F57 jw-meeting-media multi-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; Irregular con 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 de sws2apps/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

  1. Solver CP-SAT (OR-Tools) que resuelve programa midweek + weekend completo (~40 slots) en <2s p95 sobre roster de 60+ personas activas.
  2. Importador organized-app JSON backup que pobla PersonRecord[] y SchedWeek[] históricas, con dry-run + diff antes de cualquier escritura.
  3. Store SQLite local cifrado (Fernet + PBKDF2) en ~/.jw-agent-toolkit/congregations/<congregation_id>/, fuera del second-brain.
  4. Tabla histórica assignment_history con timestamps CRDT-style, fuente de verdad para constraints de rotación.
  5. AssignmentConstraints per-congregación definidas en YAML versionable; validadas con Pydantic; cargadas a runtime y aplicadas al solver.
  6. 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.
  7. Multi-congregación: igual patrón que jw-meeting-media (F57.16), aislamiento por congregation_id.
  8. Determinismo reproducible: misma semilla + mismo estado del store → mismas sugerencias. Tests reproducen con seed=42.
  9. Infactibilidad como output de primera clase: si el solver no encuentra solución, devuelve unfilled_slots[] con infeasibility_reason estructurado por slot (no excepción).
  10. 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).
  11. Integración limpia con F26: cada slot AYF propuesto puede invocar student_part_helper para 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 SchedWeekType propuesta 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): cada AssignmentCongregation tiene updatedAt CRDT; el solver solo escribe en slots cuyo updatedAt es 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.py style 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-app ni 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 a organized-app formato 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:

  1. Primer arranque sin store: jw scheduler import <organized-app.json> puebla el store SQLite cifrado.
  2. Tras el import, el coordinador puede editar cualquier campo (jw scheduler person edit "Juan Pérez" --add-privilege MS --add-assignment AYF_Part1_Student).
  3. Re-import futuros respetan el Timestamped[T] CRDT: si una entrada local tiene updatedAt más reciente que la importada, se conserva la local.
  4. 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 — devuelve ProposedSchedWeek.model_dump().
  • meeting_commit_schedule(congregation_id: str, week_of: str, proposed: dict, confirmed_slots: list[str]) → dict — persiste solo los slots confirmados al assignment_history.
  • meeting_list_people(congregation_id: str) → list[dict] — sin nombres descifrados; devuelve person_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:

  1. Lector del JSON backup: estructura {persons: [...], schedules: [...], congregation: {...}}.
  2. Mapeo PersonType (organized-app, schema real verificado en packages/jw-core/src/jw_core/models_organized/person.py) → PersonRecord (scheduler). Acceso real a campos:
    • person.person_data.person_display_name.valueFieldEncryptor.encrypt(value)display_name_ciphered.
    • person.person_data.male.value y person.person_data.female.value → derivar gender (“male”|“female”|“unknown” si ambos False/True).
    • person.person_data.assignments[*].values (flatten + dedupe) → eligible_assignments: list[AssignmentCode].
    • person.person_data.timeAway[*] (filtrar deleted=False) → time_away[].
    • person.person_data.publisher_baptized.active.value + publisher_unbaptized.active.value + statusHistory[*]status.
    • person.person_data.midweek_meeting_student.active.valueis_midweek_student.
    • person.person_data.privileges[*] (PrivilegeHistoryEntry, filter deleted=False y end_date futura/vacía) → privileges.
    • max(person.person_data.*.updatedAt) para campos Timestamped → last_updated CRDT seed.
  3. Mapeo SchedWeek (organized-app) → AssignmentHistoryEntry[] (uno por slot poblado).
  4. Dry-run mode: --dry-run muestra qué se importaría sin tocar el store.
  5. Diff mode: si ya hay store previo, mostrar (person, field, old, new) y respetar updated_at CRDT.
  6. 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 PersonType mapean a PersonRecord o 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:

  1. 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).
  2. 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.
  3. PersonStore con métodos upsert(record), get(person_id), list_eligible_for(assignment_code, gender, language, on_date), decrypt_display_name(person_id, passphrase).
  4. HistoryStore con record(entry), days_since_last(person_id, assignment_code, ref_date), assignments_in_month(person_id, year_month).
  5. Reutilizar jw_core.privacy.encryption.FieldEncryptor (no instanciar cryptography.Fernet directo). Lee JW_PRIVACY_KEY; si vacío, no-op + warning idénticos al patrón field_report.py. Para passphrase manual en CLI usar derive_key_from_password(passphrase, salt=congregation_id.encode()). Output cifrado es str (base64) — NO bytes — para consistencia con field_report.
  6. Migration system: schema version stored in _meta table.
  7. 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 .db no revela display_name.
  • Migration de schema v1 → v2 sin pérdida de datos (test).

Fase F81.2 — AssignmentConstraints YAML (3 días)

Tareas:

  1. Schema Pydantic AssignmentConstraints (ya definido arriba).
  2. Loader: load_constraints(congregation_id) → AssignmentConstraints lee ~/.jw-agent-toolkit/congregations/<id>/constraints.yaml.
  3. Validador semántico: gap_minimum_days[code] ≥ 7, max_assignments_per_month ≥ 1, weights no-negativos.
  4. Template generator: jw scheduler constraints init --congregation <id> produce un YAML con defaults comentados.
  5. Linter: jw scheduler constraints lint valida sintaxis + semántica.
  6. 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:

  1. Crear solver/builder.py con build_model(workbook, people, history, constraints, seed) → CpModel:
    • Variables booleanas x[p, s] por persona elegible × slot.
    • Hard constraints (lista completa arriba).
    • Soft constraints como IntVar de penalización sumados en Minimize().
  2. solver/runner.py con solve(model, timeout) → SolverResult:
    • cp_model.CpSolver() con params.random_seed = seed y params.max_time_in_seconds = timeout.
    • Si status INFEASIBLE: extraer unfilled_slots[] via análisis del modelo (qué constraints fueron incompatibles).
  3. solver/explainer.py con explain_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”).
  4. solver/infeasibility.py con diagnose_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.
  5. 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:

  1. 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."""
  2. Internamente: carga workbook via workbook_helper (F11), carga store, llama suggest_assignments().
  3. Cada slot llena un Finding con metadata = {assignment_field, person_id, aula, confidence, rationale}.
  4. @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.
  5. Tracing F43: emite CustomEvent("solver_started"...), CustomEvent("slot_decision"...) por slot, CustomEvent("solver_completed"...).
  6. Tests: 5 goldens E2E con fixture de congregación.

Criterios de éxito:

  • assignment_generator produce AgentResult con N findings = N slots filled + metadata ProposedSchedWeek.
  • 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:

  1. CLI: packages/jw-cli/src/jw_cli/commands/scheduler.py — Typer subapp con import, people, person, suggest, confirm, history, constraints.
  2. MCP: registrar 4 tools nuevas en jw_mcp.server (lazy-loaded).
  3. REST: añadir 4 endpoints en jw_mcp.rest_api.
  4. Documentar en docs/guias/meeting-scheduler.md.

Criterios de éxito:

  • uv run jw scheduler suggest --week 2026-07-06 --congregation test corre en <3s end-to-end (incluye fetch de workbook desde caché).
  • MCP tool meeting_suggest_assignments aparece en uv run jw-mcp --list-tools.
  • REST endpoint responde 200 con ProposedSchedWeek JSON valid.

Fase F81.6 — Tauri UI (post-MVP, 1 semana)

Tareas:

  1. 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.
  2. Llama REST /api/v1/scheduler/suggest y /api/v1/scheduler/confirm.
  3. 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_name cifrado 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étricaBaselineTarget F81
Slots midweek+weekend cubiertos por slot solver0≥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ónn/a100% (property-based)
Infactibilidades reportadas con razón estructuradan/a100% (no excepciones silenciosas)
Tests verdes2 716≥2 800 al final
Cobertura nuevo packagen/a≥85% line coverage
0 regresiones en tests existentes

Riesgos y mitigaciones

  1. 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; cuaderno notebooks/scheduler_explorer.ipynb para iterar.
  2. 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 devolvemos unfilled_slots=[] sin diagnóstico.
  3. Datos organized-app desactualizados — si la backup tiene varios meses, los privilegios no reflejan estado actual.

    • Mitigación: dry-run + diff antes de overwrite; campo last_updated por entrada con respeto CRDT.
  4. Pérdida de Fernet passphrase — sin la passphrase, el display_name_encrypted es irrecuperable.

    • Mitigación: la passphrase no se almacena en el repo ni en variables de entorno por defecto; documento docs/guias/meeting-scheduler-recovery.md con procedimiento (re-import desde organized-app reseteando passphrase).
  5. Constraints mal escritos en YAML — coordinador edita constraints.yaml y rompe el solver.

    • Mitigación: Pydantic validator strict; jw scheduler constraints lint antes de cualquier suggest; defaults conservadores.
  6. Sesgo en orden de inserción — el primer hermano en people.db queda “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 de people.
  7. Sensibilidad de datos — nombres + privilegios + asistencia son PII de congregación.

    • Mitigación: cifrado at-rest; MCP/REST nunca devuelven display_name descifrado por defecto (alias only); flag opt-in --decrypt requiere passphrase.
  8. Cambio de estructura del programa JW — si JW añade una parte AYF Part 5 o renombra TGW Gems, models_organized necesita actualización.

    • Mitigación: F81 declara contrato vía AssignmentCode IntEnum; cualquier cambio upstream se detecta por test de schema; migration de constraints YAML automatizable.
  9. Congregaciones bilingües/multilingües — algunos slots requieren orador en idioma específico, el constraint multiplica complejidad.

    • Mitigación: languages_active por congregación; language_match(person, slot) como hard constraint cuando aplica; v1 testea congregación monolingüe + bilingüe.
  10. 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_at por entrada; importador respeta local > imported cuando local.updated_at > imported.updated_at.
  11. Solver lento en congregaciones grandes (300+ personas, multi-idioma, multi-aula).

    • Mitigación: timeout configurable (default 10s) con fallback a FEASIBLE no óptimo; warm-start con solución previa cuando aplica.
  12. F26 student_part_helper desincronizado — el script generado no matchea el slot asignado (e.g. bible_reading student_part_helper genera guion pero el slot real es MM_AYFPart1_Student).

    • Mitigación: contrato explícito assignment_code → kind en F26 (ya cubierto por su contrato); integración test E2E “suggest → invoke F26 → expect Finding[]”.

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-recording de 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

  1. Aprobación del spec (este documento) por el owner.
  2. Plan de implementación Fase F81.0 (importer) vía superpowers:writing-plans.
  3. Scaffold del package con create-jw-agent (F42): uvx create-jw-agent meeting-scheduler --type=agent --lang=es.
  4. Cassettes de prueba: solicitar al usuario un backup organized-app JSON anonimizado o sintético para fixtures.
  5. Decisión multi-idioma del usuario: ¿la congregación de prueba es monolingüe o bilingüe? Define cobertura de tests inicial.

Referencias

Edit this page on docs/superpowers/specs/2026-06-17-fase-81-meeting-scheduler-design.md