From c02e3f967ab7120cd905a75c3c91548c88a27d3c Mon Sep 17 00:00:00 2001 From: bing1100 Date: Wed, 22 Jul 2026 19:38:32 +0000 Subject: [PATCH 1/9] criteria-building --- backend/api/criteria/README.md | 42 +++ backend/api/criteria/__init__.py | 7 + backend/api/criteria/models.py | 172 ++++++++++ backend/api/criteria/service.py | 309 ++++++++++++++++++ backend/api/services/sr_db_service.py | 81 ++++- backend/api/sr/router.py | 225 +++++++++++-- backend/tests/criteria_config_router_test.py | 104 ++++++ .../criteria_configuration_service_test.py | 151 +++++++++ frontend/app/[lang]/can-sr/setup/page.tsx | 7 + .../can-sr/reviews/criteria-config/route.ts | 56 ++++ .../can-sr/setup/criteria-builder.test.tsx | 69 ++++ .../can-sr/setup/criteria-builder.tsx | 137 ++++++++ .../can-sr/setup/criteria-editor.tsx | 87 +++++ .../components/can-sr/setup/criteria-types.ts | 136 ++++++++ frontend/dictionaries/en.json | 12 +- frontend/dictionaries/fr.json | 12 +- 16 files changed, 1580 insertions(+), 27 deletions(-) create mode 100644 backend/api/criteria/README.md create mode 100644 backend/api/criteria/__init__.py create mode 100644 backend/api/criteria/models.py create mode 100644 backend/api/criteria/service.py create mode 100644 backend/tests/criteria_config_router_test.py create mode 100644 backend/tests/criteria_configuration_service_test.py create mode 100644 frontend/app/api/can-sr/reviews/criteria-config/route.ts create mode 100644 frontend/components/can-sr/setup/criteria-builder.test.tsx create mode 100644 frontend/components/can-sr/setup/criteria-builder.tsx create mode 100644 frontend/components/can-sr/setup/criteria-editor.tsx create mode 100644 frontend/components/can-sr/setup/criteria-types.ts diff --git a/backend/api/criteria/README.md b/backend/api/criteria/README.md new file mode 100644 index 00000000..d9e93054 --- /dev/null +++ b/backend/api/criteria/README.md @@ -0,0 +1,42 @@ +# Criteria configuration backend contract + +This module is the Phase 1 foundation for the extended criteria UI. It introduces +canonical criteria configuration without replacing existing screening consumers. + +## Canonical storage + +- `criteria`: strict `schema_version: 2` JSON. +- `criteria_yaml`: deterministic YAML generated from the same model. +- `criteria_parsed`: compatibility projection for current screening and extraction + code, plus stable `items` with IDs. +- `criteria_revision`: optimistic concurrency token supplied as `expected_revision`. + +All four values are updated atomically by the canonical save operation. + +## API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/sr/{sr_id}/criteria-config` | Canonical config or read-only legacy migration preview. | +| `POST` | `/sr/{sr_id}/criteria-config/validate` | Validate canonical JSON. | +| `POST` | `/sr/{sr_id}/criteria-config/import-yaml` | Parse v2 or legacy YAML without saving. | +| `PUT` | `/sr/{sr_id}/criteria-config` | Save with revision and invalidation guards. | +| `GET` | `/sr/{sr_id}/criteria-config/export-yaml` | Export deterministic v2 YAML. | + +Validation failures return HTTP 422 with field paths. Stale revisions, +screening-data invalidation, and unconfirmed legacy migration return HTTP 409. + +## Migration and compatibility + +Legacy conversion is deterministic. IDs derive from source text with ordered +collision suffixes. Explicit `(exclude)` or `[exclude]` markers become `exclude`; +other answers default to `include`, with diagnostics for every inference. + +Load and import operations never persist migration output. Any write of migration +output that contains inferred semantics must include the exact +`migration_fingerprint` returned by the current preview. This applies to review +creation, canonical save, and the compatibility YAML update endpoint. + +Triggers may reference only earlier questions or selection parameters and must +target one of that source's options. Text parameters cannot be trigger sources. +The existing explicit `force` guard remains required when screening data exists. diff --git a/backend/api/criteria/__init__.py b/backend/api/criteria/__init__.py new file mode 100644 index 00000000..107f0d5d --- /dev/null +++ b/backend/api/criteria/__init__.py @@ -0,0 +1,7 @@ +"""Canonical criteria configuration domain.""" +from __future__ import annotations + +from .models import CriteriaConfigV2 +from .service import CriteriaConfigurationService + +__all__ = ['CriteriaConfigV2', 'CriteriaConfigurationService'] diff --git a/backend/api/criteria/models.py b/backend/api/criteria/models.py new file mode 100644 index 00000000..f952842b --- /dev/null +++ b/backend/api/criteria/models.py @@ -0,0 +1,172 @@ +"""Strict version 2 criteria configuration models and domain validation.""" +from __future__ import annotations + +from enum import Enum +from typing import Annotated +from typing import Literal + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import model_validator +from pydantic import StringConstraints + +CriteriaId = Annotated[ + str, StringConstraints( + pattern=r'^[a-z][a-z0-9_-]{2,63}$', + ), +] +NonEmptyText = Annotated[ + str, StringConstraints( + strip_whitespace=True, min_length=1, max_length=10000, + ), +] + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra='forbid') + + +class Decision(str, Enum): + INCLUDE = 'include' + EXCLUDE = 'exclude' + + +class TriggerCondition(StrictModel): + source_item_id: CriteriaId + option_id: CriteriaId + + +class Trigger(StrictModel): + all: list[TriggerCondition] = Field(default_factory=list, max_length=20) + + @model_validator(mode='after') + def conditions_are_unique(self) -> Trigger: + pairs = [ + (condition.source_item_id, condition.option_id) + for condition in self.all + ] + if len(pairs) != len(set(pairs)): + raise ValueError('duplicate trigger conditions are not allowed') + return self + + +class CitationFields(StrictModel): + l1_include: list[NonEmptyText] = Field( + default_factory=list, max_length=100, + ) + doi: NonEmptyText | None = None + + @model_validator(mode='after') + def include_fields_are_unique(self) -> CitationFields: + if len(self.l1_include) != len(set(self.l1_include)): + raise ValueError( + 'citation_fields.l1_include must not contain duplicates', + ) + return self + + +class ScreeningAnswer(StrictModel): + id: CriteriaId + label: NonEmptyText + context: str | None = Field(default=None, max_length=10000) + decision: Decision + + +class ScreeningQuestion(StrictModel): + id: CriteriaId + question: NonEmptyText + context: str | None = Field(default=None, max_length=10000) + answers: list[ScreeningAnswer] = Field(min_length=2, max_length=50) + trigger: Trigger = Field(default_factory=Trigger) + + @model_validator(mode='after') + def answer_ids_are_unique(self) -> ScreeningQuestion: + ids = [answer.id for answer in self.answers] + if len(ids) != len(set(ids)): + raise ValueError('answer IDs must be unique within a question') + return self + + +class ParameterOption(StrictModel): + id: CriteriaId + label: NonEmptyText + context: str | None = Field(default=None, max_length=10000) + + +class TextParameter(StrictModel): + id: CriteriaId + name: NonEmptyText + description: NonEmptyText + type: Literal['text'] + unit_instructions: str | None = Field(default=None, max_length=10000) + calculation: str | None = Field(default=None, max_length=10000) + trigger: Trigger = Field(default_factory=Trigger) + legacy_category: str | None = Field(default=None, max_length=1000) + + +class SelectionParameter(StrictModel): + id: CriteriaId + name: NonEmptyText + description: NonEmptyText + type: Literal['selection'] + selection_mode: Literal['single', 'multiple'] + options: list[ParameterOption] = Field(min_length=1, max_length=100) + unit_instructions: str | None = Field(default=None, max_length=10000) + calculation: str | None = Field(default=None, max_length=10000) + trigger: Trigger = Field(default_factory=Trigger) + legacy_category: str | None = Field(default=None, max_length=1000) + + @model_validator(mode='after') + def option_ids_are_unique(self) -> SelectionParameter: + ids = [option.id for option in self.options] + if len(ids) != len(set(ids)): + raise ValueError('option IDs must be unique within a parameter') + return self + + +Parameter = Annotated[ + TextParameter | + SelectionParameter, Field(discriminator='type'), +] + + +class CriteriaConfigV2(StrictModel): + schema_version: Literal[2] = 2 + citation_fields: CitationFields = Field(default_factory=CitationFields) + l1: list[ScreeningQuestion] = Field(default_factory=list, max_length=100) + l2: list[ScreeningQuestion] = Field(default_factory=list, max_length=100) + parameters: list[Parameter] = Field(default_factory=list, max_length=200) + + @model_validator(mode='after') + def validate_global_identity_and_triggers(self) -> CriteriaConfigV2: + items = [*self.l1, *self.l2, *self.parameters] + ids = [item.id for item in items] + if len(ids) != len(set(ids)): + raise ValueError('item IDs must be unique across the review') + + seen: dict[str, set[str] | None] = {} + for item in items: + for condition in item.trigger.all: + if condition.source_item_id not in seen: + raise ValueError( + f'trigger source {condition.source_item_id!r} must reference an earlier item', + ) + options = seen[condition.source_item_id] + if options is None: + raise ValueError( + 'text parameters cannot be trigger sources', + ) + if condition.option_id not in options: + raise ValueError( + f'option {condition.option_id!r} does not belong to trigger source ' + f'{condition.source_item_id!r}', + ) + + if isinstance(item, ScreeningQuestion): + seen[item.id] = {answer.id for answer in item.answers} + elif isinstance(item, SelectionParameter): + seen[item.id] = {option.id for option in item.options} + else: + seen[item.id] = None + return self diff --git a/backend/api/criteria/service.py b/backend/api/criteria/service.py new file mode 100644 index 00000000..43002bb3 --- /dev/null +++ b/backend/api/criteria/service.py @@ -0,0 +1,309 @@ +"""Parsing, migration, serialization, and compatibility projection for criteria.""" +from __future__ import annotations + +import hashlib +import json +import re +import unicodedata +from typing import Any +from typing import Literal + +import yaml +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + +from .models import CriteriaConfigV2 + +LEGACY_KEYS = {'include', 'criteria', 'l2_criteria', 'parameters'} +LEGACY_ONLY_KEYS = {'include', 'criteria', 'l2_criteria'} +MAX_YAML_BYTES = 1_000_000 + + +class Diagnostic(BaseModel): + model_config = ConfigDict(extra='forbid') + + severity: Literal['info', 'warning'] + code: str + source_path: list[str | int] = Field(default_factory=list) + target_path: list[str | int] = Field(default_factory=list) + message: str + requires_confirmation: bool = False + + +class MigrationStats(BaseModel): + l1: int = 0 + l2: int = 0 + parameters: int = 0 + + +class CriteriaLoadResult(BaseModel): + criteria: CriteriaConfigV2 + source_format: Literal['criteria_v2', 'legacy_yaml_v1'] + diagnostics: list[Diagnostic] = Field(default_factory=list) + requires_confirmation: bool = False + fingerprint: str | None = None + stats: MigrationStats | None = None + + +class _IdAllocator: + def __init__(self) -> None: + self.used: set[str] = set() + + def allocate(self, prefix: str, value: str, fallback: str) -> str: + normalized = unicodedata.normalize( + 'NFKD', value, + ).encode('ascii', 'ignore').decode() + slug = re.sub( + r'[^a-z0-9]+', '_', normalized.lower(), + ).strip('_') or fallback + base = f'{prefix}{slug}'[:63].rstrip('_') + if len(base) < 3: + base = f'{prefix}{fallback}'[:63] + candidate = base + suffix = 2 + while candidate in self.used: + marker = f'_{suffix}' + candidate = f'{base[:64 - len(marker)]}{marker}' + suffix += 1 + self.used.add(candidate) + return candidate + + +class CriteriaConfigurationService: + """Pure canonical criteria operations shared by API and persistence adapters.""" + + def parse_yaml(self, content: str, *, source_kind: str = 'yaml_import') -> CriteriaLoadResult: + if len(content.encode('utf-8')) > MAX_YAML_BYTES: + raise ValueError('criteria YAML exceeds the 1 MB limit') + try: + raw = yaml.safe_load(content) + except yaml.YAMLError as exc: + raise ValueError(f'invalid criteria YAML: {exc}') from exc + if not isinstance(raw, dict): + raise ValueError('criteria YAML root must be a mapping') + return self.normalize(raw, source_kind=source_kind) + + def normalize(self, raw: dict[str, Any], *, source_kind: str = 'backend_load') -> CriteriaLoadResult: + version = raw.get('schema_version') + if version == 2: + mixed = LEGACY_ONLY_KEYS.intersection(raw) + if mixed: + raise ValueError( + f'v2 criteria cannot contain legacy keys: {sorted(mixed)}', + ) + return CriteriaLoadResult( + criteria=CriteriaConfigV2.model_validate(raw), + source_format='criteria_v2', + ) + if version is not None: + raise ValueError( + f'unsupported criteria schema_version: {version!r}', + ) + if not LEGACY_KEYS.intersection(raw): + raise ValueError('unrecognized criteria format') + return self._migrate_legacy(raw, source_kind=source_kind) + + def export_yaml(self, criteria: CriteriaConfigV2 | dict[str, Any]) -> str: + model = criteria if isinstance( + criteria, CriteriaConfigV2, + ) else CriteriaConfigV2.model_validate(criteria) + payload = model.model_dump(mode='json', exclude_none=True) + return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=100) + + def build_compatibility_projection( + self, criteria: CriteriaConfigV2 | dict[str, Any], + ) -> dict[str, Any]: + model = criteria if isinstance( + criteria, CriteriaConfigV2, + ) else CriteriaConfigV2.model_validate(criteria) + + def stage(items): + return { + 'questions': [item.question for item in items], + 'possible_answers': [[answer.label for answer in item.answers] for item in items], + 'additional_infos': [ + '\n\n'.join( + f'For articles matching <{answer.label}> we answer "{answer.label}":\n' + f'<{answer.label}>\n{answer.context or ""}\n' + for answer in item.answers + ) + for item in items + ], + 'items': [item.model_dump(mode='json', exclude_none=True) for item in items], + } + + parameter_items = [ + item.model_dump( + mode='json', exclude_none=True, + ) for item in model.parameters + ] + categories: list[str] = [] + grouped: dict[str, list[Any]] = {} + for item in model.parameters: + category = item.legacy_category or 'Parameters' + if category not in grouped: + categories.append(category) + grouped[category] = [] + grouped[category].append(item) + return { + 'schema_version': 2, + 'l1': {'include': model.citation_fields.l1_include, **stage(model.l1)}, + 'l2': stage(model.l2), + 'parameters': { + 'categories': categories, + 'possible_parameters': [[item.name for item in grouped[category]] for category in categories], + 'descriptions': [ + [f'Parameter {item.name} are described as {item.description}.' for item in grouped[category]] + for category in categories + ], + 'items': parameter_items, + }, + } + + def _migrate_legacy(self, legacy: dict[str, Any], *, source_kind: str) -> CriteriaLoadResult: + # Reserved for source-specific telemetry; migration remains pure. + del source_kind + diagnostics: list[Diagnostic] = [] + item_ids = _IdAllocator() + + include = legacy.get('include', []) + if not isinstance(include, list) or any(not isinstance(value, str) or not value.strip() for value in include): + raise ValueError( + 'legacy include must be a list of non-empty strings', + ) + normalized_include = list( + dict.fromkeys( + value.strip() for value in include + ), + ) + if len(normalized_include) != len(include): + diagnostics.append( + Diagnostic( + severity='warning', code='duplicate_include_fields_removed', + source_path=['include'], target_path=['citation_fields', 'l1_include'], + message='Duplicate citation fields were removed while preserving order.', + ), + ) + + def questions(key: str, stage: str) -> list[dict[str, Any]]: + block = legacy.get(key, {}) + if block is None: + block = {} + if not isinstance(block, dict): + raise ValueError(f'legacy {key} must be a mapping') + output = [] + for question_index, (question, answers) in enumerate(block.items()): + if not isinstance(question, str) or not question.strip() or not isinstance(answers, dict): + raise ValueError( + f'legacy {key} questions and answers must be mappings of non-empty strings', + ) + option_ids = _IdAllocator() + converted_answers = [] + decisions: set[str] = set() + for answer_index, (label, context) in enumerate(answers.items()): + if not isinstance(label, str) or not label.strip() or not isinstance(context, str): + raise ValueError( + f'legacy {key} answer labels and contexts must be strings', + ) + exclude = bool( + re.search( + r'(?i)(?:\(exclude\)|\[exclude\]|^\s*exclude\s*$)', label, + ), + ) + decision = 'exclude' if exclude else 'include' + decisions.add(decision) + diagnostics.append( + Diagnostic( + severity='warning' if exclude else 'info', + code='decision_inferred_exclude' if exclude else 'decision_defaulted_include', + source_path=[key, question, label], + target_path=[ + stage, question_index, + 'answers', answer_index, 'decision', + ], + message=( + 'This answer was marked Exclude from a legacy text marker.' if exclude + else 'This answer defaulted to Include because no legacy exclusion marker was present.' + ), + requires_confirmation=exclude, + ), + ) + converted_answers.append({ + 'id': option_ids.allocate('', label, f'answer_{answer_index + 1}'), + 'label': label.strip(), 'context': context.strip() or None, 'decision': decision, + }) + if len(converted_answers) < 2: + raise ValueError( + f'legacy {key} question {question!r} must have at least two answers', + ) + if decisions != {'include', 'exclude'}: + diagnostics.append( + Diagnostic( + severity='warning', code='unbalanced_screening_decisions', + source_path=[key, question], target_path=[ + stage, question_index, 'answers', + ], + message='This question does not contain both Include and Exclude answers.', + requires_confirmation=True, + ), + ) + output.append({ + 'id': item_ids.allocate(f'q_{stage}_', question, f'question_{question_index + 1}'), + 'question': question.strip(), 'answers': converted_answers, 'trigger': {'all': []}, + }) + return output + + parameters = legacy.get('parameters', {}) + if parameters is None: + parameters = {} + if not isinstance(parameters, dict): + raise ValueError('legacy parameters must be a mapping') + converted_parameters = [] + parameter_index = 0 + for category, values in parameters.items(): + if not isinstance(category, str) or not category.strip() or not isinstance(values, dict): + raise ValueError( + 'legacy parameter categories must map to parameter mappings', + ) + for name, description in values.items(): + if not isinstance(name, str) or not name.strip() or not isinstance(description, str) or not description.strip(): + raise ValueError( + 'legacy parameter names and descriptions must be non-empty strings', + ) + parameter_index += 1 + converted_parameters.append({ + 'id': item_ids.allocate('p_', f'{category}_{name}', f'parameter_{parameter_index}'), + 'name': name.strip(), 'description': description.strip(), 'type': 'text', + 'unit_instructions': None, 'calculation': None, 'trigger': {'all': []}, + 'legacy_category': category.strip(), + }) + + l1 = questions('criteria', 'l1') + l2 = questions('l2_criteria', 'l2') + criteria = CriteriaConfigV2.model_validate({ + 'schema_version': 2, + 'citation_fields': {'l1_include': normalized_include, 'doi': None}, + 'l1': l1, 'l2': l2, 'parameters': converted_parameters, + }) + canonical_source = json.dumps( + legacy, sort_keys=True, separators=(',', ':'), ensure_ascii=False, + ) + fingerprint = f'sha256:{hashlib.sha256(canonical_source.encode()).hexdigest()}' + return CriteriaLoadResult( + criteria=criteria, + source_format='legacy_yaml_v1', + diagnostics=diagnostics, + requires_confirmation=any( + item.requires_confirmation for item in diagnostics + ), + fingerprint=fingerprint, + stats=MigrationStats( + l1=len(l1), l2=len( + l2, + ), parameters=len(converted_parameters), + ), + ) + + +criteria_configuration_service = CriteriaConfigurationService() diff --git a/backend/api/services/sr_db_service.py b/backend/api/services/sr_db_service.py index f5027dd2..727f874c 100644 --- a/backend/api/services/sr_db_service.py +++ b/backend/api/services/sr_db_service.py @@ -23,6 +23,7 @@ from fastapi import status from ..core.config import settings +from ..criteria.service import criteria_configuration_service from .postgres_auth import postgres_server logger = logging.getLogger(__name__) @@ -56,6 +57,7 @@ def ensure_table_exists(self) -> None: criteria JSONB, criteria_yaml TEXT, criteria_parsed JSONB, + criteria_revision INTEGER NOT NULL DEFAULT 0, screening_thresholds JSONB, critical_prompt_additions JSONB, screening_db JSONB, @@ -67,6 +69,18 @@ def ensure_table_exists(self) -> None: # Runtime schema evolution for existing deployments. # (No migrations philosophy: add columns if missing.) + try: + cur.execute( + 'ALTER TABLE systematic_reviews ADD COLUMN IF NOT EXISTS criteria_revision INTEGER NOT NULL DEFAULT 0', + ) + except Exception: + try: + cur.execute( + 'ALTER TABLE systematic_reviews ADD COLUMN criteria_revision INTEGER NOT NULL DEFAULT 0', + ) + except Exception: + pass + try: cur.execute( 'ALTER TABLE systematic_reviews ADD COLUMN IF NOT EXISTS screening_thresholds JSONB', @@ -117,6 +131,11 @@ def build_criteria_parsed(self, criteria_obj: dict[str, Any] | None) -> dict[str if not criteria_obj or not isinstance(criteria_obj, dict): return parsed + if criteria_obj.get('schema_version') == 2: + return criteria_configuration_service.build_compatibility_projection( + criteria_obj, + ) + # include list (columns) include = criteria_obj.get('include', []) if isinstance(include, list): @@ -517,7 +536,8 @@ def update_criteria(self, sr_id: str, criteria_obj: dict[str, Any], criteria_str update_sql = """ UPDATE systematic_reviews - SET criteria = %s, criteria_yaml = %s, criteria_parsed = %s, updated_at = %s + SET criteria = %s, criteria_yaml = %s, criteria_parsed = %s, + criteria_revision = criteria_revision + 1, updated_at = %s WHERE id = %s """ @@ -565,6 +585,65 @@ def update_criteria(self, sr_id: str, criteria_obj: dict[str, Any], criteria_str if conn: pass + def save_criteria_config( + self, + sr_id: str, + criteria_obj: dict[str, Any], + criteria_str: str, + expected_revision: int, + requester_id: str, + ) -> dict[str, Any]: + """Atomically save all canonical criteria representations with optimistic locking.""" + sr = self.get_systematic_review(sr_id) + if not sr or not sr.get('visible', True): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail='Systematic review not found', + ) + if not self.user_has_sr_permission(sr_id, requester_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Not authorized to modify this systematic review', + ) + + projection = self.build_criteria_parsed(criteria_obj) + conn = None + try: + conn = postgres_server.conn + cur = conn.cursor() + cur.execute( + """ + UPDATE systematic_reviews + SET criteria = %s, criteria_yaml = %s, criteria_parsed = %s, + criteria_revision = criteria_revision + 1, updated_at = %s + WHERE id = %s AND criteria_revision = %s + RETURNING criteria_revision + """, + ( + json.dumps(criteria_obj), criteria_str, json.dumps( + projection, + ), + datetime.utcnow().isoformat(), sr_id, expected_revision, + ), + ) + row = cur.fetchone() + if not row: + conn.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Criteria revision conflict. Reload the latest configuration and retry.', + ) + conn.commit() + return {'revision': row[0], 'criteria_parsed': projection} + except HTTPException: + raise + except Exception as exc: + if conn: + conn.rollback() + logger.exception( + 'Failed to save canonical criteria config: %s', exc, + ) + raise + def list_systematic_reviews_for_user(self, user_email: str) -> list[dict[str, Any]]: """ Return all SR documents where the user is a member (regardless of visible flag). diff --git a/backend/api/sr/router.py b/backend/api/sr/router.py index 62d9e1b6..f845a5fd 100644 --- a/backend/api/sr/router.py +++ b/backend/api/sr/router.py @@ -20,17 +20,62 @@ from fastapi import status from fastapi import UploadFile from fastapi.concurrency import run_in_threadpool +from fastapi.responses import PlainTextResponse from pydantic import BaseModel +from pydantic import ValidationError from ..core.cit_utils import load_sr_and_check from ..core.config import settings from ..core.security import get_current_active_user +from ..criteria.models import CriteriaConfigV2 +from ..criteria.service import criteria_configuration_service from ..services.sr_db_service import srdb_service from ..services.user_db import user_db_service router = APIRouter() +class CriteriaConfigSaveRequest(BaseModel): + expected_revision: int + force: bool = False + migration_fingerprint: str | None = None + criteria: CriteriaConfigV2 + + +class CriteriaYamlImportRequest(BaseModel): + criteria_yaml: str + + +def _criteria_error(exc: ValueError) -> HTTPException: + if isinstance(exc, ValidationError): + errors = [ + { + 'path': list(error['loc']), + 'code': error['type'], + 'message': error['msg'], + } + for error in exc.errors(include_url=False) + ] + else: + errors = [ + {'path': [], 'code': 'invalid_criteria', 'message': str(exc)}, + ] + return HTTPException(status_code=422, detail={'errors': errors}) + + +def _require_migration_confirmation(result, fingerprint: str | None) -> None: + if result.source_format == 'legacy_yaml_v1' and result.requires_confirmation: + if fingerprint != result.fingerprint: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + 'code': 'migration_confirmation_required', + 'message': 'Preview and confirm the legacy migration before saving.', + 'fingerprint': result.fingerprint, + }, + ) + + class SystematicReviewCreate(BaseModel): name: str description: str | None = None @@ -79,6 +124,7 @@ async def create_systematic_review( description: str | None = Form(None), criteria_file: UploadFile | None = File(None), criteria_yaml: str | None = Form(None), + migration_fingerprint: str | None = Form(None), current_user: dict[str, Any] = Depends(get_current_active_user), ): """ @@ -111,19 +157,20 @@ async def create_systematic_review( criteria_str = criteria_yaml if criteria_str: - criteria_obj = yaml.safe_load(criteria_str) - # ensure it's a mapping/dict - if criteria_obj is None: - criteria_obj = {} - elif not isinstance(criteria_obj, dict): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Parsed YAML criteria must be a mapping/object at the top level', - ) - except yaml.YAMLError as ye: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid YAML provided: {ye}", - ) + normalized = criteria_configuration_service.parse_yaml( + criteria_str, + ) + _require_migration_confirmation(normalized, migration_fingerprint) + criteria_obj = normalized.criteria.model_dump( + mode='json', exclude_none=True, + ) + criteria_str = criteria_configuration_service.export_yaml( + normalized.criteria, + ) + except (yaml.YAMLError, ValueError) as exc: + raise _criteria_error(exc) from exc + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), @@ -317,6 +364,135 @@ async def list_systematic_reviews_for_user( return results +async def _load_criteria_review(sr_id: str, current_user: dict[str, Any]) -> dict[str, Any]: + review, _screening = await load_sr_and_check( + sr_id, current_user, srdb_service, require_screening=False, + ) + return review + + +@router.get('/{sr_id}/criteria-config') +async def get_criteria_config( + sr_id: str, + current_user: dict[str, Any] = Depends(get_current_active_user), +): + review = await _load_criteria_review(sr_id, current_user) + try: + if isinstance(review.get('criteria'), dict): + result = criteria_configuration_service.normalize( + review['criteria'], + ) + elif review.get('criteria_yaml'): + result = criteria_configuration_service.parse_yaml( + review['criteria_yaml'], source_kind='backend_load', + ) + else: + raise ValueError( + 'No usable criteria configuration is stored for this review.', + ) + except ValueError as exc: + raise _criteria_error(exc) from exc + + response: dict[str, Any] = { + 'criteria': result.criteria.model_dump(mode='json', exclude_none=True), + 'revision': review.get('criteria_revision', 0), + 'warnings': [item.model_dump(mode='json') for item in result.diagnostics], + } + if result.source_format == 'legacy_yaml_v1': + response['migration'] = { + 'status': 'preview', + 'source_format': result.source_format, + 'fingerprint': result.fingerprint, + 'requires_confirmation': result.requires_confirmation, + 'stats': result.stats.model_dump() if result.stats else None, + 'diagnostics': [item.model_dump(mode='json') for item in result.diagnostics], + } + return response + + +@router.post('/{sr_id}/criteria-config/validate') +async def validate_criteria_config( + sr_id: str, + criteria: CriteriaConfigV2, + current_user: dict[str, Any] = Depends(get_current_active_user), +): + await _load_criteria_review(sr_id, current_user) + return { + 'valid': True, + 'criteria': criteria.model_dump(mode='json', exclude_none=True), + 'warnings': [], + } + + +@router.post('/{sr_id}/criteria-config/import-yaml') +async def import_criteria_yaml( + sr_id: str, + payload: CriteriaYamlImportRequest, + current_user: dict[str, Any] = Depends(get_current_active_user), +): + await _load_criteria_review(sr_id, current_user) + try: + result = criteria_configuration_service.parse_yaml( + payload.criteria_yaml, + ) + except ValueError as exc: + raise _criteria_error(exc) from exc + return result.model_dump(mode='json', exclude_none=True) + + +@router.put('/{sr_id}/criteria-config') +async def save_criteria_config( + sr_id: str, + payload: CriteriaConfigSaveRequest, + current_user: dict[str, Any] = Depends(get_current_active_user), +): + review = await _load_criteria_review(sr_id, current_user) + if (review.get('screening_db') or {}).get('table_name') and not payload.force: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='This SR already has screening data. Pass force=true to confirm criteria invalidation.', + ) + try: + stored = criteria_configuration_service.normalize(review['criteria']) + except (KeyError, ValueError): + stored = None + if stored: + _require_migration_confirmation(stored, payload.migration_fingerprint) + criteria = payload.criteria.model_dump(mode='json', exclude_none=True) + criteria_yaml = criteria_configuration_service.export_yaml( + payload.criteria, + ) + saved = await run_in_threadpool( + srdb_service.save_criteria_config, + sr_id, + criteria, + criteria_yaml, + payload.expected_revision, + current_user.get('email') or current_user.get('id'), + ) + return { + 'criteria': criteria, + 'revision': saved['revision'], + 'warnings': [], + 'invalidation': {'forced': payload.force, 'screening_data_present': bool(review.get('screening_db'))}, + } + + +@router.get('/{sr_id}/criteria-config/export-yaml', response_class=PlainTextResponse) +async def export_criteria_yaml( + sr_id: str, + current_user: dict[str, Any] = Depends(get_current_active_user), +): + response = await get_criteria_config(sr_id, current_user) + return PlainTextResponse( + criteria_configuration_service.export_yaml(response['criteria']), + media_type='application/yaml', + headers={ + 'Content-Disposition': f'attachment; filename="criteria-{sr_id}.yaml"', + }, + ) + + @router.get('/{sr_id}', response_model=SystematicReviewRead) async def get_systematic_review(sr_id: str, current_user: dict[str, Any] = Depends(get_current_active_user)): """ @@ -383,6 +559,7 @@ async def update_systematic_review_criteria( criteria_file: UploadFile | None = File(None), criteria_yaml: str | None = Form(None), force: str | None = Form(None), + migration_fingerprint: str | None = Form(None), current_user: dict[str, Any] = Depends(get_current_active_user), ): """ @@ -435,18 +612,18 @@ async def update_systematic_review_criteria( detail='Either criteria_file or criteria_yaml must be provided', ) - criteria_obj = yaml.safe_load(criteria_str) - if criteria_obj is None: - criteria_obj = {} - elif not isinstance(criteria_obj, dict): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail='Parsed YAML criteria must be a mapping/object at the top level', - ) - except yaml.YAMLError as ye: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid YAML provided: {ye}", + normalized = criteria_configuration_service.parse_yaml(criteria_str) + _require_migration_confirmation(normalized, migration_fingerprint) + criteria_obj = normalized.criteria.model_dump( + mode='json', exclude_none=True, + ) + criteria_str = criteria_configuration_service.export_yaml( + normalized.criteria, ) + except (yaml.YAMLError, ValueError) as exc: + raise _criteria_error(exc) from exc + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), diff --git a/backend/tests/criteria_config_router_test.py b/backend/tests/criteria_config_router_test.py new file mode 100644 index 00000000..4c932362 --- /dev/null +++ b/backend/tests/criteria_config_router_test.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock +from unittest.mock import patch + +from api.criteria.models import CriteriaConfigV2 +from api.sr.router import CriteriaConfigSaveRequest +from api.sr.router import CriteriaYamlImportRequest +from api.sr.router import get_criteria_config +from api.sr.router import import_criteria_yaml +from api.sr.router import save_criteria_config +from fastapi import HTTPException + + +EMPTY_CONFIG = { + 'schema_version': 2, + 'citation_fields': {'l1_include': []}, + 'l1': [], + 'l2': [], + 'parameters': [], +} +USER = {'id': 'owner-id', 'email': 'owner@example.test'} + + +class CriteriaConfigRouterTests(unittest.IsolatedAsyncioTestCase): + @patch('api.sr.router._load_criteria_review', new_callable=AsyncMock) + async def test_get_normalizes_legacy_without_persisting(self, load_review: AsyncMock) -> None: + load_review.return_value = { + 'criteria_revision': 4, + 'criteria': { + 'include': ['Title'], + 'criteria': {'Eligible?': {'Yes': 'Include', 'No (exclude)': 'Exclude'}}, + }, + } + + response = await get_criteria_config('review-id', USER) + + self.assertEqual(response['revision'], 4) + self.assertEqual(response['criteria']['schema_version'], 2) + self.assertEqual(response['migration']['status'], 'preview') + self.assertTrue( + response['migration'] + ['fingerprint'].startswith('sha256:'), + ) + + @patch('api.sr.router._load_criteria_review', new_callable=AsyncMock) + async def test_import_returns_422_for_invalid_yaml(self, load_review: AsyncMock) -> None: + load_review.return_value = {'criteria_revision': 0} + + with self.assertRaises(HTTPException) as caught: + await import_criteria_yaml( + 'review-id', CriteriaYamlImportRequest( + criteria_yaml='- not\n- a mapping', + ), USER, + ) + + self.assertEqual(caught.exception.status_code, 422) + self.assertEqual( + caught.exception.detail['errors'][0]['code'], 'invalid_criteria', + ) + + @patch('api.sr.router._load_criteria_review', new_callable=AsyncMock) + async def test_save_requires_force_when_screening_data_exists(self, load_review: AsyncMock) -> None: + load_review.return_value = { + 'screening_db': {'table_name': 'citations'}, + } + payload = CriteriaConfigSaveRequest( + expected_revision=2, + force=False, + criteria=CriteriaConfigV2.model_validate(EMPTY_CONFIG), + ) + + with self.assertRaises(HTTPException) as caught: + await save_criteria_config('review-id', payload, USER) + + self.assertEqual(caught.exception.status_code, 409) + + @patch('api.sr.router._load_criteria_review', new_callable=AsyncMock) + async def test_save_requires_matching_legacy_migration_fingerprint(self, load_review: AsyncMock) -> None: + load_review.return_value = { + 'criteria': { + 'criteria': {'Eligible?': {'Yes': 'Include', 'No (exclude)': 'Exclude'}}, + }, + } + payload = CriteriaConfigSaveRequest( + expected_revision=0, + criteria=CriteriaConfigV2.model_validate(EMPTY_CONFIG), + ) + + with self.assertRaises(HTTPException) as caught: + await save_criteria_config('review-id', payload, USER) + + self.assertEqual(caught.exception.status_code, 409) + self.assertEqual( + caught.exception.detail['code'], 'migration_confirmation_required', + ) + self.assertTrue( + caught.exception.detail['fingerprint'].startswith('sha256:'), + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tests/criteria_configuration_service_test.py b/backend/tests/criteria_configuration_service_test.py new file mode 100644 index 00000000..75488541 --- /dev/null +++ b/backend/tests/criteria_configuration_service_test.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + +from api.criteria.models import CriteriaConfigV2 +from api.criteria.service import CriteriaConfigurationService +from pydantic import ValidationError + + +class CriteriaConfigurationServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.service = CriteriaConfigurationService() + + def test_v2_yaml_round_trip_is_deterministic(self) -> None: + config = { + 'schema_version': 2, + 'citation_fields': {'l1_include': ['Title', 'Abstract'], 'doi': 'DOI'}, + 'l1': [{ + 'id': 'q_primary', + 'question': 'Primary research?', + 'answers': [ + {'id': 'yes', 'label': 'Yes', 'decision': 'include'}, + {'id': 'no_answer', 'label': 'No', 'decision': 'exclude'}, + ], + 'trigger': {'all': []}, + }], + 'l2': [], + 'parameters': [{ + 'id': 'p_design', + 'name': 'Design', + 'description': 'Reported study design.', + 'type': 'selection', + 'selection_mode': 'single', + 'options': [{'id': 'cohort', 'label': 'Cohort'}], + 'trigger': {'all': [{'source_item_id': 'q_primary', 'option_id': 'yes'}]}, + }], + } + + first = self.service.export_yaml(config) + loaded = self.service.parse_yaml(first) + second = self.service.export_yaml(loaded.criteria) + + self.assertEqual(first, second) + self.assertEqual( + loaded.criteria.model_dump( + mode='json', + ), CriteriaConfigV2.model_validate(config).model_dump(mode='json'), + ) + + def test_forward_and_text_parameter_trigger_sources_are_rejected(self) -> None: + base = { + 'schema_version': 2, 'citation_fields': {}, 'l1': [], 'l2': [], + 'parameters': [ + { + 'id': 'p_text', 'name': 'Text', 'description': 'Text value', 'type': 'text', + }, + { + 'id': 'p_later', 'name': 'Later', 'description': 'Later value', 'type': 'text', + 'trigger': {'all': [{'source_item_id': 'p_text', 'option_id': 'anything'}]}, + }, + ], + } + + with self.assertRaisesRegex(ValidationError, 'text parameters cannot be trigger sources'): + CriteriaConfigV2.model_validate(base) + + base['parameters'][0]['trigger'] = { + 'all': [{'source_item_id': 'p_later', 'option_id': 'anything'}], + } + with self.assertRaisesRegex(ValidationError, 'must reference an earlier item'): + CriteriaConfigV2.model_validate(base) + + def test_representative_legacy_yaml_migrates_deterministically(self) -> None: + path = Path(__file__).parents[1] / \ + 'api/sr/criteria_config_measles_updated.yaml' + content = path.read_text(encoding='utf-8') + + first = self.service.parse_yaml(content) + second = self.service.parse_yaml(content) + + self.assertEqual(first.criteria, second.criteria) + self.assertEqual(first.fingerprint, second.fingerprint) + self.assertEqual(first.stats.l1, 3) + self.assertEqual(first.stats.l2, 1) + self.assertGreater(first.stats.parameters, 1) + self.assertTrue(first.requires_confirmation) + self.assertIn( + 'decision_inferred_exclude', { + item.code for item in first.diagnostics + }, + ) + + def test_legacy_collision_ids_are_stable_and_unique(self) -> None: + legacy = ''' +criteria: + Same question: + "Yes": Include + "No (exclude)": Exclude +l2_criteria: + Same question: + "Yes": Include + "No (exclude)": Exclude +parameters: + Category: + A/B: First + A B: Second +''' + result = self.service.parse_yaml(legacy) + item_ids = [ + *[item.id for item in result.criteria.l1], + *[item.id for item in result.criteria.l2], + *[item.id for item in result.criteria.parameters], + ] + + self.assertEqual(len(item_ids), len(set(item_ids))) + self.assertEqual(item_ids[-1], f'{item_ids[-2]}_2') + + def test_mixed_v2_and_legacy_keys_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, 'cannot contain legacy keys'): + self.service.normalize({ + 'schema_version': 2, 'citation_fields': {}, 'l1': [], 'l2': [], + 'parameters': [], 'include': ['Title'], + }) + + def test_projection_retains_legacy_arrays_and_adds_stable_items(self) -> None: + migrated = self.service.normalize({ + 'include': ['Title'], + 'criteria': {'Eligible?': {'Yes': 'Include it', 'No (exclude)': 'Exclude it'}}, + 'parameters': {'Group': {'Rate': 'A reported rate'}}, + }) + + projection = self.service.build_compatibility_projection( + migrated.criteria, + ) + + self.assertEqual(projection['schema_version'], 2) + self.assertEqual(projection['l1']['questions'], ['Eligible?']) + self.assertEqual(projection['l1']['include'], ['Title']) + self.assertEqual( + projection['l1']['items'] + [0]['id'], migrated.criteria.l1[0].id, + ) + self.assertEqual( + projection['parameters']['items'] + [0]['id'], migrated.criteria.parameters[0].id, + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/frontend/app/[lang]/can-sr/setup/page.tsx b/frontend/app/[lang]/can-sr/setup/page.tsx index 43839a58..8307fb8d 100644 --- a/frontend/app/[lang]/can-sr/setup/page.tsx +++ b/frontend/app/[lang]/can-sr/setup/page.tsx @@ -6,6 +6,7 @@ import GCHeader, { SRHeader } from '@/components/can-sr/headers' import { authenticatedFetch, getAuthToken, getTokenType } from '@/lib/auth' import { SAMPLE_YAML } from '@/components/can-sr/setup/sample-yaml' import ManageUsersPopup from '@/components/can-sr/setup/manage-users-popup' +import CriteriaEditor from '@/components/can-sr/setup/criteria-editor' import { AlertDialog, AlertDialogCancel, @@ -411,6 +412,12 @@ export default function CanSrSetupPage() { + } + /> + {/* YAML editor */}
diff --git a/frontend/app/api/can-sr/reviews/criteria-config/route.ts b/frontend/app/api/can-sr/reviews/criteria-config/route.ts new file mode 100644 index 00000000..fad7f854 --- /dev/null +++ b/frontend/app/api/can-sr/reviews/criteria-config/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { BACKEND_URL } from '@/lib/config' + +function target(request: NextRequest, suffix = '') { + const srId = request.nextUrl.searchParams.get('sr_id') + if (!srId) return null + return `${BACKEND_URL}/api/sr/${encodeURIComponent(srId)}/criteria-config${suffix}` +} + +async function auth(request: NextRequest) { + return request.headers.get('authorization') +} + +export async function GET(request: NextRequest) { + const downloadYaml = request.nextUrl.searchParams.get('download') === 'yaml' + const url = target(request, downloadYaml ? '/yaml' : '') + const authorization = await auth(request) + if (!url) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 }) + if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 }) + const response = await fetch(url, { headers: { Authorization: authorization } }) + if (downloadYaml) { + return new NextResponse(await response.text(), { + status: response.status, + headers: { 'Content-Type': response.headers.get('content-type') || 'text/yaml; charset=utf-8' }, + }) + } + return NextResponse.json(await response.json().catch(() => ({})), { status: response.status }) +} + +export async function PUT(request: NextRequest) { + const url = target(request) + const authorization = await auth(request) + if (!url) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 }) + if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 }) + const response = await fetch(url, { + method: 'PUT', + headers: { Authorization: authorization, 'Content-Type': 'application/json' }, + body: JSON.stringify(await request.json()), + }) + return NextResponse.json(await response.json().catch(() => ({})), { status: response.status }) +} + +export async function POST(request: NextRequest) { + const operation = request.nextUrl.searchParams.get('operation') + const suffix = operation === 'import-yaml' ? '/import-yaml' : '/validate' + const url = target(request, suffix) + const authorization = await auth(request) + if (!url) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 }) + if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 }) + const response = await fetch(url, { + method: 'POST', + headers: { Authorization: authorization, 'Content-Type': 'application/json' }, + body: JSON.stringify(await request.json()), + }) + return NextResponse.json(await response.json().catch(() => ({})), { status: response.status }) +} diff --git a/frontend/components/can-sr/setup/criteria-builder.test.tsx b/frontend/components/can-sr/setup/criteria-builder.test.tsx new file mode 100644 index 00000000..f786ef34 --- /dev/null +++ b/frontend/components/can-sr/setup/criteria-builder.test.tsx @@ -0,0 +1,69 @@ +import { useReducer } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import CriteriaBuilder from './criteria-builder' +import { criteriaDraftReducer, emptyCriteria } from './criteria-types' + +const labels = new Proxy({}, { get: (_target, property) => String(property) }) as Record + +function Harness() { + const [state, dispatch] = useReducer(criteriaDraftReducer, { criteria: emptyCriteria(), revision: 0, dirty: false }) + return <>{state.dirty ? 'dirty' : 'clean'} +} + +describe('CriteriaBuilder', () => { + it('adds an accessible question with explicit decisions', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getAllByRole('button', { name: /addQuestion/ })[0]) + expect(screen.getByLabelText('questionText')).toHaveValue('New screening question') + expect(screen.getAllByLabelText('decision')).toHaveLength(2) + expect(screen.getAllByLabelText('decision')[1]).toHaveValue('exclude') + expect(screen.getByText('dirty')).toBeInTheDocument() + }) + + it('supports keyboard-operable move controls', async () => { + const user = userEvent.setup() + render() + const add = screen.getAllByRole('button', { name: /addQuestion/ })[0] + await user.click(add); await user.click(add) + const questions = screen.getAllByLabelText('questionText') + await user.clear(questions[0]); await user.type(questions[0], 'First') + await user.clear(questions[1]); await user.type(questions[1], 'Second') + await user.click(screen.getByRole('button', { name: 'moveUp 2' })) + expect(screen.getAllByLabelText('questionText').map((input) => (input as HTMLInputElement).value)).toEqual(['Second', 'First']) + }) + + it('offers only earlier questions and retains forward references as actionable errors', async () => { + const user = userEvent.setup() + render() + const addQuestion = screen.getAllByRole('button', { name: /addQuestion/ })[0] + await user.click(addQuestion); await user.click(addQuestion) + const questions = screen.getAllByLabelText('questionText') + await user.clear(questions[0]); await user.type(questions[0], 'Earlier question') + await user.clear(questions[1]); await user.type(questions[1], 'Dependent question') + + const addConditions = screen.getAllByRole('button', { name: 'addTrigger' }) + expect(addConditions[0]).toBeDisabled() + expect(addConditions[1]).toBeEnabled() + await user.click(addConditions[1]) + expect(screen.getByLabelText('triggerSource')).toHaveDisplayValue('Earlier question') + expect(screen.getByLabelText('triggerAnswer')).toHaveDisplayValue('Yes') + + await user.click(screen.getByRole('button', { name: 'moveUp 2' })) + expect(screen.getByRole('alert')).toHaveTextContent('triggerOrderError') + expect(screen.getByRole('button', { name: 'removeTrigger 1' })).toBeEnabled() + }) + + it('surfaces a missing source after deletion without silently removing the trigger', async () => { + const user = userEvent.setup() + render() + const addQuestion = screen.getAllByRole('button', { name: /addQuestion/ })[0] + await user.click(addQuestion); await user.click(addQuestion) + await user.click(screen.getAllByRole('button', { name: 'addTrigger' })[1]) + await user.click(screen.getByRole('button', { name: 'deleteQuestion 1' })) + expect(screen.getByRole('alert')).toHaveTextContent('triggerOrderError') + expect(screen.getByRole('button', { name: 'removeTrigger 1' })).toBeInTheDocument() + }) +}) diff --git a/frontend/components/can-sr/setup/criteria-builder.tsx b/frontend/components/can-sr/setup/criteria-builder.tsx new file mode 100644 index 00000000..907a71e0 --- /dev/null +++ b/frontend/components/can-sr/setup/criteria-builder.tsx @@ -0,0 +1,137 @@ +'use client' + +import type { Dispatch } from 'react' +import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react' +import type { CriteriaDraftAction, CriteriaDraftState, ScreeningQuestion } from './criteria-types' + +type SourceOption = { stage: 'l1' | 'l2'; question: ScreeningQuestion } + +type Props = { + state: CriteriaDraftState + dispatch: Dispatch + labels: Record +} + +function QuestionCard({ + question, + index, + count, + stage, + dispatch, + labels, + sources, +}: { + question: ScreeningQuestion + index: number + count: number + stage: 'l1' | 'l2' + dispatch: Dispatch + labels: Record + sources: SourceOption[] +}) { + const prefix = `${stage}-${question.id}` + return ( +
+
+ {labels.question} {index + 1} +
+ + + +
+
+ + dispatch({ type: 'update-question', stage, questionId: question.id, field: 'question', value: event.target.value })} className="mt-1 w-full rounded-md border px-3 py-2" /> + +