From 82ca97209ae6912444b92233f2d0230432aa15b9 Mon Sep 17 00:00:00 2001 From: Juanpacol Date: Thu, 30 Jul 2026 23:47:19 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20r=C3=BAbrica=20desde=20PDF/Word,=20y=20?= =?UTF-8?q?mejoras=20UX=20docente/estudiante?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracción de temas desde rúbrica institucional: - Subir PDF/DOCX en la pestaña Rúbrica, extraer texto y proponer temas vía IA para revisar/editar antes de lanzar la corrida (paso de preview, sin persistir). Limpieza: - Quita el tipo de ejercicio order_lines, la sección de Periodos académicos y el interruptor de Agentes IA por grupo (los agentes siguen activos siempre). Guías: botón para quitar (borra borrador/fallida/cancelada; publicada exige archivar). Banco de preguntas: editor CodeMirror para código en fill_code/live_code, con botón para reemplazar el código que generó la IA. Evaluaciones: - Porcentaje de la nota por evaluación, con nota acumulada ponderada en el gradebook y aviso si la suma de porcentajes del grupo no da 100%. - Variantes de ejercicios por IA con pantalla de revisión (aceptar/editar/ descartar) antes de crearlas. Pendientes: las asignaciones ahora también apuntan a examen o taller (antes solo tema/ejercicio), con una página global en el perfil docente para asignarlas sin entrar primero a un grupo. Vista previa como estudiante: conmutador en el sidebar (solo docente/admin) con banner permanente mientras está activo. Co-Authored-By: Claude Sonnet 5 --- apps/api/alembic/env.py | 7 +- .../alembic/versions/024_quita_order_lines.py | 61 +++ .../versions/025_quita_periodos_academicos.py | 53 +++ .../versions/026_quita_agent_configs.py | 64 +++ .../versions/027_porcentaje_evaluacion.py | 25 + .../028_asignaciones_examen_taller.py | 47 ++ apps/api/pyproject.toml | 2 + apps/api/scripts/seed.py | 18 - .../src/logica/ai/agents/code_integrity.py | 4 +- .../src/logica/ai/agents/config_repository.py | 22 - .../src/logica/ai/agents/config_service.py | 58 --- .../logica/ai/agents/exercise_generator.py | 103 ++++- .../src/logica/ai/agents/grading_assistant.py | 3 - apps/api/src/logica/ai/agents/guide_writer.py | 2 - .../logica/ai/agents/learning_analytics.py | 3 - apps/api/src/logica/ai/agents/models.py | 32 +- apps/api/src/logica/ai/agents/router.py | 48 +- .../ai/agents/rubric_topic_extractor.py | 26 ++ apps/api/src/logica/ai/agents/schemas.py | 27 +- apps/api/src/logica/ai/agents/tutor.py | 4 +- .../src/logica/ai/harness/prompts/__init__.py | 1 + .../templates/rubric_topic_extraction.v1.j2 | 27 ++ apps/api/src/logica/ai/harness/router.py | 1 + .../src/logica/modules/assignments/models.py | 27 +- .../logica/modules/assignments/repository.py | 13 +- .../src/logica/modules/assignments/router.py | 2 + .../src/logica/modules/assignments/schemas.py | 11 +- .../src/logica/modules/assignments/service.py | 53 ++- .../src/logica/modules/evaluations/models.py | 5 + .../src/logica/modules/evaluations/router.py | 1 + .../src/logica/modules/evaluations/schemas.py | 2 + .../src/logica/modules/evaluations/service.py | 5 +- .../src/logica/modules/exercises/models.py | 1 - .../api/src/logica/modules/grading/plugins.py | 19 - .../src/logica/modules/grading/registry.py | 2 - .../src/logica/modules/grading/sanitize.py | 1 - apps/api/src/logica/modules/guides/router.py | 10 + apps/api/src/logica/modules/guides/service.py | 18 + .../api/src/logica/modules/progress/models.py | 17 +- .../src/logica/modules/progress/repository.py | 33 +- .../api/src/logica/modules/progress/router.py | 26 -- .../src/logica/modules/progress/schemas.py | 15 +- .../src/logica/modules/progress/service.py | 26 +- apps/api/src/logica/modules/reports/models.py | 3 - .../src/logica/modules/reports/repository.py | 50 +- apps/api/src/logica/modules/reports/router.py | 5 +- .../api/src/logica/modules/reports/schemas.py | 3 +- .../api/src/logica/modules/reports/service.py | 31 +- .../modules/rubrics/document_extraction.py | 78 ++++ apps/api/src/logica/modules/rubrics/router.py | 47 +- .../api/src/logica/modules/rubrics/schemas.py | 16 + apps/api/src/logica/workers/settings.py | 9 +- .../evals/datasets/exercise_generation.yaml | 19 - .../integration/test_agent_code_integrity.py | 35 -- .../tests/integration/test_agent_config.py | 68 --- .../test_agent_exercise_generator.py | 94 ++-- .../test_agent_grading_assistant.py | 33 -- .../integration/test_agent_guide_writer.py | 29 -- .../test_agent_learning_analytics.py | 27 -- .../api/tests/integration/test_agent_tutor.py | 39 -- .../api/tests/integration/test_assignments.py | 94 ++++ apps/api/tests/integration/test_gradebook.py | 57 ++- apps/api/tests/integration/test_guides_api.py | 53 +++ .../integration/test_guides_autogeneration.py | 24 +- apps/api/tests/integration/test_progress.py | 40 -- apps/api/tests/integration/test_reports.py | 13 - .../test_rubric_document_extraction.py | 127 ++++++ apps/api/tests/unit/test_grading.py | 30 -- .../unit/test_rubric_document_extraction.py | 73 +++ apps/api/uv.lock | 52 +++ .../exercises/OrderLinesRenderer.tsx | 46 -- .../web/src/components/exercises/registry.tsx | 4 - .../components/exercises/renderers.test.tsx | 16 - .../src/components/layout/AppShell.test.tsx | 90 ++++ apps/web/src/components/layout/AppShell.tsx | 60 ++- .../features/admin/AcademicPeriodsPage.tsx | 106 ----- apps/web/src/features/teacher/AgentsTab.tsx | 98 ---- .../src/features/teacher/AssignmentsTab.tsx | 105 ++++- .../teacher/EvaluationBuilderPage.test.tsx | 123 +++++ .../teacher/EvaluationBuilderPage.tsx | 258 ++++++++++- .../teacher/ExerciseContentForm.test.tsx | 51 ++- .../features/teacher/ExerciseContentForm.tsx | 105 +++-- .../features/teacher/GradebookTab.test.tsx | 44 +- .../web/src/features/teacher/GradebookTab.tsx | 28 ++ .../src/features/teacher/GuidesTab.test.tsx | 30 ++ apps/web/src/features/teacher/GuidesTab.tsx | 52 ++- .../teacher/PendingAssignmentsPage.test.tsx | 62 +++ .../teacher/PendingAssignmentsPage.tsx | 50 ++ apps/web/src/features/teacher/ReportsTab.tsx | 23 +- .../src/features/teacher/RubricTab.test.tsx | 38 +- apps/web/src/features/teacher/RubricTab.tsx | 67 ++- .../teacher/TeacherGroupDetailPage.tsx | 3 - apps/web/src/lazyPages.tsx | 6 +- apps/web/src/lib/api/queries.ts | 2 - apps/web/src/lib/api/schema.d.ts | 427 ++++++++---------- apps/web/src/router.tsx | 7 +- 96 files changed, 2526 insertions(+), 1449 deletions(-) create mode 100644 apps/api/alembic/versions/024_quita_order_lines.py create mode 100644 apps/api/alembic/versions/025_quita_periodos_academicos.py create mode 100644 apps/api/alembic/versions/026_quita_agent_configs.py create mode 100644 apps/api/alembic/versions/027_porcentaje_evaluacion.py create mode 100644 apps/api/alembic/versions/028_asignaciones_examen_taller.py delete mode 100644 apps/api/src/logica/ai/agents/config_repository.py delete mode 100644 apps/api/src/logica/ai/agents/config_service.py create mode 100644 apps/api/src/logica/ai/agents/rubric_topic_extractor.py create mode 100644 apps/api/src/logica/ai/harness/prompts/templates/rubric_topic_extraction.v1.j2 create mode 100644 apps/api/src/logica/modules/rubrics/document_extraction.py delete mode 100644 apps/api/tests/integration/test_agent_config.py create mode 100644 apps/api/tests/integration/test_rubric_document_extraction.py create mode 100644 apps/api/tests/unit/test_rubric_document_extraction.py delete mode 100644 apps/web/src/components/exercises/OrderLinesRenderer.tsx create mode 100644 apps/web/src/components/layout/AppShell.test.tsx delete mode 100644 apps/web/src/features/admin/AcademicPeriodsPage.tsx delete mode 100644 apps/web/src/features/teacher/AgentsTab.tsx create mode 100644 apps/web/src/features/teacher/EvaluationBuilderPage.test.tsx create mode 100644 apps/web/src/features/teacher/PendingAssignmentsPage.test.tsx create mode 100644 apps/web/src/features/teacher/PendingAssignmentsPage.tsx diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py index 41d7814..a3481f0 100644 --- a/apps/api/alembic/env.py +++ b/apps/api/alembic/env.py @@ -8,7 +8,6 @@ # Import all module models here so Base.metadata is fully populated for autogenerate. from logica.ai.agents.models import ( # noqa: F401 - AgentConfig, CodeIntegrityAlert, CurriculumPlan, CurriculumPlanItem, @@ -41,11 +40,7 @@ ) from logica.modules.notifications.models import Notification # noqa: F401 from logica.modules.observability.models import ErrorLog # noqa: F401 -from logica.modules.progress.models import ( # noqa: F401 - AcademicPeriod, - Badge, - StudentBadge, -) +from logica.modules.progress.models import Badge, StudentBadge # noqa: F401 from logica.modules.reports.models import ReportJob # noqa: F401 from logica.modules.rubrics.models import RubricItem, RubricRun # noqa: F401 from logica.modules.users.models import Institution, PasswordResetToken, User # noqa: F401 diff --git a/apps/api/alembic/versions/024_quita_order_lines.py b/apps/api/alembic/versions/024_quita_order_lines.py new file mode 100644 index 0000000..b8599a3 --- /dev/null +++ b/apps/api/alembic/versions/024_quita_order_lines.py @@ -0,0 +1,61 @@ +"""quita el tipo de ejercicio order_lines + +Revision ID: 024 +Revises: 023 +Create Date: 2026-07-30 00:00:00.000000 + +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "024" +down_revision: str | None = "877d0f5d3ae7" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + +_OLD_VALUES = ( + "true_false", + "multiple_choice", + "fill_code", + "find_error", + "trace_variables", + "order_lines", + "argued_response", + "live_code", +) +_NEW_VALUES = ( + "true_false", + "multiple_choice", + "fill_code", + "find_error", + "trace_variables", + "argued_response", + "live_code", +) + + +def upgrade() -> None: + # Postgres no permite DROP VALUE de un enum, así que hay que recrear el + # tipo. La tabla `exercises` está vacía en producción a la fecha de esta + # migración, así que no hace falta backfill de filas con `order_lines`. + op.execute("ALTER TYPE exercise_type RENAME TO exercise_type_old") + new_values = ", ".join(f"'{v}'" for v in _NEW_VALUES) + op.execute(f"CREATE TYPE exercise_type AS ENUM ({new_values})") + op.execute( + "ALTER TABLE exercises ALTER COLUMN type TYPE exercise_type " + "USING type::text::exercise_type" + ) + op.execute("DROP TYPE exercise_type_old") + + +def downgrade() -> None: + op.execute("ALTER TYPE exercise_type RENAME TO exercise_type_old") + old_values = ", ".join(f"'{v}'" for v in _OLD_VALUES) + op.execute(f"CREATE TYPE exercise_type AS ENUM ({old_values})") + op.execute( + "ALTER TABLE exercises ALTER COLUMN type TYPE exercise_type " + "USING type::text::exercise_type" + ) + op.execute("DROP TYPE exercise_type_old") diff --git a/apps/api/alembic/versions/025_quita_periodos_academicos.py b/apps/api/alembic/versions/025_quita_periodos_academicos.py new file mode 100644 index 0000000..ff4c800 --- /dev/null +++ b/apps/api/alembic/versions/025_quita_periodos_academicos.py @@ -0,0 +1,53 @@ +"""quita periodos académicos + +Revision ID: 025 +Revises: 024 +Create Date: 2026-07-30 00:10:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "025" +down_revision: str | None = "024" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + # `curriculum_plan_items.period_id` era la única otra FK hacia + # `academic_periods` — apuntaba a una tabla inerte de la Fase 15 que ningún + # servicio leía ni escribía (ver `content/models.py::Topic.estimated_sessions`), + # así que se quita junto con la sección de periodos, sin backfill. + op.drop_column("curriculum_plan_items", "period_id") + op.drop_column("report_jobs", "period_id") + op.drop_table("academic_periods") + + +def downgrade() -> None: + op.create_table( + "academic_periods", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("institution_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column("report_jobs", sa.Column("period_id", sa.UUID(), nullable=True)) + op.add_column("curriculum_plan_items", sa.Column("period_id", sa.UUID(), nullable=True)) diff --git a/apps/api/alembic/versions/026_quita_agent_configs.py b/apps/api/alembic/versions/026_quita_agent_configs.py new file mode 100644 index 0000000..15fd953 --- /dev/null +++ b/apps/api/alembic/versions/026_quita_agent_configs.py @@ -0,0 +1,64 @@ +"""quita el interruptor por grupo de agentes IA + +Revision ID: 026 +Revises: 025 +Create Date: 2026-07-30 00:20:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "026" +down_revision: str | None = "025" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + # Se quita solo el interruptor on/off por grupo (RF-30): los agentes en sí + # (AgentName, TASK_TIERS, prompts) siguen intactos y ahora quedan siempre + # activos — ver `docstring` de `AgentName` en ai/agents/models.py. + op.drop_table("agent_configs") + op.execute("DROP TYPE agent_name") + + +def downgrade() -> None: + # `sa.Enum(...)` en la columna de abajo crea el tipo `agent_name` en + # Postgres automáticamente al correr `create_table` — no hace falta un + # `CREATE TYPE` manual aparte. + op.create_table( + "agent_configs", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("group_id", sa.UUID(), nullable=False), + sa.Column( + "agent_name", + sa.Enum( + "progressive_hint", + "exercise_generation", + "grading_suggestion", + "summarize_group", + "code_integrity", + "guide_generation", + name="agent_name", + ), + nullable=False, + ), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("group_id", "agent_name", name="uq_agent_config_group"), + ) diff --git a/apps/api/alembic/versions/027_porcentaje_evaluacion.py b/apps/api/alembic/versions/027_porcentaje_evaluacion.py new file mode 100644 index 0000000..227457c --- /dev/null +++ b/apps/api/alembic/versions/027_porcentaje_evaluacion.py @@ -0,0 +1,25 @@ +"""agrega weight_percent a evaluations + +Revision ID: 027 +Revises: 026 +Create Date: 2026-07-31 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "027" +down_revision: str | None = "026" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("evaluations", sa.Column("weight_percent", sa.Float(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("evaluations", "weight_percent") diff --git a/apps/api/alembic/versions/028_asignaciones_examen_taller.py b/apps/api/alembic/versions/028_asignaciones_examen_taller.py new file mode 100644 index 0000000..67a0f1c --- /dev/null +++ b/apps/api/alembic/versions/028_asignaciones_examen_taller.py @@ -0,0 +1,47 @@ +"""asignaciones: agrega examen (evaluation_id) y taller (guide_id) + +Revision ID: 028 +Revises: 027 +Create Date: 2026-07-31 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "028" +down_revision: str | None = "027" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("assignments", sa.Column("evaluation_id", sa.UUID(), nullable=True)) + op.add_column("assignments", sa.Column("guide_id", sa.UUID(), nullable=True)) + op.create_foreign_key( + "fk_assignments_evaluation_id", "assignments", "evaluations", ["evaluation_id"], ["id"] + ) + op.create_foreign_key("fk_assignments_guide_id", "assignments", "guides", ["guide_id"], ["id"]) + + op.drop_constraint("ck_assignment_topic_xor_exercise", "assignments", type_="check") + op.create_check_constraint( + "ck_assignment_exactly_one_target", + "assignments", + "(topic_id IS NOT NULL)::int + (exercise_id IS NOT NULL)::int + " + "(evaluation_id IS NOT NULL)::int + (guide_id IS NOT NULL)::int = 1", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_assignment_exactly_one_target", "assignments", type_="check") + op.create_check_constraint( + "ck_assignment_topic_xor_exercise", + "assignments", + "(topic_id IS NULL) <> (exercise_id IS NULL)", + ) + op.drop_constraint("fk_assignments_guide_id", "assignments", type_="foreignkey") + op.drop_constraint("fk_assignments_evaluation_id", "assignments", type_="foreignkey") + op.drop_column("assignments", "guide_id") + op.drop_column("assignments", "evaluation_id") diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index afa30d7..76abb36 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -33,6 +33,8 @@ dependencies = [ "prometheus-fastapi-instrumentator>=7.0", "pyyaml>=6.0", "slowapi>=0.1.10", + "pypdf>=5.0", + "python-docx>=1.1", ] [dependency-groups] diff --git a/apps/api/scripts/seed.py b/apps/api/scripts/seed.py index 7852cc6..46b9262 100644 --- a/apps/api/scripts/seed.py +++ b/apps/api/scripts/seed.py @@ -278,24 +278,6 @@ async def get_or_create_exercise( "expected_trace": [{"contador": 2}, {"contador": 4}, {"contador": 6}], }, ) - await get_or_create_exercise( - pseint.id, - "Ordenar: cálculo de un promedio", - ExerciseType.order_lines, - { - "statement": ( - "Ordena las líneas para que el algoritmo calcule correctamente " - "el promedio de dos notas." - ), - "lines": [ - "Escribir promedio", - "promedio <- (n1 + n2) / 2", - "Leer n1, n2", - "Definir n1, n2, promedio Como Real", - ], - "correct_order": [3, 2, 1, 0], - }, - ) await get_or_create_exercise( pseint.id, "Argumenta: Mientras vs. Repetir", diff --git a/apps/api/src/logica/ai/agents/code_integrity.py b/apps/api/src/logica/ai/agents/code_integrity.py index 68f904a..1c7b3aa 100644 --- a/apps/api/src/logica/ai/agents/code_integrity.py +++ b/apps/api/src/logica/ai/agents/code_integrity.py @@ -11,8 +11,7 @@ from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled -from logica.ai.agents.models import AgentName, CodeIntegrityAlert +from logica.ai.agents.models import CodeIntegrityAlert from logica.ai.agents.repository import create_code_integrity_alert from logica.ai.harness.structured import complete_structured from logica.core.errors import NotFoundError, PermissionDeniedError, ValidationDomainError @@ -56,7 +55,6 @@ async def check_integrity( if evaluation is None or evaluation.institution_id != teacher.institution_id: raise NotFoundError("Evaluación no encontrada") await get_group_with_access(db, teacher, evaluation.group_id) - await ensure_agent_enabled(db, evaluation.group_id, AgentName.code_integrity) answer = await get_answer_by_id(db, answer_id) if answer is None: diff --git a/apps/api/src/logica/ai/agents/config_repository.py b/apps/api/src/logica/ai/agents/config_repository.py deleted file mode 100644 index edfb88b..0000000 --- a/apps/api/src/logica/ai/agents/config_repository.py +++ /dev/null @@ -1,22 +0,0 @@ -import uuid - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from logica.ai.agents.models import AgentConfig, AgentName - - -async def get_agent_config( - db: AsyncSession, group_id: uuid.UUID, agent_name: AgentName -) -> AgentConfig | None: - stmt = select(AgentConfig).where( - AgentConfig.group_id == group_id, AgentConfig.agent_name == agent_name - ) - result = await db.execute(stmt) - return result.scalar_one_or_none() - - -async def list_agent_configs_for_group(db: AsyncSession, group_id: uuid.UUID) -> list[AgentConfig]: - stmt = select(AgentConfig).where(AgentConfig.group_id == group_id) - result = await db.execute(stmt) - return list(result.scalars().all()) diff --git a/apps/api/src/logica/ai/agents/config_service.py b/apps/api/src/logica/ai/agents/config_service.py deleted file mode 100644 index 3401ab9..0000000 --- a/apps/api/src/logica/ai/agents/config_service.py +++ /dev/null @@ -1,58 +0,0 @@ -import uuid - -from sqlalchemy.ext.asyncio import AsyncSession - -from logica.ai.agents.config_repository import get_agent_config, list_agent_configs_for_group -from logica.ai.agents.models import AgentConfig, AgentName -from logica.core.errors import PermissionDeniedError -from logica.modules.groups.service import get_group_with_access -from logica.modules.users.models import Role, User - - -class AgentDisabledError(PermissionDeniedError): - pass - - -async def is_agent_enabled(db: AsyncSession, group_id: uuid.UUID, agent_name: AgentName) -> bool: - """Absence of a row means enabled — see AgentConfig docstring.""" - config = await get_agent_config(db, group_id, agent_name) - return config is None or config.enabled - - -async def ensure_agent_enabled( - db: AsyncSession, group_id: uuid.UUID, agent_name: AgentName -) -> None: - if not await is_agent_enabled(db, group_id, agent_name): - raise AgentDisabledError( - f"El agente '{agent_name.name}' está desactivado para este grupo. " - "El docente puede seguir haciendo esta tarea manualmente." - ) - - -async def list_agent_status( - db: AsyncSession, user: User, group_id: uuid.UUID -) -> dict[AgentName, bool]: - await get_group_with_access(db, user, group_id) # raises if no access at all - configs = {c.agent_name: c for c in await list_agent_configs_for_group(db, group_id)} - return {name: (configs[name].enabled if name in configs else True) for name in AgentName} - - -async def set_agent_enabled( - db: AsyncSession, teacher: User, group_id: uuid.UUID, agent_name: AgentName, enabled: bool -) -> AgentConfig: - if teacher.role not in (Role.teacher, Role.admin): - raise PermissionDeniedError( - "Solo un docente o administrador puede activar/desactivar agentes" - ) - await get_group_with_access(db, teacher, group_id) - - config = await get_agent_config(db, group_id, agent_name) - if config is None: - config = AgentConfig(group_id=group_id, agent_name=agent_name, enabled=enabled) - db.add(config) - else: - config.enabled = enabled - - await db.flush() - await db.refresh(config) - return config diff --git a/apps/api/src/logica/ai/agents/exercise_generator.py b/apps/api/src/logica/ai/agents/exercise_generator.py index 3b42136..e7975e3 100644 --- a/apps/api/src/logica/ai/agents/exercise_generator.py +++ b/apps/api/src/logica/ai/agents/exercise_generator.py @@ -14,8 +14,6 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled -from logica.ai.agents.models import AgentName from logica.ai.harness.structured import complete_structured from logica.ai.skills.retrieve_context import retrieve_context from logica.core.errors import ( @@ -24,6 +22,7 @@ NotFoundError, PermissionDeniedError, ServiceUnavailableError, + ValidationDomainError, ) from logica.modules.content.models import Topic from logica.modules.content.repository import get_language, get_topic @@ -34,7 +33,12 @@ ExerciseType, TopicExercise, ) -from logica.modules.exercises.repository import get_topic_exercise_link, list_exercises +from logica.modules.exercises.repository import ( + get_exercise, + get_topic_exercise_link, + list_exercises, + list_topic_ids_for_exercise, +) from logica.modules.groups.service import get_group_with_access from logica.modules.guides.models import Guide, GuidesFolder from logica.modules.users.models import Role, User @@ -64,10 +68,6 @@ '{"title": "...", "content": {"statement": "...", "code": "...", ' '"expected_trace": [{"variable": "valor"}]}}' ), - ExerciseType.order_lines: ( - '{"title": "...", "content": {"statement": "...", "lines": ["...", "..."], ' - '"correct_order": [0, 1]}}' - ), ExerciseType.argued_response: '{"title": "...", "content": {"prompt": "..."}}', ExerciseType.live_code: ( '{"title": "...", "content": {"language": "python", "version": "3.10.0", ' @@ -90,17 +90,23 @@ async def _complete_exercise( language_name: str, exercise_type: ExerciseType, guide_excerpt: str = "", + extra_similar: Sequence[str] = (), ) -> ExerciseGenerationOutput: """Solo la llamada al modelo. Está separada de la persistencia a propósito: el lote de la Fase 17 necesita envolver los INSERTs en un SAVEPOINT, y meter también esta parte adentro revertiría la fila de `ai_interactions` que `complete_structured` ya escribió antes de fallar — justo el registro que - explica por qué falló.""" + explica por qué falló. + + `extra_similar` es para el caso de variantes (§ítem 21): además de lo que + ya hay en el banco, cada variante nueva del lote debe conocer las que ya + se generaron en esa misma tanda, o el modelo tiende a repetir la primera.""" reference_context = await retrieve_context( db, teacher.institution_id, f"{topic.name} {exercise_type.value}", topic_id=topic.id ) existing = await list_exercises(db, teacher.institution_id, topic_id=topic.id) - similar_exercises = "\n".join(f"- {e.title}" for e in existing[:5]) + similar_titles = [e.title for e in existing[:5]] + list(extra_similar) + similar_exercises = "\n".join(f"- {t}" for t in similar_titles) return await complete_structured( db, @@ -175,7 +181,6 @@ async def generate_exercise_draft( raise PermissionDeniedError("Solo un docente o administrador puede generar ejercicios") await get_group_with_access(db, teacher, group_id) - await ensure_agent_enabled(db, group_id, AgentName.exercise_generator) topic = await get_topic(db, topic_id) if topic is None or topic.institution_id != teacher.institution_id: @@ -222,7 +227,6 @@ async def generate_exercises_for_guide( folder = await db.get(GuidesFolder, guide.folder_id) if folder is None: raise NotFoundError("Carpeta de guías no encontrada") - await ensure_agent_enabled(db, folder.group_id, AgentName.exercise_generator) topic = await get_topic(db, guide.topic_id) if topic is None or topic.institution_id != teacher.institution_id: @@ -288,3 +292,80 @@ async def generate_exercises_for_guide( created=len(created), ) return created + + +async def generate_exercise_variants( + db: AsyncSession, + redis: Redis, + teacher: User, + *, + exercise_id: uuid.UUID, + count: int, +) -> list[ExerciseGenerationOutput]: + """Ítem 21: N variantes de un ejercicio existente (mismo tipo/tema), + devueltas como *preview* — nada se persiste acá. El docente las revisa, + edita si quiere, y solo las que acepta se crean como ejercicios reales + (vía el `POST /exercises` que ya existe) antes de adjuntarlas a la + evaluación que está armando. + + Aislamiento de fallos por variante, mismo criterio que + `generate_exercises_for_guide`: si una del lote falla (casi siempre + `StructuredOutputError`), las demás igual se devuelven — presupuesto + agotado o proveedores caídos sí se propagan, porque repetir el resto + solo repetiría el mismo fallo.""" + if teacher.role not in (Role.teacher, Role.admin): + raise PermissionDeniedError("Solo un docente o administrador puede generar variantes") + + exercise = await get_exercise(db, exercise_id) + if exercise is None or exercise.institution_id != teacher.institution_id: + raise NotFoundError("Ejercicio no encontrado") + + topic_ids = await list_topic_ids_for_exercise(db, exercise_id) + if not topic_ids: + raise ValidationDomainError( + "Este ejercicio no está asociado a ningún tema", + hint="Solo se pueden generar variantes de ejercicios que ya están en un tema.", + ) + topic = await get_topic(db, topic_ids[0]) + if topic is None: + raise NotFoundError("Tema no encontrado") + language = await get_language(db, exercise.language_id) + if language is None: + raise NotFoundError("Lenguaje no encontrado") + + variants: list[ExerciseGenerationOutput] = [] + similar_so_far = [exercise.title] + for _ in range(count): + try: + output = await _complete_exercise( + db, + redis, + teacher, + topic=topic, + language_name=language.name, + exercise_type=exercise.type, + extra_similar=similar_so_far, + ) + except (ConflictError, ServiceUnavailableError): + logger.warning( + "exercise_variant_batch_aborted", + exercise_id=str(exercise_id), + created=len(variants), + ) + raise + except LogicaError as exc: + logger.warning( + "exercise_variant_batch_item_failed", exercise_id=str(exercise_id), error=str(exc) + ) + continue + + variants.append(output) + similar_so_far.append(output.title) + + logger.info( + "exercise_variants_generated", + exercise_id=str(exercise_id), + requested=count, + generated=len(variants), + ) + return variants diff --git a/apps/api/src/logica/ai/agents/grading_assistant.py b/apps/api/src/logica/ai/agents/grading_assistant.py index b2582d4..fd5fb04 100644 --- a/apps/api/src/logica/ai/agents/grading_assistant.py +++ b/apps/api/src/logica/ai/agents/grading_assistant.py @@ -12,8 +12,6 @@ from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled -from logica.ai.agents.models import AgentName from logica.ai.harness.structured import complete_structured from logica.core.errors import NotFoundError, PermissionDeniedError from logica.modules.evaluations.models import EvaluationAnswer @@ -52,7 +50,6 @@ async def suggest_grade( if evaluation is None or evaluation.institution_id != teacher.institution_id: raise NotFoundError("Evaluación no encontrada") await get_group_with_access(db, teacher, evaluation.group_id) - await ensure_agent_enabled(db, evaluation.group_id, AgentName.grading_assistant) answer = await get_answer_by_id(db, answer_id) if answer is None: diff --git a/apps/api/src/logica/ai/agents/guide_writer.py b/apps/api/src/logica/ai/agents/guide_writer.py index bc4d2c9..73d6bdb 100644 --- a/apps/api/src/logica/ai/agents/guide_writer.py +++ b/apps/api/src/logica/ai/agents/guide_writer.py @@ -15,7 +15,6 @@ from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled from logica.ai.agents.models import AgentName from logica.ai.harness.prompts import active_version from logica.ai.harness.structured import complete_structured @@ -159,7 +158,6 @@ async def _write_all_sections( folder = await db.get(GuidesFolder, guide.folder_id) if folder is None: raise NotFoundError("Carpeta de guías no encontrada") - await ensure_agent_enabled(db, folder.group_id, AgentName.guide_writer) teacher = await db.get(User, guide.created_by_id) if teacher is None: diff --git a/apps/api/src/logica/ai/agents/learning_analytics.py b/apps/api/src/logica/ai/agents/learning_analytics.py index 5ed4cca..64c4946 100644 --- a/apps/api/src/logica/ai/agents/learning_analytics.py +++ b/apps/api/src/logica/ai/agents/learning_analytics.py @@ -9,8 +9,6 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled -from logica.ai.agents.models import AgentName from logica.ai.skills.summarize_group import generate_group_summary from logica.core.errors import NotFoundError, PermissionDeniedError from logica.modules.evaluations.models import PracticeSubmission @@ -72,7 +70,6 @@ async def summarize_group( ) await get_group_with_access(db, teacher, group_id) - await ensure_agent_enabled(db, group_id, AgentName.learning_analytics) group = await get_group(db, group_id) if group is None: diff --git a/apps/api/src/logica/ai/agents/models.py b/apps/api/src/logica/ai/agents/models.py index 2b67084..78d10b9 100644 --- a/apps/api/src/logica/ai/agents/models.py +++ b/apps/api/src/logica/ai/agents/models.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime -from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, Text, UniqueConstraint +from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -19,9 +19,8 @@ class AgentName(enum.StrEnum): Ese invariante lo verifica `tests/unit/test_agent_registry.py`: agregar un miembro acá obliga a agregar su tier y su plantilla de prompt en el mismo commit. No es burocracia — `curriculum_planner` vivió en este enum sin - plantilla ni tier, así que aparecía en `GET /ai/groups/{id}/agents` como un - agente activo que el docente podía apagar y que, si algo lo hubiera invocado, - habría muerto con `TemplateNotFound` en runtime.""" + plantilla ni tier, y si algo lo hubiera invocado habría muerto con + `TemplateNotFound` en runtime.""" tutor = "progressive_hint" exercise_generator = "exercise_generation" @@ -31,24 +30,6 @@ class AgentName(enum.StrEnum): guide_writer = "guide_generation" -class AgentConfig(UUIDPkMixin, TimestampMixin, Base): - """Per-group on/off switch for each agent (RF-30). Absence of a row for - a (group_id, agent_name) pair means "enabled" — the default — so a group - only needs a row once a teacher actually disables something, rather - than seeding 5 rows for every group at creation time.""" - - __tablename__ = "agent_configs" - __table_args__ = (UniqueConstraint("group_id", "agent_name", name="uq_agent_config_group"),) - - group_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("groups.id"), nullable=False, index=True - ) - agent_name: Mapped[AgentName] = mapped_column( - Enum(AgentName, name="agent_name"), nullable=False - ) - enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - - class TutorMessageRole(enum.StrEnum): student = "student" tutor = "tutor" @@ -145,9 +126,7 @@ class CurriculumPlan(UUIDPkMixin, TenantMixin, TimestampMixin, Base): class CurriculumPlanItem(UUIDPkMixin, TimestampMixin, Base): - """Un tema asignado (o no) a un periodo dentro de un `CurriculumPlan`. - `period_id = NULL` significa "no alcanza" — el scheduler simbólico - (curriculum_scheduler.py) lo deja así en vez de inventar una fecha.""" + """Un tema dentro de un `CurriculumPlan`, en el orden propuesto.""" __tablename__ = "curriculum_plan_items" @@ -160,8 +139,5 @@ class CurriculumPlanItem(UUIDPkMixin, TimestampMixin, Base): topic_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id"), nullable=False ) - period_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("academic_periods.id"), nullable=True - ) position: Mapped[int] = mapped_column(Integer, nullable=False) estimated_sessions: Mapped[int] = mapped_column(Integer, nullable=False) diff --git a/apps/api/src/logica/ai/agents/router.py b/apps/api/src/logica/ai/agents/router.py index f801313..741eea4 100644 --- a/apps/api/src/logica/ai/agents/router.py +++ b/apps/api/src/logica/ai/agents/router.py @@ -7,19 +7,17 @@ from logica.ai.agents import ( code_integrity, - config_service, exercise_generator, grading_assistant, learning_analytics, pending_approvals, tutor, ) -from logica.ai.agents.models import AgentName from logica.ai.agents.repository import list_alerts_for_evaluation from logica.ai.agents.schemas import ( - AgentConfigOut, - AgentToggleRequest, ExerciseGenerateRequest, + ExerciseVariantOut, + ExerciseVariantsRequest, GradingSuggestionOut, GradingSuggestionRequest, GroupSummaryOut, @@ -49,29 +47,6 @@ router = APIRouter(prefix="/ai", tags=["ai-agents"]) -@router.get("/groups/{group_id}/agents", response_model=list[AgentConfigOut]) -async def list_agents( - group_id: uuid.UUID, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -) -> list[AgentConfigOut]: - statuses = await config_service.list_agent_status(db, user, group_id) - return [AgentConfigOut(agent_name=name, enabled=enabled) for name, enabled in statuses.items()] - - -@router.put("/groups/{group_id}/agents/{agent_name}", response_model=AgentConfigOut) -async def toggle_agent( - group_id: uuid.UUID, - agent_name: AgentName, - payload: AgentToggleRequest, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -) -> AgentConfigOut: - config = await config_service.set_agent_enabled(db, user, group_id, agent_name, payload.enabled) - await db.commit() - return AgentConfigOut(agent_name=config.agent_name, enabled=config.enabled) - - @router.post("/tutor/hint", response_model=TutorMessageOut, status_code=201) @user_limiter.limit("30/minute") async def ask_tutor_hint( @@ -129,6 +104,25 @@ async def generate_exercise( return ExerciseOut.model_validate(exercise) +@router.post("/exercises/{exercise_id}/variants", response_model=list[ExerciseVariantOut]) +@user_limiter.limit("10/minute") +async def generate_exercise_variants( + request: Request, + exercise_id: uuid.UUID, + payload: ExerciseVariantsRequest, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +) -> list[ExerciseVariantOut]: + """Preview puro (ítem 21): no crea nada, el docente acepta/edita/descarta + cada variante y solo las aceptadas se crean vía `POST /exercises`.""" + variants = await exercise_generator.generate_exercise_variants( + db, redis, user, exercise_id=exercise_id, count=payload.count + ) + await db.commit() + return [ExerciseVariantOut(title=v.title, content=v.content) for v in variants] + + @router.post("/guides/generate", response_model=GuideOut, status_code=202) @user_limiter.limit("20/minute") async def generate_guide( diff --git a/apps/api/src/logica/ai/agents/rubric_topic_extractor.py b/apps/api/src/logica/ai/agents/rubric_topic_extractor.py new file mode 100644 index 0000000..b345a64 --- /dev/null +++ b/apps/api/src/logica/ai/agents/rubric_topic_extractor.py @@ -0,0 +1,26 @@ +"""Interpreta el texto crudo extraído de una rúbrica institucional (PDF/DOCX, +ver `modules/rubrics/document_extraction.py`) y propone una lista estructurada +de temas para prellenar el formulario de rúbrica — un paso de *preview* puro, +sin interruptor de agente por grupo (como `pedagogical_feedback`), porque no +escribe nada en la DB del docente hasta que él confirma y lanza la corrida.""" + +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from logica.ai.harness.structured import complete_structured +from logica.modules.rubrics.schemas import DocumentExtractionResult +from logica.modules.users.models import User + + +async def extract_topics_from_text( + db: AsyncSession, redis: Redis, *, user: User, raw_text: str +) -> DocumentExtractionResult: + return await complete_structured( + db, + redis, + task="rubric_topic_extraction", + user=user, + template_vars={"raw_text": raw_text}, + untrusted_input=raw_text, + output_model=DocumentExtractionResult, + ) diff --git a/apps/api/src/logica/ai/agents/schemas.py b/apps/api/src/logica/ai/agents/schemas.py index 4a5ae7c..ddd23a8 100644 --- a/apps/api/src/logica/ai/agents/schemas.py +++ b/apps/api/src/logica/ai/agents/schemas.py @@ -1,22 +1,14 @@ import uuid from datetime import datetime +from typing import Any from pydantic import BaseModel, Field, field_validator -from logica.ai.agents.models import AgentName, TutorMessageRole +from logica.ai.agents.models import TutorMessageRole from logica.modules.exercises.models import ExerciseType from logica.modules.guides.schemas import GuideOut -class AgentConfigOut(BaseModel): - agent_name: AgentName - enabled: bool - - -class AgentToggleRequest(BaseModel): - enabled: bool - - class TutorHintRequest(BaseModel): group_id: uuid.UUID exercise_id: uuid.UUID @@ -45,6 +37,21 @@ class ExerciseGenerateRequest(BaseModel): exercise_type: ExerciseType +class ExerciseVariantsRequest(BaseModel): + # Tope de 4: cada variante es una llamada al modelo, y esto corre en el + # request path (a diferencia del lote de guías) — 4 mantiene la espera + # del docente razonable. + count: int = Field(default=3, ge=1, le=4) + + +class ExerciseVariantOut(BaseModel): + """Preview puro: no tiene `id` porque no se persiste hasta que el docente + la acepta (vía el `POST /exercises` que ya existe).""" + + title: str + content: dict[str, Any] + + class GuideExercisesRequest(BaseModel): # Tope de 6: son 6 llamadas al modelo en un mismo lote, y el free tier de # Groq limita peticiones por minuto. Sin tope, pedir los 8 tipos de una guía diff --git a/apps/api/src/logica/ai/agents/tutor.py b/apps/api/src/logica/ai/agents/tutor.py index fd59813..76a9bc4 100644 --- a/apps/api/src/logica/ai/agents/tutor.py +++ b/apps/api/src/logica/ai/agents/tutor.py @@ -9,8 +9,7 @@ from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled -from logica.ai.agents.models import AgentName, TutorMessage, TutorMessageRole +from logica.ai.agents.models import TutorMessage, TutorMessageRole from logica.ai.agents.repository import create_tutor_message, list_tutor_messages from logica.ai.rag.retriever import retrieve from logica.ai.skills.progressive_hint import generate_progressive_hint @@ -52,7 +51,6 @@ async def ask_hint( raise PermissionDeniedError("Solo un estudiante puede pedir una pista al tutor") await get_group_with_access(db, student, group_id) - await ensure_agent_enabled(db, group_id, AgentName.tutor) exercise = await _get_enabled_exercise(db, student.institution_id, group_id, exercise_id) diff --git a/apps/api/src/logica/ai/harness/prompts/__init__.py b/apps/api/src/logica/ai/harness/prompts/__init__.py index c797b26..7f23fbb 100644 --- a/apps/api/src/logica/ai/harness/prompts/__init__.py +++ b/apps/api/src/logica/ai/harness/prompts/__init__.py @@ -37,6 +37,7 @@ "grading_suggestion": 1, "code_integrity": 1, "guide_generation": 1, + "rubric_topic_extraction": 1, } _DEFAULT_VERSION = 1 diff --git a/apps/api/src/logica/ai/harness/prompts/templates/rubric_topic_extraction.v1.j2 b/apps/api/src/logica/ai/harness/prompts/templates/rubric_topic_extraction.v1.j2 new file mode 100644 index 0000000..07e7021 --- /dev/null +++ b/apps/api/src/logica/ai/harness/prompts/templates/rubric_topic_extraction.v1.j2 @@ -0,0 +1,27 @@ +Eres un asistente que ayuda a un docente a digitalizar la rúbrica/temario oficial de +su institución. A continuación tienes el texto crudo extraído de un documento PDF o +Word (puede venir en forma de tabla, con columnas como unidad, tema, nivel u horas, +unidas con " | " por fila; el formato varía entre instituciones). + +Identifica cada tema o subtema que el documento pide enseñar, en el orden en que +aparecen. Para cada uno, infiere: +- "topic_name": el nombre del tema, corto y claro (sin el número de unidad ni la + numeración, solo el nombre del contenido). +- "level": "basico", "intermedio" o "avanzado" — si el documento no lo indica + explícitamente, infiere por la posición (temas al inicio del documento suelen ser + básicos) o usa "basico" por defecto. +- "order_index": la posición del tema en el documento, empezando en 0. + +Ignora encabezados, pies de página, nombres de la institución, fechas y cualquier +texto que no sea un tema de contenido a enseñar. + +Texto del documento: +{{ raw_text }} + +Responde ÚNICAMENTE con un objeto JSON válido con esta forma exacta (sin texto +adicional antes o después): +{"items": [{"topic_name": "...", "level": "basico", "order_index": 0}]} + +{% if previous_error %} +{{ previous_error }} +{% endif %} diff --git a/apps/api/src/logica/ai/harness/router.py b/apps/api/src/logica/ai/harness/router.py index 153c923..1bb9680 100644 --- a/apps/api/src/logica/ai/harness/router.py +++ b/apps/api/src/logica/ai/harness/router.py @@ -47,6 +47,7 @@ "exercise_generation": "capable", "grading_suggestion": "capable", "code_integrity": "capable", + "rubric_topic_extraction": "capable", # Fase 16: una guía es material de referencia que el estudiante lee tal cual # (a diferencia de una pista, que es desechable), así que va al tier capaz # aunque cueste más — el docente igual la revisa, pero corregir prosa mala diff --git a/apps/api/src/logica/modules/assignments/models.py b/apps/api/src/logica/modules/assignments/models.py index 83ec3e2..1420bef 100644 --- a/apps/api/src/logica/modules/assignments/models.py +++ b/apps/api/src/logica/modules/assignments/models.py @@ -23,20 +23,22 @@ class Assignment(UUIDPkMixin, TenantMixin, TimestampMixin, Base): """Lo que un docente le pide a un grupo, con fecha de entrega. - Apunta a un tema **o** a un ejercicio, nunca a ambos: asignar "el tema de - ciclos" y "el ejercicio 47" son las dos granularidades que el docente usa, y - un `CheckConstraint` las mantiene excluyentes en vez de dejar que una fila - con ambas signifique lo que cada consulta quiera. + Apunta a exactamente uno de cuatro destinos — tema, ejercicio, evaluación + (examen) o guía (taller) — nunca a más de uno: son las granularidades que + el docente usa para pedir algo, y un `CheckConstraint` las mantiene + excluyentes en vez de dejar que una fila con varias signifique lo que cada + consulta quiera. - Sin `ON DELETE CASCADE` hacia temas o ejercicios: borrar un ejercicio no + Sin `ON DELETE CASCADE` hacia ninguno de los cuatro: borrar el objetivo no debería borrar en silencio el registro de que fue asignado. """ __tablename__ = "assignments" __table_args__ = ( CheckConstraint( - "(topic_id IS NULL) <> (exercise_id IS NULL)", - name="ck_assignment_topic_xor_exercise", + "(topic_id IS NOT NULL)::int + (exercise_id IS NOT NULL)::int + " + "(evaluation_id IS NOT NULL)::int + (guide_id IS NOT NULL)::int = 1", + name="ck_assignment_exactly_one_target", ), ) @@ -53,6 +55,17 @@ class Assignment(UUIDPkMixin, TenantMixin, TimestampMixin, Base): exercise_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("exercises.id"), nullable=True ) + # Examen: la asignación apunta a la evaluación en sí, no a sus ejercicios + # sueltos — "cumplida" se resuelve contra si ya la presentó. + evaluation_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("evaluations.id"), nullable=True + ) + # Taller: una guía de clase. No hay señal de "la leyó" en la plataforma + # todavía, así que "cumplida" queda siempre en falso para este destino — + # ver `service.py::list_my_assignments`. + guide_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("guides.id"), nullable=True + ) # Nullable: "para cuando puedas" es una asignación válida, y forzar una # fecha inventada haría que el dashboard mostrara vencimientos falsos. due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/apps/api/src/logica/modules/assignments/repository.py b/apps/api/src/logica/modules/assignments/repository.py index e7f1159..0f791ff 100644 --- a/apps/api/src/logica/modules/assignments/repository.py +++ b/apps/api/src/logica/modules/assignments/repository.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from logica.modules.assignments.models import Assignment -from logica.modules.evaluations.models import PracticeSubmission +from logica.modules.evaluations.models import AttemptStatus, EvaluationAttempt, PracticeSubmission from logica.modules.exercises.models import TopicExercise from logica.modules.groups.models import Group, GroupMembership @@ -73,3 +73,14 @@ async def solved_exercise_ids(db: AsyncSession, student_id: uuid.UUID) -> set[uu PracticeSubmission.correct.is_(True), ) return set((await db.execute(stmt)).scalars().all()) + + +async def submitted_evaluation_ids(db: AsyncSession, student_id: uuid.UUID) -> set[uuid.UUID]: + """Exámenes que el estudiante ya presentó (§ítem 21, "taller/examen/ + ejercicios" como destinos de asignación). Igual patrón que + `solved_exercise_ids`: una sola consulta para todas sus asignaciones.""" + stmt = select(EvaluationAttempt.evaluation_id).where( + EvaluationAttempt.student_id == student_id, + EvaluationAttempt.status == AttemptStatus.submitted, + ) + return set((await db.execute(stmt)).scalars().all()) diff --git a/apps/api/src/logica/modules/assignments/router.py b/apps/api/src/logica/modules/assignments/router.py index 64a2641..42f2f6a 100644 --- a/apps/api/src/logica/modules/assignments/router.py +++ b/apps/api/src/logica/modules/assignments/router.py @@ -35,6 +35,8 @@ async def create_assignment( title=payload.title, topic_id=payload.topic_id, exercise_id=payload.exercise_id, + evaluation_id=payload.evaluation_id, + guide_id=payload.guide_id, due_at=payload.due_at, ) await db.commit() diff --git a/apps/api/src/logica/modules/assignments/schemas.py b/apps/api/src/logica/modules/assignments/schemas.py index fb98890..bb9f041 100644 --- a/apps/api/src/logica/modules/assignments/schemas.py +++ b/apps/api/src/logica/modules/assignments/schemas.py @@ -8,6 +8,8 @@ class AssignmentCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=200) topic_id: uuid.UUID | None = None exercise_id: uuid.UUID | None = None + evaluation_id: uuid.UUID | None = None + guide_id: uuid.UUID | None = None due_at: datetime | None = None @model_validator(mode="after") @@ -15,8 +17,9 @@ def exactly_one_target(self) -> "AssignmentCreateRequest": """Mismo invariante que el `CheckConstraint` de la tabla. Se valida acá también para que el docente reciba un 422 explicativo en vez del error de integridad crudo de Postgres.""" - if (self.topic_id is None) == (self.exercise_id is None): - raise ValueError("Asigna un tema o un ejercicio, no ambos ni ninguno") + targets = [self.topic_id, self.exercise_id, self.evaluation_id, self.guide_id] + if sum(t is not None for t in targets) != 1: + raise ValueError("Asigna un tema, un ejercicio, un examen o un taller — solo uno") return self @@ -34,6 +37,8 @@ class AssignmentOut(BaseModel): title: str topic_id: uuid.UUID | None exercise_id: uuid.UUID | None + evaluation_id: uuid.UUID | None + guide_id: uuid.UUID | None due_at: datetime | None created_at: datetime @@ -54,6 +59,8 @@ class StudentAssignmentOut(BaseModel): title: str topic_id: uuid.UUID | None exercise_id: uuid.UUID | None + evaluation_id: uuid.UUID | None + guide_id: uuid.UUID | None due_at: datetime | None done: bool # Cuántos ejercicios cubre y cuántos van; para un ejercicio suelto es 1 y 0/1. diff --git a/apps/api/src/logica/modules/assignments/service.py b/apps/api/src/logica/modules/assignments/service.py index 55357a9..2b012bc 100644 --- a/apps/api/src/logica/modules/assignments/service.py +++ b/apps/api/src/logica/modules/assignments/service.py @@ -18,8 +18,10 @@ from logica.modules.assignments.models import Assignment from logica.modules.assignments.schemas import StudentAssignmentOut from logica.modules.content.repository import get_topic +from logica.modules.evaluations.repository import get_evaluation from logica.modules.exercises.repository import get_exercise from logica.modules.groups.service import get_group_with_access +from logica.modules.guides.repository import get_guide from logica.modules.users.models import Role, User logger = structlog.get_logger() @@ -45,12 +47,14 @@ async def create_assignment( title: str, topic_id: uuid.UUID | None, exercise_id: uuid.UUID | None, + evaluation_id: uuid.UUID | None, + guide_id: uuid.UUID | None, due_at: datetime | None, ) -> Assignment: await _ensure_teaches(db, user, group_id) # Se valida la tenencia del objetivo, no solo su existencia: sin esto un - # docente podría asignar el tema de otra institución cuyo id conociera. + # docente podría asignar contenido de otra institución cuyo id conociera. if topic_id is not None: topic = await get_topic(db, topic_id) if topic is None or topic.institution_id != user.institution_id: @@ -61,6 +65,14 @@ async def create_assignment( exercise = await get_exercise(db, exercise_id) if exercise is None or exercise.institution_id != user.institution_id: raise NotFoundError("Ejercicio no encontrado") + if evaluation_id is not None: + evaluation = await get_evaluation(db, evaluation_id) + if evaluation is None or evaluation.institution_id != user.institution_id: + raise NotFoundError("Evaluación no encontrada") + if guide_id is not None: + guide = await get_guide(db, user.institution_id, guide_id) + if guide is None: + raise NotFoundError("Guía no encontrada") assignment = Assignment( institution_id=user.institution_id, @@ -69,6 +81,8 @@ async def create_assignment( title=title, topic_id=topic_id, exercise_id=exercise_id, + evaluation_id=evaluation_id, + guide_id=guide_id, due_at=due_at, ) db.add(assignment) @@ -133,16 +147,32 @@ async def list_my_assignments(db: AsyncSession, student: User) -> list[StudentAs return [] solved = await repository.solved_exercise_ids(db, student.id) + submitted_evaluations = await repository.submitted_evaluation_ids(db, student.id) out: list[StudentAssignmentOut] = [] for assignment, group_name in rows: - if assignment.exercise_id is not None: - targets = {assignment.exercise_id} + if assignment.evaluation_id is not None: + # Examen: "cumplida" es "ya la presentó", no un conteo de + # ejercicios — la evaluación se presenta entera de una vez. + done = assignment.evaluation_id in submitted_evaluations + total, solved_count = 1, int(done) + elif assignment.guide_id is not None: + # Taller: sin señal de lectura en la plataforma todavía (ver + # docstring del modelo) — queda pendiente hasta que exista una. + done, total, solved_count = False, 0, 0 else: - assert assignment.topic_id is not None # el CheckConstraint lo garantiza - targets = await repository.exercise_ids_for_topic(db, assignment.topic_id) + if assignment.exercise_id is not None: + targets = {assignment.exercise_id} + else: + assert assignment.topic_id is not None # el CheckConstraint lo garantiza + targets = await repository.exercise_ids_for_topic(db, assignment.topic_id) + solved_count = len(targets & solved) + total = len(targets) + # Un tema sin ejercicios no está "cumplido": no hay nada que + # resolver todavía, y decir que sí le escondería al estudiante + # que el docente asignó algo que aún no tiene contenido. + done = total > 0 and solved_count == total - solved_here = len(targets & solved) out.append( StudentAssignmentOut( id=assignment.id, @@ -151,13 +181,12 @@ async def list_my_assignments(db: AsyncSession, student: User) -> list[StudentAs title=assignment.title, topic_id=assignment.topic_id, exercise_id=assignment.exercise_id, + evaluation_id=assignment.evaluation_id, + guide_id=assignment.guide_id, due_at=assignment.due_at, - # Un tema sin ejercicios no está "cumplido": no hay nada que - # resolver todavía, y decir que sí le escondería al estudiante - # que el docente asignó algo que aún no tiene contenido. - done=len(targets) > 0 and solved_here == len(targets), - total_exercises=len(targets), - solved_exercises=solved_here, + done=done, + total_exercises=total, + solved_exercises=solved_count, ) ) diff --git a/apps/api/src/logica/modules/evaluations/models.py b/apps/api/src/logica/modules/evaluations/models.py index 3fadb53..9a2e0f2 100644 --- a/apps/api/src/logica/modules/evaluations/models.py +++ b/apps/api/src/logica/modules/evaluations/models.py @@ -57,6 +57,11 @@ class Evaluation(UUIDPkMixin, TenantMixin, TimestampMixin, Base): ) duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True) is_ranked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # Cuánto pesa esta evaluación en la nota acumulada del grupo (0-100). + # Nullable: no todo docente lleva la nota por porcentajes desde el día uno, + # y una evaluación sin peso definido simplemente no entra al cálculo del + # acumulado — no hay valor por defecto que no sea una suposición falsa. + weight_percent: Mapped[float | None] = mapped_column(Float, nullable=True) class EvaluationExercise(UUIDPkMixin, TimestampMixin, Base): diff --git a/apps/api/src/logica/modules/evaluations/router.py b/apps/api/src/logica/modules/evaluations/router.py index 56c1019..6130ffb 100644 --- a/apps/api/src/logica/modules/evaluations/router.py +++ b/apps/api/src/logica/modules/evaluations/router.py @@ -52,6 +52,7 @@ async def create_evaluation( payload.duration_minutes, payload.is_ranked, payload.exercise_ids, + payload.weight_percent, ) await db.commit() return EvaluationOut.model_validate(evaluation) diff --git a/apps/api/src/logica/modules/evaluations/schemas.py b/apps/api/src/logica/modules/evaluations/schemas.py index cc0e51a..1dc2500 100644 --- a/apps/api/src/logica/modules/evaluations/schemas.py +++ b/apps/api/src/logica/modules/evaluations/schemas.py @@ -15,6 +15,7 @@ class EvaluationCreateRequest(BaseModel): up_to_topic_id: uuid.UUID | None = None duration_minutes: int | None = Field(default=None, gt=0) is_ranked: bool = False + weight_percent: float | None = Field(default=None, ge=0, le=100) exercise_ids: list[uuid.UUID] = Field(min_length=1) @@ -28,6 +29,7 @@ class EvaluationOut(BaseModel): up_to_topic_id: uuid.UUID | None duration_minutes: int | None is_ranked: bool + weight_percent: float | None model_config = {"from_attributes": True} diff --git a/apps/api/src/logica/modules/evaluations/service.py b/apps/api/src/logica/modules/evaluations/service.py index b3a5aad..de5671a 100644 --- a/apps/api/src/logica/modules/evaluations/service.py +++ b/apps/api/src/logica/modules/evaluations/service.py @@ -5,8 +5,6 @@ from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from logica.ai.agents.config_service import ensure_agent_enabled -from logica.ai.agents.models import AgentName from logica.ai.skills.pedagogical_feedback import generate_pedagogical_feedback from logica.core.errors import ( ConflictError, @@ -125,6 +123,7 @@ async def create_evaluation( duration_minutes: int | None, is_ranked: bool, exercise_ids: list[uuid.UUID], + weight_percent: float | None = None, ) -> Evaluation: _ensure_teacher(teacher) await get_group_with_access(db, teacher, group_id) @@ -142,6 +141,7 @@ async def create_evaluation( up_to_topic_id=up_to_topic_id if mode == EvaluationMode.fixed else None, duration_minutes=duration_minutes, is_ranked=is_ranked, + weight_percent=weight_percent, ) db.add(evaluation) await db.flush() @@ -419,7 +419,6 @@ async def generate_answer_feedback( _ensure_teacher(teacher) evaluation = await _get_evaluation_in_institution(db, teacher, evaluation_id) await get_group_with_access(db, teacher, evaluation.group_id) - await ensure_agent_enabled(db, evaluation.group_id, AgentName.grading_assistant) answer = await get_answer_by_id(db, answer_id) if answer is None: diff --git a/apps/api/src/logica/modules/exercises/models.py b/apps/api/src/logica/modules/exercises/models.py index 23db18c..9b7714e 100644 --- a/apps/api/src/logica/modules/exercises/models.py +++ b/apps/api/src/logica/modules/exercises/models.py @@ -20,7 +20,6 @@ class ExerciseType(enum.StrEnum): fill_code = "fill_code" find_error = "find_error" trace_variables = "trace_variables" - order_lines = "order_lines" argued_response = "argued_response" live_code = "live_code" diff --git a/apps/api/src/logica/modules/grading/plugins.py b/apps/api/src/logica/modules/grading/plugins.py index 33c8209..5869a3b 100644 --- a/apps/api/src/logica/modules/grading/plugins.py +++ b/apps/api/src/logica/modules/grading/plugins.py @@ -91,25 +91,6 @@ def grade(self, content: dict[str, Any], answer: dict[str, Any]) -> GradeResult: return GradeResult(score=score, correct=score == 1.0, detail={"matching_steps": matches}) -class OrderLinesGrader: - def grade(self, content: dict[str, Any], answer: dict[str, Any]) -> GradeResult: - expected_order: list[int] = content.get("correct_order", []) - submitted_order: list[int] | None = answer.get("order") - - if not expected_order: - return GradeResult(score=0.0, correct=False, detail={"reason": "sin orden esperado"}) - if not submitted_order: - return GradeResult(score=0.0, correct=False, detail={"reason": "respuesta vacía"}) - - matches = sum( - 1 - for i, expected in enumerate(expected_order) - if i < len(submitted_order) and submitted_order[i] == expected - ) - score = matches / len(expected_order) - return GradeResult(score=score, correct=score == 1.0) - - class ArguedResponseGrader: """Open-ended responses always require a teacher's judgment (RF-12) — this plugin never asserts correctness, it only decides whether the queue needs diff --git a/apps/api/src/logica/modules/grading/registry.py b/apps/api/src/logica/modules/grading/registry.py index d43ad7b..1dc6c37 100644 --- a/apps/api/src/logica/modules/grading/registry.py +++ b/apps/api/src/logica/modules/grading/registry.py @@ -6,7 +6,6 @@ FillCodeGrader, FindErrorGrader, MultipleChoiceGrader, - OrderLinesGrader, TraceVariablesGrader, TrueFalseGrader, ) @@ -18,7 +17,6 @@ ExerciseType.fill_code: FillCodeGrader(), ExerciseType.find_error: FindErrorGrader(), ExerciseType.trace_variables: TraceVariablesGrader(), - ExerciseType.order_lines: OrderLinesGrader(), ExerciseType.argued_response: ArguedResponseGrader(), } diff --git a/apps/api/src/logica/modules/grading/sanitize.py b/apps/api/src/logica/modules/grading/sanitize.py index 293c9b4..d41b422 100644 --- a/apps/api/src/logica/modules/grading/sanitize.py +++ b/apps/api/src/logica/modules/grading/sanitize.py @@ -14,7 +14,6 @@ ExerciseType.fill_code: ("blanks",), ExerciseType.find_error: ("error_line", "error_kind"), ExerciseType.trace_variables: ("expected_trace",), - ExerciseType.order_lines: ("correct_order",), ExerciseType.argued_response: (), ExerciseType.live_code: ("test_cases",), } diff --git a/apps/api/src/logica/modules/guides/router.py b/apps/api/src/logica/modules/guides/router.py index 5821fb2..3c814af 100644 --- a/apps/api/src/logica/modules/guides/router.py +++ b/apps/api/src/logica/modules/guides/router.py @@ -170,3 +170,13 @@ async def archive_guide( guide = await service.archive_guide(db, user, guide_id) await db.commit() return guide + + +@router.delete("/guides/{guide_id}", status_code=204) +async def delete_guide( + guide_id: uuid.UUID, + user: User = Depends(RequireTeacher), + db: AsyncSession = Depends(get_db), +) -> None: + await service.delete_guide(db, user, guide_id) + await db.commit() diff --git a/apps/api/src/logica/modules/guides/service.py b/apps/api/src/logica/modules/guides/service.py index 2c12018..1e99b8f 100644 --- a/apps/api/src/logica/modules/guides/service.py +++ b/apps/api/src/logica/modules/guides/service.py @@ -298,3 +298,21 @@ async def archive_guide(db: AsyncSession, user: User, guide_id: uuid.UUID) -> Gu await db.flush() await db.refresh(guide) return guide + + +async def delete_guide(db: AsyncSession, user: User, guide_id: uuid.UUID) -> None: + """Borrado en duro, solo para guías que ningún estudiante pudo haber visto + (`draft`/`failed`/`cancelled`; `generating` también se descarta, no tiene + contenido todavía). Una guía `published` o `archived` se archiva, nunca se + borra — mismo criterio que `§9.2` para cualquier contenido que ya circuló: + desaparecer en silencio sería peor que dejarla marcada como archivada.""" + guide = await get_guide_for_teacher(db, user, guide_id) + if guide.status in (GuideStatus.published, GuideStatus.archived): + raise ConflictError( + "No se puede borrar una guía publicada o archivada", + hint="Si ya no la quieres visible, dejarla archivada tiene el mismo efecto.", + ) + + await db.delete(guide) + await db.flush() + logger.info("guide_deleted", guide_id=str(guide_id)) diff --git a/apps/api/src/logica/modules/progress/models.py b/apps/api/src/logica/modules/progress/models.py index 48a2338..c889e5b 100644 --- a/apps/api/src/logica/modules/progress/models.py +++ b/apps/api/src/logica/modules/progress/models.py @@ -1,8 +1,8 @@ import enum import uuid -from datetime import date, datetime +from datetime import datetime -from sqlalchemy import Date, DateTime, Enum, Float, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import DateTime, Enum, Float, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -68,16 +68,3 @@ class StudentBadge(UUIDPkMixin, TenantMixin, TimestampMixin, Base): UUID(as_uuid=True), ForeignKey("topics.id"), nullable=True ) earned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - - -class AcademicPeriod(UUIDPkMixin, TenantMixin, TimestampMixin, Base): - """RF-17: un rango de fechas con nombre (por ejemplo "Periodo 1 - 2026") - que el docente crea explícitamente y que los reportes/el progreso pueden - usar para filtrar por fecha — no altera ninguna tabla existente, solo - acota una consulta por `created_at`/`submitted_at` entre sus fechas.""" - - __tablename__ = "academic_periods" - - name: Mapped[str] = mapped_column(String(100), nullable=False) - start_date: Mapped[date] = mapped_column(Date, nullable=False) - end_date: Mapped[date] = mapped_column(Date, nullable=False) diff --git a/apps/api/src/logica/modules/progress/repository.py b/apps/api/src/logica/modules/progress/repository.py index c6838b9..7dccb27 100644 --- a/apps/api/src/logica/modules/progress/repository.py +++ b/apps/api/src/logica/modules/progress/repository.py @@ -16,7 +16,7 @@ ) from logica.modules.exercises.models import Exercise, TopicExercise from logica.modules.groups.models import GroupMembership -from logica.modules.progress.models import AcademicPeriod, Badge, BadgeCriteria, StudentBadge +from logica.modules.progress.models import Badge, BadgeCriteria, StudentBadge _CORRECT_AS_INT = cast(PracticeSubmission.correct, Integer) @@ -289,44 +289,13 @@ async def recent_evaluation_attempts( return [(row, title) for row, title in result.all()] -async def create_academic_period( - db: AsyncSession, institution_id: uuid.UUID, name: str, start_date: date, end_date: date -) -> AcademicPeriod: - period = AcademicPeriod( - institution_id=institution_id, name=name, start_date=start_date, end_date=end_date - ) - db.add(period) - await db.flush() - await db.refresh(period) - return period - - -async def list_academic_periods( - db: AsyncSession, institution_id: uuid.UUID -) -> list[AcademicPeriod]: - stmt = ( - select(AcademicPeriod) - .where(AcademicPeriod.institution_id == institution_id) - .order_by(AcademicPeriod.start_date.desc()) - ) - result = await db.execute(stmt) - return list(result.scalars().all()) - - -async def get_academic_period(db: AsyncSession, period_id: uuid.UUID) -> AcademicPeriod | None: - return await db.get(AcademicPeriod, period_id) - - __all__ = [ "BadgeCriteria", "count_correct_practice", - "create_academic_period", - "get_academic_period", "get_badge_by_slug", "get_student_badge", "group_member_ids", "language_accuracy", - "list_academic_periods", "list_badges", "list_student_badges", "mastery_by_language", diff --git a/apps/api/src/logica/modules/progress/router.py b/apps/api/src/logica/modules/progress/router.py index ac203b7..18b481c 100644 --- a/apps/api/src/logica/modules/progress/router.py +++ b/apps/api/src/logica/modules/progress/router.py @@ -3,13 +3,10 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession -from logica.core.permissions import require_role from logica.core.security import get_current_user from logica.db import get_db from logica.modules.progress import service from logica.modules.progress.schemas import ( - AcademicPeriodCreateRequest, - AcademicPeriodOut, LaggingStudentOut, StudentActivityOut, StudentProgressOut, @@ -20,8 +17,6 @@ router = APIRouter(tags=["progress"]) -RequireTeacher = require_role("teacher", "admin") - @router.get("/progress/me", response_model=StudentProgressOut) async def get_my_progress( @@ -72,24 +67,3 @@ async def get_lagging_students( db: AsyncSession = Depends(get_db), ) -> list[LaggingStudentOut]: return await service.get_lagging_students(db, user, group_id, topic_id=topic_id) - - -@router.post("/academic-periods", response_model=AcademicPeriodOut, status_code=201) -async def create_academic_period( - payload: AcademicPeriodCreateRequest, - user: User = Depends(RequireTeacher), - db: AsyncSession = Depends(get_db), -) -> AcademicPeriodOut: - period = await service.create_academic_period( - db, user, payload.name, payload.start_date, payload.end_date - ) - await db.commit() - return AcademicPeriodOut.model_validate(period) - - -@router.get("/academic-periods", response_model=list[AcademicPeriodOut]) -async def list_academic_periods( - user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) -) -> list[AcademicPeriodOut]: - periods = await service.list_academic_periods(db, user) - return [AcademicPeriodOut.model_validate(p) for p in periods] diff --git a/apps/api/src/logica/modules/progress/schemas.py b/apps/api/src/logica/modules/progress/schemas.py index 0a98f2c..7d5473a 100644 --- a/apps/api/src/logica/modules/progress/schemas.py +++ b/apps/api/src/logica/modules/progress/schemas.py @@ -2,7 +2,7 @@ from datetime import date, datetime from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel from logica.modules.progress.models import BadgeCriteria @@ -89,16 +89,3 @@ class LaggingStudentOut(BaseModel): reason: str -class AcademicPeriodCreateRequest(BaseModel): - name: str = Field(min_length=1, max_length=100) - start_date: date - end_date: date - - -class AcademicPeriodOut(BaseModel): - id: uuid.UUID - name: str - start_date: date - end_date: date - - model_config = {"from_attributes": True} diff --git a/apps/api/src/logica/modules/progress/service.py b/apps/api/src/logica/modules/progress/service.py index cbe0e96..3d43326 100644 --- a/apps/api/src/logica/modules/progress/service.py +++ b/apps/api/src/logica/modules/progress/service.py @@ -15,7 +15,7 @@ from logica.modules.exercises.models import Exercise from logica.modules.groups.service import get_group_with_access from logica.modules.progress import repository -from logica.modules.progress.models import AcademicPeriod, Badge, BadgeCriteria, StudentBadge +from logica.modules.progress.models import Badge, BadgeCriteria, StudentBadge from logica.modules.progress.schemas import ( BadgeOut, DailyActivityOut, @@ -27,7 +27,7 @@ TodaySummaryOut, TopicMasteryOut, ) -from logica.modules.users.models import Role, User +from logica.modules.users.models import User from logica.modules.users.repository import get_user_by_id # RF-29: umbrales elegidos para que una insignia signifique algo (no se gana @@ -467,32 +467,10 @@ async def get_lagging_students( return lagging -def _ensure_teacher(user: User) -> None: - if user.role not in (Role.teacher, Role.admin): - raise PermissionDeniedError( - "Solo un docente o administrador puede administrar periodos académicos" - ) - - -async def create_academic_period( - db: AsyncSession, teacher: User, name: str, start_date: date, end_date: date -) -> AcademicPeriod: - _ensure_teacher(teacher) - return await repository.create_academic_period( - db, teacher.institution_id, name, start_date, end_date - ) - - -async def list_academic_periods(db: AsyncSession, user: User) -> list[AcademicPeriod]: - return await repository.list_academic_periods(db, user.institution_id) - - __all__ = [ - "create_academic_period", "ensure_default_badges", "evaluate_and_award_badges", "get_lagging_students", "get_student_progress", "lagging_reason_for_student", - "list_academic_periods", ] diff --git a/apps/api/src/logica/modules/reports/models.py b/apps/api/src/logica/modules/reports/models.py index 99b986b..846131c 100644 --- a/apps/api/src/logica/modules/reports/models.py +++ b/apps/api/src/logica/modules/reports/models.py @@ -35,9 +35,6 @@ class ReportJob(UUIDPkMixin, TenantMixin, TimestampMixin, Base): group_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("groups.id"), nullable=False, index=True ) - period_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("academic_periods.id"), nullable=True - ) format: Mapped[ReportFormat] = mapped_column( Enum(ReportFormat, name="report_format"), nullable=False ) diff --git a/apps/api/src/logica/modules/reports/repository.py b/apps/api/src/logica/modules/reports/repository.py index 071e548..d6d19d1 100644 --- a/apps/api/src/logica/modules/reports/repository.py +++ b/apps/api/src/logica/modules/reports/repository.py @@ -1,6 +1,6 @@ import uuid from dataclasses import dataclass -from datetime import UTC, date, datetime +from datetime import UTC, datetime from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -9,6 +9,7 @@ AttemptStatus, Evaluation, EvaluationAttempt, + EvaluationExercise, PracticeSubmission, ) from logica.modules.groups.models import GroupMembership @@ -42,6 +43,11 @@ class GradebookStudentRow: scores: list[GradebookScore] evaluations_submitted: int avg_evaluation_score: float | None + # Suma de (nota/máximo) × peso, solo sobre las evaluaciones con + # `weight_percent` definido y con intento presentado — None si ninguna + # evaluación del grupo tiene peso asignado o el estudiante no ha + # presentado ninguna de ellas. + weighted_average: float | None async def create_report_job( @@ -50,14 +56,12 @@ async def create_report_job( requested_by_id: uuid.UUID, group_id: uuid.UUID, format: ReportFormat, - period_id: uuid.UUID | None, ) -> ReportJob: job = ReportJob( institution_id=institution_id, requested_by_id=requested_by_id, group_id=group_id, format=format, - period_id=period_id, ) db.add(job) await db.flush() @@ -90,9 +94,6 @@ async def mark_failed(db: AsyncSession, job: ReportJob, error_message: str) -> N async def student_report_rows( db: AsyncSession, group_id: uuid.UUID, - *, - period_start: date | None, - period_end: date | None, ) -> list[StudentReportRow]: """One row per enrolled student (RF-16), each computed with its own small queries rather than one large join — the group's roster is small enough @@ -114,10 +115,6 @@ async def student_report_rows( ).where( PracticeSubmission.group_id == group_id, PracticeSubmission.student_id == student.id ) - if period_start is not None: - practice_stmt = practice_stmt.where(PracticeSubmission.created_at >= period_start) - if period_end is not None: - practice_stmt = practice_stmt.where(PracticeSubmission.created_at <= period_end) practice_total, practice_correct = (await db.execute(practice_stmt)).one() eval_stmt = ( @@ -130,10 +127,6 @@ async def student_report_rows( EvaluationAttempt.status == AttemptStatus.submitted, ) ) - if period_start is not None: - eval_stmt = eval_stmt.where(EvaluationAttempt.submitted_at >= period_start) - if period_end is not None: - eval_stmt = eval_stmt.where(EvaluationAttempt.submitted_at <= period_end) evaluations_submitted, avg_score = (await db.execute(eval_stmt)).one() badges_stmt = select(func.count(StudentBadge.id)).where( @@ -160,8 +153,8 @@ async def gradebook_rows( db: AsyncSession, group_id: uuid.UUID ) -> tuple[list[Evaluation], list[GradebookStudentRow]]: """Vista en vivo de calificaciones (a diferencia de `student_report_rows`, - que agrega por periodo para el export async xlsx/pdf): misma definición - de `avg_evaluation_score` — promedio de `total_score` sobre intentos + usada por el export async xlsx/pdf): misma definición de + `avg_evaluation_score` — promedio de `total_score` sobre intentos presentados — para que ambas vistas coincidan, pero aquí se conserva el detalle por evaluación en vez de solo el promedio.""" evaluations_stmt = ( @@ -197,6 +190,30 @@ async def gradebook_rows( GradebookScore(evaluation_id=evaluation_id, total_score=total_score) ) + # Máximo posible por evaluación (suma de `points` de sus ejercicios): hace + # falta para normalizar `total_score` (una suma en escala arbitraria, no + # 0-100) antes de aplicarle el peso de la evaluación. + max_score_stmt = ( + select(EvaluationExercise.evaluation_id, func.sum(EvaluationExercise.points)) + .join(Evaluation, Evaluation.id == EvaluationExercise.evaluation_id) + .where(Evaluation.group_id == group_id) + .group_by(EvaluationExercise.evaluation_id) + ) + max_score_by_evaluation: dict[uuid.UUID, float] = dict( + (await db.execute(max_score_stmt)).tuples().all() + ) + weight_by_evaluation = {e.id: e.weight_percent for e in evaluations} + + def _weighted_average(scores: list[GradebookScore]) -> float | None: + contributions = [] + for s in scores: + weight = weight_by_evaluation.get(s.evaluation_id) + max_score = max_score_by_evaluation.get(s.evaluation_id) + if weight is None or not max_score: + continue + contributions.append((s.total_score / max_score) * weight) + return sum(contributions) if contributions else None + rows: list[GradebookStudentRow] = [] for student in students: scores = scores_by_student.get(student.id, []) @@ -208,6 +225,7 @@ async def gradebook_rows( scores=scores, evaluations_submitted=len(scores), avg_evaluation_score=avg_score, + weighted_average=_weighted_average(scores), ) ) return evaluations, rows diff --git a/apps/api/src/logica/modules/reports/router.py b/apps/api/src/logica/modules/reports/router.py index 736fbd0..86ea75b 100644 --- a/apps/api/src/logica/modules/reports/router.py +++ b/apps/api/src/logica/modules/reports/router.py @@ -40,9 +40,7 @@ async def request_group_report( db: AsyncSession = Depends(get_db), arq_pool: ArqRedis = Depends(get_arq_pool), ) -> ReportJobOut: - job = await service.request_group_report( - db, arq_pool, user, group_id, payload.format, payload.period_id - ) + job = await service.request_group_report(db, arq_pool, user, group_id, payload.format) await db.commit() return ReportJobOut.model_validate(job) @@ -95,6 +93,7 @@ async def get_gradebook( ], evaluations_submitted=row.evaluations_submitted, avg_evaluation_score=row.avg_evaluation_score, + weighted_average=row.weighted_average, ) for row in students ], diff --git a/apps/api/src/logica/modules/reports/schemas.py b/apps/api/src/logica/modules/reports/schemas.py index 4d7b010..1b70099 100644 --- a/apps/api/src/logica/modules/reports/schemas.py +++ b/apps/api/src/logica/modules/reports/schemas.py @@ -9,7 +9,6 @@ class ReportRequest(BaseModel): format: ReportFormat - period_id: uuid.UUID | None = None class ReportJobOut(BaseModel): @@ -28,6 +27,7 @@ class GradebookEvaluationOut(BaseModel): title: str mode: EvaluationMode is_ranked: bool + weight_percent: float | None model_config = {"from_attributes": True} @@ -43,6 +43,7 @@ class GradebookStudentOut(BaseModel): scores: list[GradebookScoreOut] evaluations_submitted: int avg_evaluation_score: float | None + weighted_average: float | None class GradebookOut(BaseModel): diff --git a/apps/api/src/logica/modules/reports/service.py b/apps/api/src/logica/modules/reports/service.py index 079299d..b502548 100644 --- a/apps/api/src/logica/modules/reports/service.py +++ b/apps/api/src/logica/modules/reports/service.py @@ -17,8 +17,6 @@ from logica.modules.evaluations.models import Evaluation from logica.modules.groups.models import Group from logica.modules.groups.service import get_group_with_access -from logica.modules.progress.models import AcademicPeriod -from logica.modules.progress.repository import get_academic_period from logica.modules.progress.service import lagging_reason_for_student from logica.modules.reports import repository from logica.modules.reports.models import ReportFormat, ReportJob, ReportStatus @@ -34,19 +32,13 @@ async def request_group_report( teacher: User, group_id: uuid.UUID, format: ReportFormat, - period_id: uuid.UUID | None, ) -> ReportJob: _, is_teacher_view = await get_group_with_access(db, teacher, group_id) if not is_teacher_view: raise PermissionDeniedError("Solo un docente o administrador puede exportar reportes") - if period_id is not None: - period = await get_academic_period(db, period_id) - if period is None or period.institution_id != teacher.institution_id: - raise NotFoundError("Periodo académico no encontrado") - job = await repository.create_report_job( - db, teacher.institution_id, teacher.id, group_id, format, period_id + db, teacher.institution_id, teacher.id, group_id, format ) await db.flush() await arq_pool.enqueue_job("generate_group_report_job", str(job.id)) @@ -59,7 +51,6 @@ def _status_label(reason: str | None) -> str: def _build_xlsx( group: Group, - period: AcademicPeriod | None, rows: list[StudentReportRow], lagging_reasons: dict[uuid.UUID, str | None], ) -> bytes: @@ -71,8 +62,6 @@ def _build_xlsx( ws.title = "Progreso" ws.append([f"Reporte de progreso — {group.name}"]) ws.cell(row=1, column=1).font = Font(bold=True, size=14) - if period is not None: - ws.append([f"Periodo: {period.name} ({period.start_date} a {period.end_date})"]) ws.append([]) headers = [ @@ -111,7 +100,6 @@ def _build_xlsx( def _build_pdf( group: Group, - period: AcademicPeriod | None, rows: list[StudentReportRow], lagging_reasons: dict[uuid.UUID, str | None], ) -> bytes: @@ -121,11 +109,6 @@ def _build_pdf( # every other report/xlsx test collectible without them installed. from weasyprint import HTML - period_html = ( - f"

Periodo: {period.name} ({period.start_date} a {period.end_date})

" - if period is not None - else "" - ) rows_html = "".join( f"{r.full_name}{r.email}{r.practice_total}" f"{f'{r.practice_correct / r.practice_total:.0%}' if r.practice_total else '—'}" @@ -144,7 +127,6 @@ def _build_pdf(

Reporte de progreso — {group.name}

- {period_html} @@ -188,13 +170,8 @@ async def generate_group_report(db: AsyncSession, report_job_id: uuid.UUID) -> N group = await db.get(Group, job.group_id) if group is None: raise NotFoundError("Grupo no encontrado") - period = await get_academic_period(db, job.period_id) if job.period_id else None - period_start = period.start_date if period else None - period_end = period.end_date if period else None - rows = await repository.student_report_rows( - db, job.group_id, period_start=period_start, period_end=period_end - ) + rows = await repository.student_report_rows(db, job.group_id) # RF-15 reusado en el export: la misma señal de "quién está atascado" # que ya ve el docente en pantalla (AnalyticsTab), ahora también en el # archivo — sin inventar una métrica nueva. @@ -204,9 +181,9 @@ async def generate_group_report(db: AsyncSession, report_job_id: uuid.UUID) -> N } content = ( - _build_xlsx(group, period, rows, lagging_reasons) + _build_xlsx(group, rows, lagging_reasons) if job.format == ReportFormat.xlsx - else _build_pdf(group, period, rows, lagging_reasons) + else _build_pdf(group, rows, lagging_reasons) ) # noqa comments below: this runs in the arq worker, not on a request diff --git a/apps/api/src/logica/modules/rubrics/document_extraction.py b/apps/api/src/logica/modules/rubrics/document_extraction.py new file mode 100644 index 0000000..6d0eade --- /dev/null +++ b/apps/api/src/logica/modules/rubrics/document_extraction.py @@ -0,0 +1,78 @@ +"""Extracción de texto crudo de una rúbrica institucional en PDF/DOCX (§ubir +rúbrica). Solo extrae texto plano — la interpretación semántica (¿cuáles son +los temas? ¿qué nivel tienen?) la hace el LLM en `rubric_topic_extractor.py`, +porque el formato institucional es demasiado heterogéneo (tablas, unidades, +niveles) para un parser de tablas hecho a mano.""" + +import io + +import structlog +from docx import Document +from pypdf import PdfReader +from pypdf.errors import PdfReadError + +from logica.core.errors import ValidationDomainError + +logger = structlog.get_logger() + +# 5 MB: generoso para un temario institucional (típicamente unas pocas +# páginas) pero acotado para no cargar un archivo gigante a memoria. +MAX_FILE_BYTES = 5_000_000 +ALLOWED_EXTENSIONS = (".pdf", ".docx") +ALLOWED_CONTENT_TYPES = { + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +} + +# Un temario de 80 páginas no aporta más que uno de 10 para este prompt y +# infla el costo/latencia de la llamada al modelo; se corta antes de mandarlo. +_MAX_EXTRACTED_CHARS = 20_000 + + +def _truncate(text: str) -> str: + if len(text) <= _MAX_EXTRACTED_CHARS: + return text + logger.warning("rubric_document_text_truncated", original_chars=len(text)) + return text[:_MAX_EXTRACTED_CHARS] + + +def extract_text_from_pdf(raw: bytes) -> str: + try: + reader = PdfReader(io.BytesIO(raw)) + text = "\n".join(page.extract_text() or "" for page in reader.pages) + except (PdfReadError, ValueError) as exc: + raise ValidationDomainError( + "No se pudo leer el documento", + hint="Verifica que el PDF no esté dañado o protegido con contraseña.", + ) from exc + return _require_readable_text(text) + + +def extract_text_from_docx(raw: bytes) -> str: + try: + document = Document(io.BytesIO(raw)) + parts = [p.text for p in document.paragraphs if p.text.strip()] + for table in document.tables: + for row in table.rows: + cells = " | ".join(cell.text.strip() for cell in row.cells) + if cells.strip(" |"): + parts.append(cells) + text = "\n".join(parts) + except Exception as exc: # pypdf/python-docx no exponen una jerarquía común + raise ValidationDomainError( + "No se pudo leer el documento", + hint="Verifica que el archivo sea un .docx válido y no esté dañado.", + ) from exc + return _require_readable_text(text) + + +def _require_readable_text(text: str) -> str: + if len(text.strip()) < 20: + raise ValidationDomainError( + "El documento no tiene texto legible", + hint=( + "Si es un PDF escaneado (una imagen del papel), esta función no puede " + "leerlo — todavía no soportamos reconocimiento óptico de caracteres." + ), + ) + return _truncate(text) diff --git a/apps/api/src/logica/modules/rubrics/router.py b/apps/api/src/logica/modules/rubrics/router.py index b57fec5..c4f6b58 100644 --- a/apps/api/src/logica/modules/rubrics/router.py +++ b/apps/api/src/logica/modules/rubrics/router.py @@ -1,17 +1,21 @@ import uuid from arq import ArqRedis -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request, UploadFile from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession +from logica.ai.agents.rubric_topic_extractor import extract_topics_from_text from logica.core.arq_dep import get_arq_pool +from logica.core.errors import ValidationDomainError from logica.core.permissions import require_permission +from logica.core.rate_limit import user_limiter from logica.core.redis_dep import get_redis from logica.db import get_db -from logica.modules.rubrics import service +from logica.modules.rubrics import document_extraction, service from logica.modules.rubrics.models import RubricRun from logica.modules.rubrics.schemas import ( + DocumentExtractionResult, RubricItemOut, RubricRunCreateRequest, RubricRunDetailOut, @@ -76,6 +80,45 @@ async def get_rubric_run( ) +@router.post("/rubric-runs/extract-topics", response_model=DocumentExtractionResult) +@user_limiter.limit("10/minute") +async def extract_topics_from_document( + request: Request, + file: UploadFile, + user: User = Depends(RequireTeacher), + db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +) -> DocumentExtractionResult: + """Paso de *preview* puro: extrae los temas de un PDF/DOCX de rúbrica + institucional para prellenar el textarea de `POST /rubric-runs`, pero no + crea ningún `RubricRun` — el docente revisa/edita antes de enviar.""" + filename = file.filename or "" + if not filename.lower().endswith(document_extraction.ALLOWED_EXTENSIONS): + raise ValidationDomainError( + "Formato de archivo no soportado", + hint="Solo se aceptan archivos .pdf o .docx.", + ) + if file.content_type not in document_extraction.ALLOWED_CONTENT_TYPES: + raise ValidationDomainError( + "Formato de archivo no soportado", + hint="Solo se aceptan archivos .pdf o .docx.", + ) + + raw = await file.read() + if len(raw) > document_extraction.MAX_FILE_BYTES: + raise ValidationDomainError( + "El documento es demasiado grande", + hint=f"El máximo es {document_extraction.MAX_FILE_BYTES // 1_000_000} MB.", + ) + + if filename.lower().endswith(".pdf"): + raw_text = document_extraction.extract_text_from_pdf(raw) + else: + raw_text = document_extraction.extract_text_from_docx(raw) + + return await extract_topics_from_text(db, redis, user=user, raw_text=raw_text) + + @router.post("/rubric-runs/{run_id}/cancel", response_model=RubricRunOut) async def cancel_rubric_run( run_id: uuid.UUID, diff --git a/apps/api/src/logica/modules/rubrics/schemas.py b/apps/api/src/logica/modules/rubrics/schemas.py index c68fd6c..bbb5d46 100644 --- a/apps/api/src/logica/modules/rubrics/schemas.py +++ b/apps/api/src/logica/modules/rubrics/schemas.py @@ -72,3 +72,19 @@ class RubricRunDetailOut(BaseModel): run: RubricRunOut items: list[RubricItemOut] + + +class ExtractedTopicItem(BaseModel): + topic_name: str = Field(min_length=2, max_length=200) + level: TopicLevel = TopicLevel.basico + order_index: int = 0 + + +class DocumentExtractionResult(BaseModel): + """Salida de `POST /rubric-runs/extract-topics`: un paso de *preview* puro + (no crea `RubricRun` ni `RubricItem`), pensado para prellenar el textarea de + temas que el docente ya conoce y sigue pudiendo editar antes de enviar.""" + + # >MAX_ITEMS_PER_RUN (15) a propósito: el recorte final lo hace el docente en + # el textarea, con el mismo aviso que ya existe para exceso de temas. + items: list[ExtractedTopicItem] = Field(default_factory=list, max_length=30) diff --git a/apps/api/src/logica/workers/settings.py b/apps/api/src/logica/workers/settings.py index 3a511c2..bda6b16 100644 --- a/apps/api/src/logica/workers/settings.py +++ b/apps/api/src/logica/workers/settings.py @@ -10,10 +10,8 @@ from redis.asyncio import Redis from sqlalchemy.exc import SQLAlchemyError -from logica.ai.agents.config_service import is_agent_enabled from logica.ai.agents.exercise_generator import generate_exercises_for_guide from logica.ai.agents.guide_writer import write_guide -from logica.ai.agents.models import AgentName from logica.config import get_settings from logica.core.cancellation import clear_cancel, is_cancelled from logica.core.logging import configure_logging @@ -142,8 +140,8 @@ async def generate_guides_for_enabled_topics_job(ctx: dict[str, Any]) -> int: para un grupo cuya carpeta tiene la autogeneración activada. Nunca decide por el docente: solo actúa sobre carpetas donde él eligió una - plantilla (`auto_generate_template_id`), respeta el interruptor por grupo del - agente, y lo que produce es un borrador que él publica o descarta.""" + plantilla (`auto_generate_template_id`), y lo que produce es un borrador + que él publica o descarta.""" session_factory = get_session_factory() arq_pool: ArqRedis = ctx["redis"] created: list[str] = [] @@ -151,9 +149,6 @@ async def generate_guides_for_enabled_topics_job(ctx: dict[str, Any]) -> int: async with session_factory() as db: for folder in await guides_repository.list_auto_generate_folders(db): - if not await is_agent_enabled(db, folder.group_id, AgentName.guide_writer): - continue - # `list_auto_generate_folders` ya filtró los NULL; el guard es para # que mypy no tenga que confiar en eso. if folder.auto_generate_template_id is None: diff --git a/apps/api/tests/evals/datasets/exercise_generation.yaml b/apps/api/tests/evals/datasets/exercise_generation.yaml index d0a35d9..5f05b77 100644 --- a/apps/api/tests/evals/datasets/exercise_generation.yaml +++ b/apps/api/tests/evals/datasets/exercise_generation.yaml @@ -98,25 +98,6 @@ cases: title_min_length: 5 content_has_keys: ["statement", "code", "expected_trace"] - - id: order_lines_funciones_basico - template_vars: - exercise_type: "order_lines" - topic_name: "Funciones" - language: "Python" - level: "basico" - reference_context: "" - similar_exercises: "" - schema_hint: >- - {"title": "...", "content": {"statement": "...", "lines": ["...", "..."], - "correct_order": [0, 1]}} - fake_model_output: >- - {"title": "Ordena la definición de una función", - "content": {"statement": "Ordena las líneas para definir correctamente una función", - "lines": [" return a + b", "def sumar(a, b):"], "correct_order": [1, 0]}} - checks: - title_min_length: 5 - content_has_keys: ["statement", "lines", "correct_order"] - - id: argued_response_arreglos_avanzado template_vars: exercise_type: "argued_response" diff --git a/apps/api/tests/integration/test_agent_code_integrity.py b/apps/api/tests/integration/test_agent_code_integrity.py index ddfa424..76006ed 100644 --- a/apps/api/tests/integration/test_agent_code_integrity.py +++ b/apps/api/tests/integration/test_agent_code_integrity.py @@ -159,38 +159,3 @@ async def test_integrity_check_rejects_non_live_code_exercise( assert resp.status_code == 422 -async def test_integrity_disabled_for_group_blocks_check( - client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") - - evaluation_id, answer_id = await _setup_live_code_evaluation( - client, teacher_access, student_access - ) - - groups = await client.get("/groups/mine", headers=auth_headers(teacher_access)) - group_id = groups.json()[0]["id"] - await client.put( - f"/ai/groups/{group_id}/agents/code_integrity", - json={"enabled": False}, - headers=auth_headers(teacher_access), - ) - - called = False - - async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: - nonlocal called - called = True - return CompletionResult(text="{}", model="x", prompt_tokens=0, completion_tokens=0) - - monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - - resp = await client.post( - "/ai/integrity/check", - json={"evaluation_id": evaluation_id, "answer_id": answer_id}, - headers=auth_headers(teacher_access), - ) - assert resp.status_code == 403 - assert called is False diff --git a/apps/api/tests/integration/test_agent_config.py b/apps/api/tests/integration/test_agent_config.py deleted file mode 100644 index 715e8c9..0000000 --- a/apps/api/tests/integration/test_agent_config.py +++ /dev/null @@ -1,68 +0,0 @@ -from httpx import AsyncClient - -from logica.ai.agents.models import AgentName -from logica.modules.users.models import Institution -from tests.integration.conftest import auth_headers, create_group, register_and_login - - -async def test_default_agents_are_all_enabled( - client: AsyncClient, institution: Institution -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - group = await create_group(client, teacher_access) - - resp = await client.get( - f"/ai/groups/{group['id']}/agents", headers=auth_headers(teacher_access) - ) - assert resp.status_code == 200 - statuses = resp.json() - # Contra `AgentName` y no contra un número fijo: lo que se está afirmando es - # "todo agente arranca habilitado" (RF-30), no cuántos agentes hay. Con un - # `len(statuses) == N` este test se rompía cada vez que una fase agregaba un - # agente, señalando una regresión donde solo había un enum más largo. - assert {s["agent_name"] for s in statuses} == {a.value for a in AgentName} - assert all(s["enabled"] is True for s in statuses) - - -async def test_student_cannot_toggle_agent(client: AsyncClient, institution: Institution) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") - group = await create_group(client, teacher_access) - - resp = await client.put( - f"/ai/groups/{group['id']}/agents/progressive_hint", - json={"enabled": False}, - headers=auth_headers(student_access), - ) - assert resp.status_code == 403 - - -async def test_teacher_can_disable_and_reenable_agent( - client: AsyncClient, institution: Institution -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - group = await create_group(client, teacher_access) - - disable = await client.put( - f"/ai/groups/{group['id']}/agents/progressive_hint", - json={"enabled": False}, - headers=auth_headers(teacher_access), - ) - assert disable.status_code == 200 - assert disable.json()["enabled"] is False - - statuses = ( - await client.get(f"/ai/groups/{group['id']}/agents", headers=auth_headers(teacher_access)) - ).json() - tutor_status = next(s for s in statuses if s["agent_name"] == "progressive_hint") - assert tutor_status["enabled"] is False - - reenable = await client.put( - f"/ai/groups/{group['id']}/agents/progressive_hint", - json={"enabled": True}, - headers=auth_headers(teacher_access), - ) - assert reenable.json()["enabled"] is True diff --git a/apps/api/tests/integration/test_agent_exercise_generator.py b/apps/api/tests/integration/test_agent_exercise_generator.py index 2efb19f..e416e07 100644 --- a/apps/api/tests/integration/test_agent_exercise_generator.py +++ b/apps/api/tests/integration/test_agent_exercise_generator.py @@ -4,7 +4,9 @@ from logica.ai.harness.router import CompletionResult from logica.modules.users.models import Institution from tests.integration.conftest import ( + attach_exercise, auth_headers, + create_exercise, create_group, create_language, create_topic, @@ -65,7 +67,7 @@ async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: assert body["content"]["answer"] is True -async def test_generator_disabled_for_group_blocks_generation( +async def test_generated_draft_becomes_visible_only_after_teacher_publishes( client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch ) -> None: domain = institution.email_domains[0] @@ -75,31 +77,38 @@ async def test_generator_disabled_for_group_blocks_generation( topic_id = await create_topic(client, teacher_access, language_id) group = await create_group(client, teacher_access) - await client.put( - f"/ai/groups/{group['id']}/agents/exercise_generation", - json={"enabled": False}, - headers=auth_headers(teacher_access), - ) - - called = False - async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: - nonlocal called - called = True - return CompletionResult(text="{}", model="x", prompt_tokens=0, completion_tokens=0) + return CompletionResult( + text='{"title": "Generado", "content": {"statement": "x", "answer": true}}', + model="groq/fake", + prompt_tokens=1, + completion_tokens=1, + ) monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - resp = await client.post( + created = await client.post( "/ai/exercises/generate", json={"group_id": group["id"], "topic_id": topic_id, "exercise_type": "true_false"}, headers=auth_headers(teacher_access), ) - assert resp.status_code == 403 - assert called is False + exercise_id = created.json()["id"] + pending = await client.get("/ai/pending-approvals", headers=auth_headers(teacher_access)) + assert any(e["id"] == exercise_id for e in pending.json()["exercises"]) -async def test_generated_draft_becomes_visible_only_after_teacher_publishes( + published = await client.patch( + f"/exercises/{exercise_id}", + json={"status": "published"}, + headers=auth_headers(teacher_access), + ) + assert published.json()["status"] == "published" + + pending_after = await client.get("/ai/pending-approvals", headers=auth_headers(teacher_access)) + assert all(e["id"] != exercise_id for e in pending_after.json()["exercises"]) + + +async def test_variants_are_previews_not_persisted( client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch ) -> None: domain = institution.email_domains[0] @@ -107,11 +116,19 @@ async def test_generated_draft_becomes_visible_only_after_teacher_publishes( language_id = await create_language(client, teacher_access) topic_id = await create_topic(client, teacher_access, language_id) - group = await create_group(client, teacher_access) + exercise = await create_exercise( + client, teacher_access, language_id, content={"statement": "2+2=4", "answer": True} + ) + await attach_exercise(client, teacher_access, exercise["id"], topic_id) + + calls = 0 async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: + nonlocal calls + calls += 1 + content = '{"statement": "x", "answer": true}' return CompletionResult( - text='{"title": "Generado", "content": {"statement": "x", "answer": true}}', + text=f'{{"title": "Variante {calls}", "content": {content}}}', model="groq/fake", prompt_tokens=1, completion_tokens=1, @@ -119,22 +136,35 @@ async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - created = await client.post( - "/ai/exercises/generate", - json={"group_id": group["id"], "topic_id": topic_id, "exercise_type": "true_false"}, + resp = await client.post( + f"/ai/exercises/{exercise['id']}/variants", + json={"count": 2}, headers=auth_headers(teacher_access), ) - exercise_id = created.json()["id"] + assert resp.status_code == 200, resp.text + variants = resp.json() + assert [v["title"] for v in variants] == ["Variante 1", "Variante 2"] - pending = await client.get("/ai/pending-approvals", headers=auth_headers(teacher_access)) - assert any(e["id"] == exercise_id for e in pending.json()["exercises"]) + # Nada se persistió: el banco sigue con solo el ejercicio original. + bank = await client.get("/exercises", headers=auth_headers(teacher_access)) + assert [e["title"] for e in bank.json()] == [exercise["title"]] - published = await client.patch( - f"/exercises/{exercise_id}", - json={"status": "published"}, - headers=auth_headers(teacher_access), - ) - assert published.json()["status"] == "published" - pending_after = await client.get("/ai/pending-approvals", headers=auth_headers(teacher_access)) - assert all(e["id"] != exercise_id for e in pending_after.json()["exercises"]) +async def test_student_cannot_request_variants( + client: AsyncClient, institution: Institution +) -> None: + domain = institution.email_domains[0] + teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") + student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") + + language_id = await create_language(client, teacher_access) + topic_id = await create_topic(client, teacher_access, language_id) + exercise = await create_exercise(client, teacher_access, language_id) + await attach_exercise(client, teacher_access, exercise["id"], topic_id) + + resp = await client.post( + f"/ai/exercises/{exercise['id']}/variants", + json={"count": 1}, + headers=auth_headers(student_access), + ) + assert resp.status_code == 403 diff --git a/apps/api/tests/integration/test_agent_grading_assistant.py b/apps/api/tests/integration/test_agent_grading_assistant.py index 6a1894f..c4cb585 100644 --- a/apps/api/tests/integration/test_agent_grading_assistant.py +++ b/apps/api/tests/integration/test_agent_grading_assistant.py @@ -198,36 +198,3 @@ async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: assert "condición de salida" in answer_out["ai_generated_feedback"] -async def test_grading_assistant_disabled_blocks_suggestion( - client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") - - evaluation_id, answer_id, group_id = await _setup_argued_evaluation( - client, teacher_access, student_access - ) - - await client.put( - f"/ai/groups/{group_id}/agents/grading_suggestion", - json={"enabled": False}, - headers=auth_headers(teacher_access), - ) - - called = False - - async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: - nonlocal called - called = True - return CompletionResult(text="{}", model="x", prompt_tokens=0, completion_tokens=0) - - monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - - resp = await client.post( - "/ai/grading/suggest", - json={"evaluation_id": evaluation_id, "answer_id": answer_id, "rubric": "rúbrica"}, - headers=auth_headers(teacher_access), - ) - assert resp.status_code == 403 - assert called is False diff --git a/apps/api/tests/integration/test_agent_guide_writer.py b/apps/api/tests/integration/test_agent_guide_writer.py index 436edab..9f35a36 100644 --- a/apps/api/tests/integration/test_agent_guide_writer.py +++ b/apps/api/tests/integration/test_agent_guide_writer.py @@ -24,7 +24,6 @@ ) from logica.modules.users.models import Institution from tests.integration.conftest import ( - auth_headers, create_group, create_language, create_topic, @@ -260,34 +259,6 @@ async def test_teacher_budget_is_larger_than_student_budget( assert guide.status == GuideStatus.draft -async def test_disabled_agent_blocks_generation( - client: AsyncClient, - institution: Institution, - redis_client: Redis, - monkeypatch: pytest.MonkeyPatch, -) -> None: - seeded = await _seed_guide(client, institution) - await client.put( - f"/ai/groups/{seeded.group_id}/agents/guide_generation", - json={"enabled": False}, - headers=auth_headers(seeded.teacher_access), - ) - - called = False - - async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: - nonlocal called - called = True - return CompletionResult(text="{}", model="x", prompt_tokens=0, completion_tokens=0) - - monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - - guide = await _run_write_guide(seeded.guide_id, redis_client) - - assert guide.status == GuideStatus.failed - assert called is False - - async def test_sources_cite_the_course_material( client: AsyncClient, institution: Institution, diff --git a/apps/api/tests/integration/test_agent_learning_analytics.py b/apps/api/tests/integration/test_agent_learning_analytics.py index 3ef275d..beb0378 100644 --- a/apps/api/tests/integration/test_agent_learning_analytics.py +++ b/apps/api/tests/integration/test_agent_learning_analytics.py @@ -75,30 +75,3 @@ async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: assert '"total_submissions": 3' in captured_vars["prompt"] -async def test_learning_analytics_disabled_blocks_summary( - client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - group = await create_group(client, teacher_access) - - await client.put( - f"/ai/groups/{group['id']}/agents/summarize_group", - json={"enabled": False}, - headers=auth_headers(teacher_access), - ) - - called = False - - async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: - nonlocal called - called = True - return CompletionResult(text="x", model="x", prompt_tokens=0, completion_tokens=0) - - monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - - resp = await client.post( - f"/ai/groups/{group['id']}/analytics/summary", headers=auth_headers(teacher_access) - ) - assert resp.status_code == 403 - assert called is False diff --git a/apps/api/tests/integration/test_agent_tutor.py b/apps/api/tests/integration/test_agent_tutor.py index ef6d602..f5056d8 100644 --- a/apps/api/tests/integration/test_agent_tutor.py +++ b/apps/api/tests/integration/test_agent_tutor.py @@ -104,45 +104,6 @@ async def test_tutor_never_reveals_full_solution( assert resp.status_code == 422 -async def test_tutor_disabled_for_group_blocks_hint( - client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") - - group, exercise_id = await _setup_group_with_exercise(client, teacher_access) - await join_group(client, student_access, group["invite_code"]) - - await client.put( - f"/ai/groups/{group['id']}/agents/progressive_hint", - json={"enabled": False}, - headers=auth_headers(teacher_access), - ) - - called = False - - async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: - nonlocal called - called = True - return CompletionResult(text="x", model="x", prompt_tokens=0, completion_tokens=0) - - monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) - - resp = await client.post( - "/ai/tutor/hint", - json={ - "group_id": group["id"], - "exercise_id": exercise_id, - "attempt_number": 1, - "student_answer": "ayuda", - }, - headers=auth_headers(student_access), - ) - assert resp.status_code == 403 - assert called is False - - async def test_student_cannot_see_another_students_history( client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/apps/api/tests/integration/test_assignments.py b/apps/api/tests/integration/test_assignments.py index 56095c2..87b4d06 100644 --- a/apps/api/tests/integration/test_assignments.py +++ b/apps/api/tests/integration/test_assignments.py @@ -183,6 +183,100 @@ async def test_el_estudiante_solo_ve_asignaciones_de_sus_grupos( assert body == [] +async def test_asignar_un_examen_se_cumple_al_presentarlo( + client: AsyncClient, institution: Institution +) -> None: + ctx = await _setup(client, institution) + evaluation = await client.post( + "/evaluations", + json={ + "group_id": ctx["group_id"], + "title": "Parcial 1", + "mode": "cumulative", + "is_ranked": False, + "exercise_ids": [ctx["exercise_id"]], + }, + headers=auth_headers(ctx["teacher"]), + ) + assert evaluation.status_code == 201, evaluation.text + evaluation_id = evaluation.json()["id"] + + created = await client.post( + f"/groups/{ctx['group_id']}/assignments", + json={"title": "Presenta el parcial", "evaluation_id": evaluation_id}, + headers=auth_headers(ctx["teacher"]), + ) + assert created.status_code == 201, created.text + + before = (await client.get("/assignments/me", headers=auth_headers(ctx["student"]))).json() + assert before[0]["done"] is False + + take = await client.get( + f"/evaluations/{evaluation_id}/take", headers=auth_headers(ctx["student"]) + ) + evaluation_exercise_id = take.json()["exercises"][0]["evaluation_exercise_id"] + await client.post( + f"/evaluations/{evaluation_id}/answers", + json={"evaluation_exercise_id": evaluation_exercise_id, "answer": {"value": True}}, + headers=auth_headers(ctx["student"]), + ) + submitted = await client.post( + f"/evaluations/{evaluation_id}/submit", headers=auth_headers(ctx["student"]) + ) + assert submitted.status_code == 200, submitted.text + + after = (await client.get("/assignments/me", headers=auth_headers(ctx["student"]))).json() + assert after[0]["done"] is True + assert after[0]["evaluation_id"] == evaluation_id + + +async def test_asignar_un_taller_nunca_se_marca_cumplido_todavia( + client: AsyncClient, institution: Institution +) -> None: + """No hay señal de "lo leyó" en la plataforma — ver el docstring del + modelo. Esto documenta el límite actual, no un objetivo.""" + ctx = await _setup(client, institution) + + folder = await client.post( + f"/groups/{ctx['group_id']}/guide-folders", + json={"name": "Guías 10-1"}, + headers=auth_headers(ctx["teacher"]), + ) + template = await client.post( + "/guide-templates", + json={ + "name": "Guía de laboratorio", + "sections": [{"heading": "Objetivos", "instructions": "Lista 3 objetivos."}], + "tone": "cercano", + "target_level": "basico", + }, + headers=auth_headers(ctx["teacher"]), + ) + guide = await client.post( + "/ai/guides/generate", + json={ + "folder_id": folder.json()["id"], + "template_id": template.json()["id"], + "topic_id": ctx["topic_id"], + }, + headers=auth_headers(ctx["teacher"]), + ) + assert guide.status_code == 202, guide.text + guide_id = guide.json()["id"] + + created = await client.post( + f"/groups/{ctx['group_id']}/assignments", + json={"title": "Lee la guía", "guide_id": guide_id}, + headers=auth_headers(ctx["teacher"]), + ) + assert created.status_code == 201, created.text + + body = (await client.get("/assignments/me", headers=auth_headers(ctx["student"]))).json() + assert body[0]["done"] is False + assert body[0]["guide_id"] == guide_id + assert body[0]["total_exercises"] == 0 + + async def test_quitar_la_fecha_limite_es_posible( client: AsyncClient, institution: Institution ) -> None: diff --git a/apps/api/tests/integration/test_gradebook.py b/apps/api/tests/integration/test_gradebook.py index a1dc370..85d06c6 100644 --- a/apps/api/tests/integration/test_gradebook.py +++ b/apps/api/tests/integration/test_gradebook.py @@ -27,7 +27,9 @@ async def _submit_true_false(client: AsyncClient, access: str, evaluation_id: st async def _setup_group_with_two_evaluations( - client: AsyncClient, teacher_access: str + client: AsyncClient, + teacher_access: str, + weight_percents: tuple[float | None, float | None] = (None, None), ) -> tuple[str, str, str]: """Returns (group_id, evaluation_id_1, evaluation_id_2), each with one enabled true_false exercise worth 1 point.""" @@ -41,17 +43,18 @@ async def _setup_group_with_two_evaluations( await enable_topic(client, teacher_access, group["id"], topic_id) evaluation_ids = [] - for title in ("Quiz 1", "Quiz 2"): + for title, weight_percent in zip(("Quiz 1", "Quiz 2"), weight_percents, strict=True): + payload = { + "group_id": group["id"], + "title": title, + "mode": "cumulative", + "is_ranked": False, + "exercise_ids": [exercise["id"]], + } + if weight_percent is not None: + payload["weight_percent"] = weight_percent created = await client.post( - "/evaluations", - json={ - "group_id": group["id"], - "title": title, - "mode": "cumulative", - "is_ranked": False, - "exercise_ids": [exercise["id"]], - }, - headers=auth_headers(teacher_access), + "/evaluations", json=payload, headers=auth_headers(teacher_access) ) assert created.status_code == 201, created.text evaluation_ids.append(created.json()["id"]) @@ -98,6 +101,38 @@ async def test_gradebook_returns_matrix(client: AsyncClient, institution: Instit assert [s["evaluation_id"] for s in by_name["Estudiante B"]["scores"]] == [eval1_id] +async def test_gradebook_weighted_average_combines_evaluations_by_percent( + client: AsyncClient, institution: Institution +) -> None: + domain = institution.email_domains[0] + teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") + student_access, _ = await register_and_login( + client, email=f"est@{domain}", full_name="Estudiante A", role="student" + ) + + group_id, eval1_id, eval2_id = await _setup_group_with_two_evaluations( + client, teacher_access, weight_percents=(30.0, 70.0) + ) + group = (await client.get("/groups/mine", headers=auth_headers(teacher_access))).json()[0] + await join_group(client, student_access, group["invite_code"]) + + # El único ejercicio de cada evaluación vale 1 punto y la respuesta es + # correcta, así que total_score == max_score == 1.0 en ambas: la nota + # ponderada debe ser exactamente 30 + 70 = 100. + await _submit_true_false(client, student_access, eval1_id) + await _submit_true_false(client, student_access, eval2_id) + + resp = await client.get(f"/groups/{group_id}/gradebook", headers=auth_headers(teacher_access)) + assert resp.status_code == 200, resp.text + body = resp.json() + + weights = {e["id"]: e["weight_percent"] for e in body["evaluations"]} + assert weights == {eval1_id: 30.0, eval2_id: 70.0} + + student = next(s for s in body["students"] if s["full_name"] == "Estudiante A") + assert student["weighted_average"] == 100.0 + + async def test_gradebook_requires_teacher(client: AsyncClient, institution: Institution) -> None: domain = institution.email_domains[0] teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") diff --git a/apps/api/tests/integration/test_guides_api.py b/apps/api/tests/integration/test_guides_api.py index 275824c..d76ec33 100644 --- a/apps/api/tests/integration/test_guides_api.py +++ b/apps/api/tests/integration/test_guides_api.py @@ -291,6 +291,59 @@ async def test_cannot_publish_a_guide_without_content( assert resp.status_code == 422 +async def test_delete_removes_a_draft_guide( + client: AsyncClient, institution: Institution, arq_pool: _FakeArqPool +) -> None: + teacher_access, group = await _teacher_with_group(client, institution) + language_id = await create_language(client, teacher_access) + topic_id = await create_topic(client, teacher_access, language_id) + folder_id = await _folder(client, teacher_access, group["id"]) + template = await _template(client, teacher_access) + + created = await client.post( + "/ai/guides/generate", + json={"folder_id": folder_id, "template_id": template["id"], "topic_id": topic_id}, + headers=auth_headers(teacher_access), + ) + guide_id = created.json()["id"] + await _force_draft(client, teacher_access, guide_id) + + resp = await client.delete(f"/guides/{guide_id}", headers=auth_headers(teacher_access)) + assert resp.status_code == 204 + + follow_up = await client.get(f"/guides/{guide_id}", headers=auth_headers(teacher_access)) + assert follow_up.status_code == 404 + + +async def test_delete_rejects_a_published_guide( + client: AsyncClient, institution: Institution, arq_pool: _FakeArqPool +) -> None: + teacher_access, group = await _teacher_with_group(client, institution) + language_id = await create_language(client, teacher_access) + topic_id = await create_topic(client, teacher_access, language_id) + folder_id = await _folder(client, teacher_access, group["id"]) + template = await _template(client, teacher_access) + + created = await client.post( + "/ai/guides/generate", + json={"folder_id": folder_id, "template_id": template["id"], "topic_id": topic_id}, + headers=auth_headers(teacher_access), + ) + guide_id = created.json()["id"] + await _force_draft(client, teacher_access, guide_id) + + published = await client.post( + f"/guides/{guide_id}/publish", headers=auth_headers(teacher_access) + ) + assert published.status_code == 200 + + resp = await client.delete(f"/guides/{guide_id}", headers=auth_headers(teacher_access)) + assert resp.status_code == 409 + + still_there = await client.get(f"/guides/{guide_id}", headers=auth_headers(teacher_access)) + assert still_there.status_code == 200 + + async def test_teacher_of_another_group_cannot_read_the_guide( client: AsyncClient, institution: Institution, arq_pool: _FakeArqPool ) -> None: diff --git a/apps/api/tests/integration/test_guides_autogeneration.py b/apps/api/tests/integration/test_guides_autogeneration.py index 2aac98b..57e66d3 100644 --- a/apps/api/tests/integration/test_guides_autogeneration.py +++ b/apps/api/tests/integration/test_guides_autogeneration.py @@ -2,8 +2,8 @@ Lo importante acá no es que el contenido salga bien (eso es `test_agent_guide_writer.py`) sino que el cron **no genere de más**: solo sobre -carpetas donde el docente eligió una plantilla, respetando el interruptor del -agente, sin duplicar, y con el tope por corrida.""" +carpetas donde el docente eligió una plantilla, sin duplicar, y con el tope +por corrida.""" import uuid from typing import Any @@ -23,7 +23,6 @@ from logica.modules.users.models import Institution from logica.workers import settings as worker_settings from tests.integration.conftest import ( - auth_headers, create_group, create_language, create_topic, @@ -159,25 +158,6 @@ async def test_locked_topic_is_not_generated(client: AsyncClient, institution: I assert await _guides_count(institution) == 0 -async def test_disabled_agent_blocks_the_cron( - client: AsyncClient, institution: Institution -) -> None: - ctx = await _setup(client, institution, auto_generate=True) - await enable_topic(client, ctx["teacher_access"], ctx["group"]["id"], ctx["topic_ids"][0]) - - await client.put( - f"/ai/groups/{ctx['group']['id']}/agents/guide_generation", - json={"enabled": False}, - headers=auth_headers(ctx["teacher_access"]), - ) - - created = await _run_cron(_FakeArqPool()) - - # El cron no puede saltarse el interruptor que el docente apagó. - assert created == 0 - assert await _guides_count(institution) == 0 - - async def test_second_run_does_not_duplicate(client: AsyncClient, institution: Institution) -> None: ctx = await _setup(client, institution, auto_generate=True) await enable_topic(client, ctx["teacher_access"], ctx["group"]["id"], ctx["topic_ids"][0]) diff --git a/apps/api/tests/integration/test_progress.py b/apps/api/tests/integration/test_progress.py index e5bce50..bc5c612 100644 --- a/apps/api/tests/integration/test_progress.py +++ b/apps/api/tests/integration/test_progress.py @@ -1,5 +1,3 @@ -from datetime import date, timedelta - from httpx import AsyncClient from logica.modules.users.models import Institution @@ -273,41 +271,3 @@ async def test_lagging_students_forbidden_for_students( assert resp.status_code == 403 -async def test_academic_period_create_and_list( - client: AsyncClient, institution: Institution -) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") - - today = date.today() - resp = await client.post( - "/academic-periods", - json={ - "name": "Periodo 1 - 2026", - "start_date": str(today - timedelta(days=30)), - "end_date": str(today + timedelta(days=30)), - }, - headers=auth_headers(teacher_access), - ) - assert resp.status_code == 201, resp.text - period = resp.json() - assert period["name"] == "Periodo 1 - 2026" - - listed = await client.get("/academic-periods", headers=auth_headers(student_access)) - assert listed.status_code == 200 - assert any(p["id"] == period["id"] for p in listed.json()) - - -async def test_academic_period_create_forbidden_for_students( - client: AsyncClient, institution: Institution -) -> None: - domain = institution.email_domains[0] - student_access, _ = await register_and_login(client, email=f"est@{domain}", role="student") - - resp = await client.post( - "/academic-periods", - json={"name": "Periodo X", "start_date": "2026-01-01", "end_date": "2026-06-30"}, - headers=auth_headers(student_access), - ) - assert resp.status_code == 403 diff --git a/apps/api/tests/integration/test_reports.py b/apps/api/tests/integration/test_reports.py index 6fcde0d..c01acb4 100644 --- a/apps/api/tests/integration/test_reports.py +++ b/apps/api/tests/integration/test_reports.py @@ -165,19 +165,6 @@ async def test_xlsx_report_includes_lagging_status_column( assert data_row[-1].startswith("Precisión") -async def test_report_requires_valid_period(client: AsyncClient, institution: Institution) -> None: - domain = institution.email_domains[0] - teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") - group = await create_group(client, teacher_access) - - resp = await client.post( - f"/groups/{group['id']}/reports", - json={"format": "xlsx", "period_id": "00000000-0000-0000-0000-000000000000"}, - headers=auth_headers(teacher_access), - ) - assert resp.status_code == 404 - - async def test_generate_group_report_is_noop_when_already_done( client: AsyncClient, institution: Institution ) -> None: diff --git a/apps/api/tests/integration/test_rubric_document_extraction.py b/apps/api/tests/integration/test_rubric_document_extraction.py new file mode 100644 index 0000000..0c29ab4 --- /dev/null +++ b/apps/api/tests/integration/test_rubric_document_extraction.py @@ -0,0 +1,127 @@ +"""Tests de `POST /rubric-runs/extract-topics`: un paso de *preview* puro que +lee un PDF/DOCX de rúbrica institucional y propone temas, sin crear ningún +`RubricRun` ni tocar la DB de negocio del docente.""" + +import io + +import pytest +from docx import Document +from httpx import AsyncClient + +from logica.ai.harness.router import CompletionResult +from logica.modules.users.models import Institution +from tests.integration.conftest import auth_headers, register_and_login + +_EXTRACTION_JSON = ( + '{"items": [{"topic_name": "Estructuras condicionales", "level": "basico", ' + '"order_index": 0}, {"topic_name": "Ciclos anidados", "level": "intermedio", ' + '"order_index": 1}]}' +) + + +def _stub_model(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake(task: str, messages: list[dict[str, str]]) -> CompletionResult: + return CompletionResult( + text=_EXTRACTION_JSON, model="groq/fake", prompt_tokens=10, completion_tokens=20 + ) + + monkeypatch.setattr("logica.ai.harness.harness.router_complete", fake) + + +def _build_docx_bytes(paragraphs: list[str]) -> bytes: + document = Document() + for text in paragraphs: + document.add_paragraph(text) + buffer = io.BytesIO() + document.save(buffer) + return buffer.getvalue() + + +async def test_extrae_temas_de_un_docx( + client: AsyncClient, institution: Institution, monkeypatch: pytest.MonkeyPatch +) -> None: + _stub_model(monkeypatch) + domain = institution.email_domains[0] + teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") + + raw = _build_docx_bytes(["Unidad 1", "Estructuras condicionales", "Ciclos anidados"]) + resp = await client.post( + "/rubric-runs/extract-topics", + files={ + "file": ( + "rubrica.docx", + raw, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + }, + headers=auth_headers(teacher_access), + ) + + assert resp.status_code == 200, resp.text + items = resp.json()["items"] + assert [item["topic_name"] for item in items] == [ + "Estructuras condicionales", + "Ciclos anidados", + ] + assert items[1]["level"] == "intermedio" + + +async def test_rechaza_extension_no_soportada( + client: AsyncClient, institution: Institution +) -> None: + domain = institution.email_domains[0] + teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") + + resp = await client.post( + "/rubric-runs/extract-topics", + files={"file": ("rubrica.txt", b"Estructuras condicionales", "text/plain")}, + headers=auth_headers(teacher_access), + ) + + assert resp.status_code == 422, resp.text + + +async def test_rechaza_docx_sin_texto_legible( + client: AsyncClient, institution: Institution +) -> None: + domain = institution.email_domains[0] + teacher_access, _ = await register_and_login(client, email=f"doc@{domain}", role="teacher") + + raw = _build_docx_bytes([]) + resp = await client.post( + "/rubric-runs/extract-topics", + files={ + "file": ( + "vacio.docx", + raw, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + }, + headers=auth_headers(teacher_access), + ) + + assert resp.status_code == 422, resp.text + + +async def test_estudiante_no_puede_extraer_temas( + client: AsyncClient, institution: Institution +) -> None: + domain = institution.email_domains[0] + student_access, _ = await register_and_login( + client, email=f"est@{domain}", role="student", student_code="E001" + ) + + raw = _build_docx_bytes(["Estructuras condicionales"]) + resp = await client.post( + "/rubric-runs/extract-topics", + files={ + "file": ( + "rubrica.docx", + raw, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + }, + headers=auth_headers(student_access), + ) + + assert resp.status_code == 403, resp.text diff --git a/apps/api/tests/unit/test_grading.py b/apps/api/tests/unit/test_grading.py index 9576fa1..bbdc12b 100644 --- a/apps/api/tests/unit/test_grading.py +++ b/apps/api/tests/unit/test_grading.py @@ -151,36 +151,6 @@ def test_identical_trace_always_scores_full(self, steps: list[dict[str, str]]) - assert result.correct is True -class TestOrderLines: - def test_exact_order_correct(self) -> None: - result = grade_exercise( - ExerciseType.order_lines, {"correct_order": [2, 0, 1]}, {"order": [2, 0, 1]} - ) - assert result.correct is True - assert result.score == 1.0 - - def test_partial_order(self) -> None: - result = grade_exercise( - ExerciseType.order_lines, {"correct_order": [0, 1, 2]}, {"order": [0, 2, 1]} - ) - assert result.correct is False - assert result.score == pytest.approx(1 / 3) - - def test_empty_answer(self) -> None: - result = grade_exercise( - ExerciseType.order_lines, {"correct_order": [0, 1, 2]}, {"order": None} - ) - assert result.score == 0.0 - - @given(order=st.permutations([0, 1, 2, 3, 4])) - def test_any_permutation_matched_against_itself_is_perfect(self, order: list[int]) -> None: - result = grade_exercise( - ExerciseType.order_lines, {"correct_order": order}, {"order": order} - ) - assert result.score == 1.0 - assert result.correct is True - - class TestArguedResponse: def test_nonempty_answer_needs_manual_review(self) -> None: result = grade_exercise( diff --git a/apps/api/tests/unit/test_rubric_document_extraction.py b/apps/api/tests/unit/test_rubric_document_extraction.py new file mode 100644 index 0000000..5766ca6 --- /dev/null +++ b/apps/api/tests/unit/test_rubric_document_extraction.py @@ -0,0 +1,73 @@ +"""Tests unitarios de `modules/rubrics/document_extraction.py` — solo la +extracción de texto crudo, sin tocar la DB ni el harness de IA.""" + +import io + +import pytest +from docx import Document +from pypdf import PdfWriter + +from logica.core.errors import ValidationDomainError +from logica.modules.rubrics import document_extraction + + +def _build_pdf_bytes(text: str) -> bytes: + writer = PdfWriter() + writer.add_blank_page(width=200, height=200) + buffer = io.BytesIO() + writer.write(buffer) + return buffer.getvalue() + + +def _build_docx_bytes(paragraphs: list[str], table_rows: list[list[str]] | None = None) -> bytes: + document = Document() + for text in paragraphs: + document.add_paragraph(text) + if table_rows: + table = document.add_table(rows=0, cols=len(table_rows[0])) + for row_values in table_rows: + row = table.add_row() + for cell, value in zip(row.cells, row_values, strict=True): + cell.text = value + buffer = io.BytesIO() + document.save(buffer) + return buffer.getvalue() + + +def test_extract_text_from_docx_incluye_parrafos_y_tablas() -> None: + raw = _build_docx_bytes( + ["Temario del curso"], + table_rows=[["Unidad", "Tema"], ["1", "Estructuras condicionales"]], + ) + text = document_extraction.extract_text_from_docx(raw) + assert "Temario del curso" in text + assert "Estructuras condicionales" in text + + +def test_extract_text_from_docx_vacio_lanza_error() -> None: + raw = _build_docx_bytes([]) + with pytest.raises(ValidationDomainError): + document_extraction.extract_text_from_docx(raw) + + +def test_extract_text_from_docx_corrupto_lanza_error() -> None: + with pytest.raises(ValidationDomainError): + document_extraction.extract_text_from_docx(b"esto no es un docx valido") + + +def test_extract_text_from_pdf_sin_texto_lanza_error() -> None: + raw = _build_pdf_bytes("") + with pytest.raises(ValidationDomainError): + document_extraction.extract_text_from_pdf(raw) + + +def test_extract_text_from_pdf_corrupto_lanza_error() -> None: + with pytest.raises(ValidationDomainError): + document_extraction.extract_text_from_pdf(b"esto no es un pdf valido") + + +def test_truncate_recorta_texto_largo() -> None: + long_paragraph = "Tema repetido. " * 3000 + raw = _build_docx_bytes([long_paragraph]) + text = document_extraction.extract_text_from_docx(raw) + assert len(text) <= document_extraction._MAX_EXTRACTED_CHARS diff --git a/apps/api/uv.lock b/apps/api/uv.lock index 89fddb4..4abe992 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -1145,6 +1145,8 @@ dependencies = [ { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "pypdf" }, + { name = "python-docx" }, { name = "python-multipart" }, { name = "pyyaml" }, { name = "redis" }, @@ -1192,6 +1194,8 @@ requires-dist = [ { name = "pydantic", extras = ["email"], specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "pyjwt", specifier = ">=2.10" }, + { name = "pypdf", specifier = ">=5.0" }, + { name = "python-docx", specifier = ">=1.1" }, { name = "python-multipart", specifier = ">=0.0.17" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "redis", specifier = ">=5.2" }, @@ -1219,6 +1223,32 @@ dev = [ { name = "ruff", specifier = ">=0.8" }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, +] + [[package]] name = "mako" version = "1.3.12" @@ -2024,6 +2054,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pypdf" +version = "6.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" }, +] + [[package]] name = "pyphen" version = "0.17.2" @@ -2089,6 +2128,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/ae/84bc0d2440c95772272bb6f4b3d09ccf08b2898fce89b3d4f969a9fc74e9/python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe", size = 34181, upload-time = "2026-07-08T23:06:49.402Z" }, ] +[[package]] +name = "python-docx" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" diff --git a/apps/web/src/components/exercises/OrderLinesRenderer.tsx b/apps/web/src/components/exercises/OrderLinesRenderer.tsx deleted file mode 100644 index 3b1764b..0000000 --- a/apps/web/src/components/exercises/OrderLinesRenderer.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Reorder } from 'motion/react' - -import { cn } from '../../lib/cn' -import type { ExerciseRendererProps } from './types' - -interface OrderLinesAnswer { - order: number[] -} - -export function OrderLinesRenderer({ - content, - value, - onChange, - disabled, -}: ExerciseRendererProps) { - const statement = String(content.statement ?? '') - const lines = Array.isArray(content.lines) ? (content.lines as string[]) : [] - const order = value?.order ?? lines.map((_, i) => i) - - return ( -
-

{statement}

- onChange({ order: next })} - className="flex flex-col gap-2" - > - {order.map((originalIndex) => ( - - {lines[originalIndex]} - - ))} - -

Arrastra las líneas para ordenarlas.

-
- ) -} diff --git a/apps/web/src/components/exercises/registry.tsx b/apps/web/src/components/exercises/registry.tsx index f2c95cc..e619ff5 100644 --- a/apps/web/src/components/exercises/registry.tsx +++ b/apps/web/src/components/exercises/registry.tsx @@ -3,7 +3,6 @@ import { FillCodeRenderer } from './FillCodeRenderer' import { FindErrorRenderer } from './FindErrorRenderer' import { LiveCodeRenderer } from './LiveCodeRenderer' import { MultipleChoiceRenderer } from './MultipleChoiceRenderer' -import { OrderLinesRenderer } from './OrderLinesRenderer' import { TraceVariablesRenderer } from './TraceVariablesRenderer' import { TrueFalseRenderer } from './TrueFalseRenderer' import type { ExerciseRendererProps } from './types' @@ -14,7 +13,6 @@ export type ExerciseType = | 'fill_code' | 'find_error' | 'trace_variables' - | 'order_lines' | 'argued_response' | 'live_code' @@ -29,7 +27,6 @@ export const exerciseRenderers: Record< fill_code: FillCodeRenderer as never, find_error: FindErrorRenderer as never, trace_variables: TraceVariablesRenderer as never, - order_lines: OrderLinesRenderer as never, argued_response: ArguedResponseRenderer as never, live_code: LiveCodeRenderer as never, } @@ -40,7 +37,6 @@ export const EXERCISE_TYPE_LABELS: Record = { fill_code: 'Completar código', find_error: 'Encontrar el error', trace_variables: 'Trazado de variables', - order_lines: 'Ordenar líneas', argued_response: 'Respuesta argumentada', live_code: 'Reto de código en vivo', } diff --git a/apps/web/src/components/exercises/renderers.test.tsx b/apps/web/src/components/exercises/renderers.test.tsx index b0f3302..3b199c8 100644 --- a/apps/web/src/components/exercises/renderers.test.tsx +++ b/apps/web/src/components/exercises/renderers.test.tsx @@ -6,7 +6,6 @@ import { ArguedResponseRenderer } from './ArguedResponseRenderer' import { FillCodeRenderer } from './FillCodeRenderer' import { FindErrorRenderer } from './FindErrorRenderer' import { MultipleChoiceRenderer } from './MultipleChoiceRenderer' -import { OrderLinesRenderer } from './OrderLinesRenderer' import { TraceVariablesRenderer } from './TraceVariablesRenderer' import { TrueFalseRenderer } from './TrueFalseRenderer' @@ -108,21 +107,6 @@ describe('TraceVariablesRenderer', () => { }) }) -describe('OrderLinesRenderer', () => { - it('starts with the identity order over content.lines', () => { - const onChange = vi.fn() - render( - , - ) - expect(screen.getByText('def sumar(a, b):')).toBeInTheDocument() - expect(screen.getByText('return a + b')).toBeInTheDocument() - }) -}) - describe('ArguedResponseRenderer', () => { it('emits {text} as the student types and shows the manual-review notice', () => { const onChange = vi.fn() diff --git a/apps/web/src/components/layout/AppShell.test.tsx b/apps/web/src/components/layout/AppShell.test.tsx new file mode 100644 index 0000000..b3ae558 --- /dev/null +++ b/apps/web/src/components/layout/AppShell.test.tsx @@ -0,0 +1,90 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { AppShell } from './AppShell' +import { AuthProvider } from '../../hooks/useAuth' +import { clearTokens, setTokens } from '../../lib/api/tokens' + +function stubApi(role: 'teacher' | 'student') { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : (input as Request).url + if (url.includes('/users/me')) { + return new Response(JSON.stringify({ id: '1', full_name: 'Ana', role }), { status: 200 }) + } + return new Response(JSON.stringify([]), { status: 200 }) + }), + ) +} + +function renderShell() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const router = createMemoryRouter( + [ + { + element: , + children: [ + { path: '/app', element:
Mis grupos (estudiante)
}, + { path: '/app/docente', element:
Grupos (docente)
}, + ], + }, + ], + { initialEntries: ['/app/docente'] }, + ) + return render( + + + + + , + ) +} + +describe('AppShell — vista previa como estudiante', () => { + beforeEach(() => { + clearTokens() + setTokens('valid-access', 'valid-refresh') + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + sessionStorage.clear() + }) + + it('un estudiante no ve el botón de vista previa', async () => { + stubApi('student') + renderShell() + + await waitFor(() => expect(screen.getByText('Ana')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Ver como estudiante' })).not.toBeInTheDocument() + }) + + it('un docente puede activar la vista de estudiante y el menú/banner cambian', async () => { + stubApi('teacher') + renderShell() + + await waitFor(() => expect(screen.getByText('Ana')).toBeInTheDocument()) + expect(screen.getByRole('link', { name: 'Banco de ejercicios' })).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Ver como estudiante' })) + + expect(screen.getByText('Estás viendo la plataforma como estudiante.')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Mi progreso' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Banco de ejercicios' })).not.toBeInTheDocument() + + // Aparece dos veces: en el banner y en el botón del sidebar que cambió de + // texto al activar la vista de estudiante. + const backButtons = screen.getAllByRole('button', { name: 'Volver a vista docente' }) + await userEvent.click(backButtons[0]) + + expect( + screen.queryByText('Estás viendo la plataforma como estudiante.'), + ).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Banco de ejercicios' })).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 33f3fc5..0500570 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -4,10 +4,13 @@ import { NavLink, Outlet } from 'react-router' import { cn } from '../../lib/cn' import { useAuth } from '../../hooks/useAuth' +import { useDraftState } from '../../hooks/useDraftState' import { Button } from '../ui/Button' import { ThemeToggle } from '../ui/ThemeToggle' import { NotificationBell } from './NotificationBell' +type ViewAs = 'teacher' | 'student' + interface NavItem { to: string label: string @@ -24,11 +27,23 @@ const teacherNav: NavItem[] = [ { to: '/app/docente/ejercicios', label: 'Banco de ejercicios' }, { to: '/app/docente/evaluaciones/nueva', label: 'Nueva evaluación' }, { to: '/app/docente/bandeja', label: 'Bandeja de aprobaciones' }, + { to: '/app/docente/pendientes', label: 'Pendientes' }, { to: '/app/docente/actividad', label: 'Actividad y errores' }, - { to: '/app/admin/periodos', label: 'Periodos académicos' }, ] -function SidebarContent({ nav, onNavigate }: { nav: NavItem[]; onNavigate?: () => void }) { +function SidebarContent({ + nav, + onNavigate, + canPreviewAsStudent, + viewAs, + setViewAs, +}: { + nav: NavItem[] + onNavigate?: () => void + canPreviewAsStudent: boolean + viewAs: ViewAs + setViewAs: (next: ViewAs) => void +}) { const { user, logout } = useAuth() return ( <> @@ -62,6 +77,16 @@ function SidebarContent({ nav, onNavigate }: { nav: NavItem[]; onNavigate?: () = + {canPreviewAsStudent && ( + + )} ) @@ -71,12 +96,22 @@ export function AppShell() { const { user } = useAuth() const [mobileNavOpen, setMobileNavOpen] = useState(false) const isTeacher = user?.role === 'teacher' || user?.role === 'admin' - const nav = isTeacher ? teacherNav : studentNav + // `sessionStorage` y no persistente: es una vista temporal para revisar la + // plataforma, no una preferencia — reaparecer activada en otra sesión + // confundiría a un docente que la prendió una vez para probar algo. + const [viewAs, setViewAs] = useDraftState('viewAs', 'teacher') + const effectiveViewAs = isTeacher ? viewAs : 'student' + const nav = effectiveViewAs === 'teacher' ? teacherNav : studentNav return (
@@ -102,6 +137,15 @@ export function AppShell() {
+ {isTeacher && viewAs === 'student' && ( +
+ Estás viendo la plataforma como estudiante. + +
+ )} +
@@ -126,7 +170,13 @@ export function AppShell() { onClick={(e) => e.stopPropagation()} className="flex h-full w-64 max-w-[80vw] flex-col border-r border-hairline bg-surface p-4" > - setMobileNavOpen(false)} /> + setMobileNavOpen(false)} + canPreviewAsStudent={isTeacher} + viewAs={effectiveViewAs} + setViewAs={setViewAs} + /> )} diff --git a/apps/web/src/features/admin/AcademicPeriodsPage.tsx b/apps/web/src/features/admin/AcademicPeriodsPage.tsx deleted file mode 100644 index 64fb8bb..0000000 --- a/apps/web/src/features/admin/AcademicPeriodsPage.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' - -import { Button } from '../../components/ui/Button' -import { Card } from '../../components/ui/Card' -import { EmptyState } from '../../components/ui/EmptyState' -import { FieldError, Input, Label } from '../../components/ui/Input' -import { apiClient, ApiError, unwrap } from '../../lib/api/client' -import { qk } from '../../lib/api/queries' - -export function AcademicPeriodsPage() { - const queryClient = useQueryClient() - const [name, setName] = useState('') - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') - const [error, setError] = useState(null) - - const { data: periods, isLoading } = useQuery({ - queryKey: qk.academicPeriods, - queryFn: () => unwrap(apiClient.GET('/academic-periods')), - }) - - const create = useMutation({ - mutationFn: () => - unwrap( - apiClient.POST('/academic-periods', { - body: { name, start_date: startDate, end_date: endDate }, - }), - ), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: qk.academicPeriods }) - setName('') - setStartDate('') - setEndDate('') - }, - onError: (err) => setError(err instanceof ApiError ? err.detail : 'No se pudo crear el periodo'), - }) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError(null) - create.mutate() - } - - return ( -
-

Periodos académicos

- - -
-
- - setName(e.target.value)} - /> -
-
-
- - setStartDate(e.target.value)} - /> -
-
- - setEndDate(e.target.value)} - /> -
-
- {error} - - -
- - {!isLoading && periods?.length === 0 && ( - - )} - -
- {periods?.map((period) => ( - - {period.name} - - {period.start_date} — {period.end_date} - - - ))} -
-
- ) -} diff --git a/apps/web/src/features/teacher/AgentsTab.tsx b/apps/web/src/features/teacher/AgentsTab.tsx deleted file mode 100644 index 0474755..0000000 --- a/apps/web/src/features/teacher/AgentsTab.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' - -import { Card } from '../../components/ui/Card' -import { apiClient, unwrap } from '../../lib/api/client' -import { qk } from '../../lib/api/queries' -import type { components } from '../../lib/api/schema' - -type AgentName = components['schemas']['AgentName'] - -const AGENT_INFO: Record = { - progressive_hint: { - label: 'Tutor', - description: 'Pistas progresivas en la práctica libre — nunca revela la solución completa.', - }, - exercise_generation: { - label: 'Generador de ejercicios', - description: 'Propone ejercicios nuevos como borrador, a la espera de tu aprobación.', - }, - grading_suggestion: { - label: 'Asistente de calificación', - description: 'Sugiere una nota y justificación para respuestas argumentadas — tú confirmas.', - }, - summarize_group: { - label: 'Analítica de aprendizaje', - description: 'Resume el avance del grupo en lenguaje natural bajo pedido.', - }, - code_integrity: { - label: 'Integridad de código', - description: 'Alerta advisory sobre posibles indicios de copia — nunca aplica una sanción.', - }, - guide_generation: { - label: 'Creador de guías', - description: - 'Redacta guías de clase como borrador, fundamentadas en tu material de apoyo. Apagarlo ' + - 'también detiene la autogeneración al habilitar un tema.', - }, -} - -const AGENT_ORDER: AgentName[] = [ - 'progressive_hint', - 'exercise_generation', - 'grading_suggestion', - 'summarize_group', - 'code_integrity', - 'guide_generation', -] - -export function AgentsTab({ groupId }: { groupId: string }) { - const queryClient = useQueryClient() - - const { data: configs } = useQuery({ - queryKey: qk.ai.agentConfig(groupId), - queryFn: () => - unwrap(apiClient.GET('/ai/groups/{group_id}/agents', { params: { path: { group_id: groupId } } })), - }) - - const toggle = useMutation({ - mutationFn: ({ agentName, enabled }: { agentName: AgentName; enabled: boolean }) => - unwrap( - apiClient.PUT('/ai/groups/{group_id}/agents/{agent_name}', { - params: { path: { group_id: groupId, agent_name: agentName } }, - body: { enabled }, - }), - ), - onSuccess: () => void queryClient.invalidateQueries({ queryKey: qk.ai.agentConfig(groupId) }), - }) - - const enabledByAgent = new Map(configs?.map((c) => [c.agent_name, c.enabled]) ?? []) - - return ( -
- {AGENT_ORDER.map((agentName) => { - // Ausencia de fila = habilitado por defecto (RF-30). - const enabled = enabledByAgent.get(agentName) ?? true - const info = AGENT_INFO[agentName] - return ( - -
-

{info.label}

-

{info.description}

-
-
@@ -195,7 +262,7 @@ export function AssignmentsTab({ groupId }: { groupId: string }) {

{assignment.title}

- {assignment.topic_id ? 'Tema' : 'Ejercicio'}: {nameFor(assignment)} + {targetKindLabel(assignment)}: {nameFor(assignment)}

diff --git a/apps/web/src/features/teacher/EvaluationBuilderPage.test.tsx b/apps/web/src/features/teacher/EvaluationBuilderPage.test.tsx new file mode 100644 index 0000000..b67e895 --- /dev/null +++ b/apps/web/src/features/teacher/EvaluationBuilderPage.test.tsx @@ -0,0 +1,123 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { EvaluationBuilderPage } from './EvaluationBuilderPage' + +const EXERCISE = { + id: 'ex-1', + institution_id: 'inst-1', + language_id: 'lang-1', + title: 'Condicionales básicos', + type: 'true_false', + content: { statement: 'x', answer: true }, + origin: 'teacher', + status: 'published', + version: 1, +} + +type Call = { url: string; method: string; body: string } + +/** Mismo patrón que RubricTab.test.tsx/GuidesTab.test.tsx: enruta por URL, + * gana la clave más larga. */ +function stubApi(routes: Record, calls?: Call[]) { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const request = input as Request + const url = typeof input === 'string' ? input : request.url + if (calls && typeof input !== 'string') { + calls.push({ + url, + method: request.method, + body: request.method === 'GET' ? '' : await request.clone().text(), + }) + } + const match = Object.keys(routes) + .filter((key) => url.includes(key)) + .sort((a, b) => b.length - a.length)[0] + return new Response(JSON.stringify(match ? routes[match] : []), { status: 200 }) + }), + ) +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('EvaluationBuilderPage — variantes por IA', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('pedir variantes las muestra para revisión, sin crearlas todavía', async () => { + const calls: Call[] = [] + stubApi( + { + 'groups/mine': [], + topics: [], + exercises: [EXERCISE], + 'ai/exercises/ex-1/variants': [ + { title: 'Condicionales — variante 1', content: { statement: 'y', answer: false } }, + ], + }, + calls, + ) + renderPage() + + await screen.findByText('Condicionales básicos') + await userEvent.click(screen.getByRole('button', { name: 'Pedir variantes' })) + + await screen.findByText('Condicionales — variante 1') + expect(screen.getByRole('button', { name: 'Aceptar' })).toBeInTheDocument() + // Todavía no se creó nada: ni POST a /exercises. + const persistedSomething = calls.some( + (c) => c.method === 'POST' && c.url.includes('/exercises') && !c.url.includes('variants'), + ) + expect(persistedSomething).toBe(false) + }) + + it('aceptar una variante la crea y la deja seleccionada', async () => { + const calls: Call[] = [] + stubApi( + { + 'groups/mine': [], + topics: [], + exercises: [EXERCISE], + 'ai/exercises/ex-1/variants': [ + { title: 'Condicionales — variante 1', content: { statement: 'y', answer: false } }, + ], + }, + calls, + ) + renderPage() + + await screen.findByText('Condicionales básicos') + await userEvent.click(screen.getByRole('button', { name: 'Pedir variantes' })) + await screen.findByText('Condicionales — variante 1') + + await userEvent.click(screen.getByRole('button', { name: 'Aceptar' })) + + await waitFor(() => + expect( + calls.some( + (c) => c.method === 'POST' && c.url.endsWith('/exercises') && c.body.includes('variante 1'), + ), + ).toBe(true), + ) + // La variante aceptada desaparece de la lista de revisión. + await waitFor(() => + expect(screen.queryByText('Condicionales — variante 1')).not.toBeInTheDocument(), + ) + }) +}) diff --git a/apps/web/src/features/teacher/EvaluationBuilderPage.tsx b/apps/web/src/features/teacher/EvaluationBuilderPage.tsx index 6b6fea8..0303443 100644 --- a/apps/web/src/features/teacher/EvaluationBuilderPage.tsx +++ b/apps/web/src/features/teacher/EvaluationBuilderPage.tsx @@ -1,27 +1,49 @@ -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { useNavigate } from 'react-router' -import { EXERCISE_TYPE_LABELS } from '../../components/exercises/registry' +import { EXERCISE_TYPE_LABELS, type ExerciseType } from '../../components/exercises/registry' import { Button } from '../../components/ui/Button' +import { Callout } from '../../components/ui/Callout' import { Card } from '../../components/ui/Card' +import { Dialog } from '../../components/ui/Dialog' import { FieldError, Input, Label } from '../../components/ui/Input' import { Select } from '../../components/ui/Select' +import { Spinner } from '../../components/ui/Spinner' import { Tag } from '../../components/ui/Tag' +import { pushToast } from '../../components/ui/toastStore' import { apiClient, ApiError, unwrap } from '../../lib/api/client' import { qk } from '../../lib/api/queries' +import type { components } from '../../lib/api/schema' +import { ExerciseContentForm } from './ExerciseContentForm' + +type Exercise = components['schemas']['ExerciseOut'] + +interface ExerciseVariant { + title: string + content: Record +} export function EvaluationBuilderPage() { const navigate = useNavigate() + const queryClient = useQueryClient() const [groupId, setGroupId] = useState('') const [title, setTitle] = useState('') const [mode, setMode] = useState<'fixed' | 'cumulative'>('cumulative') const [upToTopicId, setUpToTopicId] = useState('') const [durationMinutes, setDurationMinutes] = useState('') const [isRanked, setIsRanked] = useState(false) + const [weightPercent, setWeightPercent] = useState('') const [selectedExercises, setSelectedExercises] = useState([]) const [error, setError] = useState(null) + // Variantes por IA (ítem 21): se piden a partir de un ejercicio ya + // publicado, se revisan/editan acá mismo, y solo las aceptadas se crean de + // verdad (vía POST /exercises) y quedan seleccionadas para la evaluación. + const [variantSourceId, setVariantSourceId] = useState(null) + const [variants, setVariants] = useState([]) + const [editingVariantIndex, setEditingVariantIndex] = useState(null) + const { data: groups } = useQuery({ queryKey: qk.groups.mine, queryFn: () => unwrap(apiClient.GET('/groups/mine')), @@ -37,8 +59,27 @@ export function EvaluationBuilderPage() { queryFn: () => unwrap(apiClient.GET('/exercises')), }) + const { data: groupEvaluations } = useQuery({ + queryKey: qk.groupEvaluations(groupId), + queryFn: () => + unwrap( + apiClient.GET('/groups/{group_id}/evaluations', { params: { path: { group_id: groupId } } }), + ), + enabled: Boolean(groupId), + }) + const publishedExercises = (exercises ?? []).filter((e) => e.status === 'published') + // Suma de lo que YA pesa el grupo más lo que se está por crear — aviso, no + // bloqueo: un docente puede estar a mitad de armar el periodo y todavía no + // haber cargado todas las evaluaciones. + const existingWeightSum = (groupEvaluations ?? []).reduce( + (sum, e) => sum + (e.weight_percent ?? 0), + 0, + ) + const newWeight = weightPercent ? Number(weightPercent) : 0 + const totalWeightAfterCreate = existingWeightSum + newWeight + const create = useMutation({ mutationFn: () => unwrap( @@ -50,6 +91,7 @@ export function EvaluationBuilderPage() { up_to_topic_id: mode === 'fixed' ? upToTopicId : null, duration_minutes: durationMinutes ? Number(durationMinutes) : null, is_ranked: isRanked, + weight_percent: weightPercent ? Number(weightPercent) : null, exercise_ids: selectedExercises, }, }), @@ -58,6 +100,52 @@ export function EvaluationBuilderPage() { onError: (err) => setError(err instanceof ApiError ? err.detail : 'No se pudo crear la evaluación'), }) + const generateVariants = useMutation({ + mutationFn: (exerciseId: string) => + unwrap( + apiClient.POST('/ai/exercises/{exercise_id}/variants', { + params: { path: { exercise_id: exerciseId } }, + body: { count: 3 }, + }), + ), + onSuccess: (result, exerciseId) => { + setVariantSourceId(exerciseId) + setVariants(result) + if (result.length === 0) { + pushToast('La IA no pudo generar variantes esta vez', 'error') + } + }, + onError: (err) => + setError(err instanceof ApiError ? err.detail : 'No se pudieron generar variantes'), + }) + + const acceptVariant = useMutation({ + mutationFn: ({ variant, source }: { variant: ExerciseVariant; source: Exercise }) => + unwrap( + apiClient.POST('/exercises', { + body: { + language_id: source.language_id, + title: variant.title, + type: source.type, + content: variant.content, + status: 'published', + }, + }), + ), + onSuccess: (created, { variant }) => { + setSelectedExercises((prev) => [...prev, created.id]) + setVariants((prev) => prev.filter((v) => v !== variant)) + void queryClient.invalidateQueries({ queryKey: qk.exercises() }) + pushToast('Variante aceptada y seleccionada', 'success') + }, + onError: (err) => + setError(err instanceof ApiError ? err.detail : 'No se pudo aceptar la variante'), + }) + + function discardVariant(variant: ExerciseVariant) { + setVariants((prev) => prev.filter((v) => v !== variant)) + } + function toggleExercise(id: string) { setSelectedExercises((prev) => (prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id])) } @@ -134,17 +222,34 @@ export function EvaluationBuilderPage() { onChange={(e) => setDurationMinutes(e.target.value)} />
-
- +
+ + setWeightPercent(e.target.value)} + />
+ + {groupId && totalWeightAfterCreate > 0 && totalWeightAfterCreate !== 100 && ( + + Las evaluaciones de este grupo suman {totalWeightAfterCreate}% de la nota + {totalWeightAfterCreate > 100 ? ', ya pasaste el 100%' : ' — todavía no llegan a 100%'} + . + + )}
@@ -154,22 +259,91 @@ export function EvaluationBuilderPage() {
{publishedExercises.map((exercise) => ( - + + +
))} + {variants.length > 0 && ( + +

Variantes generadas

+

+ Revísalas antes de aceptar — solo las que aceptes se crean y se suman a la + evaluación. +

+
+ {variants.map((variant, index) => { + const source = (exercises ?? []).find((e) => e.id === variantSourceId) + return ( +
+
+ {variant.title} + {source && {EXERCISE_TYPE_LABELS[source.type]}} +
+
+ + + +
+
+ ) + })} +
+
+ )} + {error} + + ) + })()} + ) } diff --git a/apps/web/src/features/teacher/ExerciseContentForm.test.tsx b/apps/web/src/features/teacher/ExerciseContentForm.test.tsx index c70238c..f4e5381 100644 --- a/apps/web/src/features/teacher/ExerciseContentForm.test.tsx +++ b/apps/web/src/features/teacher/ExerciseContentForm.test.tsx @@ -5,8 +5,16 @@ import { describe, expect, it, vi } from 'vitest' import { ExerciseContentForm } from './ExerciseContentForm' import type { ExerciseType } from '../../components/exercises/registry' -function Controlled({ type, onCommit }: { type: ExerciseType; onCommit: (v: object) => void }) { - const [value, setValue] = useState>({}) +function Controlled({ + type, + onCommit, + initialValue = {}, +}: { + type: ExerciseType + onCommit: (v: object) => void + initialValue?: Record +}) { + const [value, setValue] = useState>(initialValue) return ( { ) }) - it('order_lines: parses comma-separated correct_order into numbers', () => { - const onCommit = vi.fn() - render() - fireEvent.change(screen.getByPlaceholderText('1,0,2'), { target: { value: '1,0,2' } }) - expect(onCommit).toHaveBeenLastCalledWith( - expect.objectContaining({ correct_order: [1, 0, 2] }), - ) - }) - it('trace_variables: valid JSON updates expected_trace, invalid JSON shows an error and does not commit', () => { const onCommit = vi.fn() render() @@ -74,4 +73,34 @@ describe('ExerciseContentForm', () => { }) expect(onCommit).toHaveBeenLastCalledWith({ prompt: 'Explica la recursión' }) }) + + it('fill_code: sin código todavía, no ofrece "Reemplazar código"', () => { + const onCommit = vi.fn() + render() + expect(screen.queryByRole('button', { name: 'Reemplazar código' })).not.toBeInTheDocument() + }) + + it('fill_code: con código ya cargado, ofrece "Reemplazar código"', () => { + const onCommit = vi.fn() + render( + , + ) + expect(screen.getByRole('button', { name: 'Reemplazar código' })).toBeInTheDocument() + }) + + it('live_code: el editor de código inicial usa el lenguaje del ejercicio', () => { + const onCommit = vi.fn() + render( + , + ) + expect(screen.getByRole('button', { name: 'Reemplazar código' })).toBeInTheDocument() + }) }) diff --git a/apps/web/src/features/teacher/ExerciseContentForm.tsx b/apps/web/src/features/teacher/ExerciseContentForm.tsx index a9db9e2..0a3d34a 100644 --- a/apps/web/src/features/teacher/ExerciseContentForm.tsx +++ b/apps/web/src/features/teacher/ExerciseContentForm.tsx @@ -1,9 +1,12 @@ +import CodeMirror from '@uiw/react-codemirror' import { useState } from 'react' import { Button } from '../../components/ui/Button' import { FieldError, Input, Textarea } from '../../components/ui/Input' import { Select } from '../../components/ui/Select' import type { ExerciseType } from '../../components/exercises/registry' +import { getCodeMirrorExtensions } from '../../lib/codemirror/getExtensions' +import { useTheme } from '../../lib/theme' interface Props { type: ExerciseType @@ -51,6 +54,50 @@ function DynamicList({ ) } +/** Editor de código para `fill_code`/`live_code`: mismo CodeMirror que ya usa + * el estudiante en `LiveCodeRenderer` (resaltado, números de línea, e + * indentación intacta al pegar) — antes era un `Textarea` plano, incómodo + * para pegar el código propio del docente cuando lo que generó la IA no + * sirve. El botón "Reemplazar" vacía el campo primero para que pegar no dejе + * el código de la IA mezclado con el pegado. */ +function CodeField({ + value, + onChange, + language, + placeholder, + label, +}: { + value: string + onChange: (next: string) => void + language?: string + placeholder?: string + label: string +}) { + const theme = useTheme() + return ( +
+
+ + {value && ( + + )} +
+
+ +
+
+ ) +} + /** Formulario de autoría por tipo de ejercicio (RE-05 también del lado del * autor) — deliberadamente más simple que los renderers de práctica: aquí * el docente escribe la clave de respuesta, no la resuelve. Para los dos @@ -130,17 +177,12 @@ export function ExerciseContentForm({ type, value, onChange }: Props) { value={String(value.statement ?? '')} onChange={(e) => set({ statement: e.target.value })} /> -
- -
EstudianteCorreoEnvíosPrecisión EvaluacionesPromedioInsigniasEstado