From e0c3c77b5ffb8e559d162cd00887593a187736f1 Mon Sep 17 00:00:00 2001 From: bing1100 Date: Wed, 29 Jul 2026 02:52:18 +0000 Subject: [PATCH 1/2] fix(criteria): making improvements and fixing bugs with configuration --- backend/api/criteria/context.py | 104 ++++++++++++ backend/api/criteria/models.py | 9 ++ backend/api/criteria/service.py | 69 ++++---- .../api/jobs/pipelines/screening_executor.py | 88 +++++++--- backend/api/screen/prompts.py | 10 ++ backend/api/screen/router.py | 31 +++- .../api/services/citation_field_service.py | 4 +- backend/api/sr/router.py | 27 +++- backend/tests/criteria_context_test.py | 61 +++++++ .../app/[lang]/can-sr/l1-screen/view/page.tsx | 40 ++--- .../app/[lang]/can-sr/l2-screen/view/page.tsx | 33 +++- .../can-sr/screening-citation-context.tsx | 84 ++++++++++ .../can-sr/setup/citation-field-selector.tsx | 109 ++++++------- .../can-sr/setup/criteria-builder.tsx | 15 +- .../components/can-sr/setup/criteria-types.ts | 17 +- .../can-sr/setup/criteria-validation.ts | 3 + .../can-sr/setup/parameter-builder.tsx | 15 +- frontend/dictionaries/en.json | 153 ++++++++++++++++-- frontend/dictionaries/fr.json | 153 ++++++++++++++++-- 19 files changed, 848 insertions(+), 177 deletions(-) create mode 100644 backend/api/criteria/context.py create mode 100644 backend/tests/criteria_context_test.py create mode 100644 frontend/components/can-sr/screening-citation-context.tsx diff --git a/backend/api/criteria/context.py b/backend/api/criteria/context.py new file mode 100644 index 00000000..0ae7235d --- /dev/null +++ b/backend/api/criteria/context.py @@ -0,0 +1,104 @@ +"""Canonical citation and imported-answer context helpers. + +This module deliberately has no database or web dependencies so prompt builders, +CSV/RIS importers, and tests all use the same matching and formatting rules. +""" +from __future__ import annotations + +import re +from typing import Any + + +def resolve_source_value(row: dict[str, Any], header: str | None) -> Any: + """Resolve a configured source header without silently guessing.""" + if not header: + return None + if header in row: + return row[header] + normalized = _normalize_key(header) + matches = [ + value for key, value in row.items( + ) if _normalize_key(str(key)) == normalized + ] + return matches[0] if len(matches) == 1 else None + + +def _normalize_key(value: str) -> str: + return re.sub(r'[^a-z0-9]+', '_', value.strip().casefold()).strip('_') + + +def normalize_answer_text(value: Any) -> str: + """Normalize formatting only; semantic synonyms are intentionally excluded.""" + return re.sub(r'\s+', ' ', str(value or '').strip()).casefold() + + +def match_answer_label(raw_value: Any, options: list[dict[str, Any]]) -> dict[str, Any]: + """Match a raw value to an option label and retain the raw audit value.""" + raw = '' if raw_value is None else str(raw_value) + if not raw.strip(): + return {'status': 'blank', 'raw': raw, 'label': None, 'option_id': None} + normalized = normalize_answer_text(raw) + for option in options: + label = str(option.get('label') or '') + if normalized == normalize_answer_text(label): + return {'status': 'matched', 'raw': raw, 'label': label, 'option_id': option.get('id')} + return {'status': 'unmatched', 'raw': raw, 'label': None, 'option_id': None} + + +def resolve_existing_human_value( + row: dict[str, Any], item: dict[str, Any], +) -> dict[str, Any]: + """Resolve and classify an explicitly mapped human answer/parameter value.""" + header = item.get('answer_column') + raw = resolve_source_value(row, header) + options = [*(item.get('answers') or []), *(item.get('options') or [])] + if options: + return match_answer_label(raw, options) + if raw is None or not str(raw).strip(): + return {'status': 'blank', 'raw': '' if raw is None else str(raw), 'value': None} + return {'status': 'matched', 'raw': str(raw), 'value': raw} + + +def format_title_abstract_context( + row: dict[str, Any], citation_fields: dict[str, Any], +) -> str: + title = resolve_source_value(row, citation_fields.get('title')) + abstract = resolve_source_value(row, citation_fields.get('abstract')) + excluded = {citation_fields.get('title'), citation_fields.get('abstract')} + pairs = [] + for header in citation_fields.get('l1_include') or []: + if header in excluded: + continue + pairs.append(f"{header}: {resolve_source_value(row, header) or ''}") + other = '; '.join(pairs) or '(none configured)' + return f"Title: {title or ''}\nAbstract: {abstract or ''}\nOther fields: {other}" + + +def format_item_context(item: dict[str, Any]) -> str: + parts = [str(item.get('question') or item.get('name') or '').strip()] + if item.get('description'): + parts.append(f"Description: {item['description']}") + if item.get('context'): + parts.append(f"Context: {item['context']}") + if item.get('critical_prompt_additions'): + parts.append( + f"Critical prompt additions: {item['critical_prompt_additions']}", + ) + if item.get('unit_instructions'): + parts.append(f"Units: {item['unit_instructions']}") + if item.get('calculation'): + parts.append(f"Calculation: {item['calculation']}") + if item.get('selection_mode'): + parts.append(f"Answer cardinality: {item['selection_mode']}") + options = [*(item.get('answers') or []), *(item.get('options') or [])] + if options: + parts.append( + 'Answer-specific guidance:\n' + '\n'.join( + f"- {option.get('label')}: {option.get('context') or '(none)'}" for option in options + ), + ) + if item.get('trigger', {}).get('all'): + parts.append( + f"Visible only when trigger conditions are met: {item['trigger']['all']}", + ) + return '\n'.join(part for part in parts if part) diff --git a/backend/api/criteria/models.py b/backend/api/criteria/models.py index 390c4eaa..905d25b0 100644 --- a/backend/api/criteria/models.py +++ b/backend/api/criteria/models.py @@ -52,6 +52,9 @@ def conditions_are_unique(self) -> Trigger: class CitationFields(StrictModel): + # Nullable while loading legacy criteria; save/use preflight requires both. + title: NonEmptyText | None = None + abstract: NonEmptyText | None = None l1_include: list[NonEmptyText] = Field( default_factory=list, max_length=100, ) @@ -77,7 +80,9 @@ class ScreeningQuestion(StrictModel): id: CriteriaId question: NonEmptyText answers: list[ScreeningAnswer] = Field(min_length=2, max_length=50) + context: str | None = Field(default=None, max_length=10000) trigger: Trigger = Field(default_factory=Trigger) + answer_column: NonEmptyText | None = None @model_validator(mode='after') def answer_ids_are_unique(self) -> ScreeningQuestion: @@ -97,17 +102,20 @@ class TextParameter(StrictModel): id: CriteriaId name: NonEmptyText description: NonEmptyText + context: str | None = Field(default=None, max_length=10000) 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) + answer_column: NonEmptyText | None = None class SelectionParameter(StrictModel): id: CriteriaId name: NonEmptyText description: NonEmptyText + context: str | None = Field(default=None, max_length=10000) type: Literal['selection'] selection_mode: Literal['single', 'multiple'] options: list[ParameterOption] = Field(min_length=1, max_length=100) @@ -115,6 +123,7 @@ class SelectionParameter(StrictModel): 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) + answer_column: NonEmptyText | None = None @model_validator(mode='after') def option_ids_are_unique(self) -> SelectionParameter: diff --git a/backend/api/criteria/service.py b/backend/api/criteria/service.py index 4c565021..16103479 100644 --- a/backend/api/criteria/service.py +++ b/backend/api/criteria/service.py @@ -110,39 +110,14 @@ def normalize(self, raw: dict[str, Any], *, source_kind: str = 'backend_load') - @staticmethod def _move_question_context_to_answers(raw: dict[str, Any]) -> list[Diagnostic]: - """Preserve obsolete v2 question guidance by applying it to every answer.""" - diagnostics: list[Diagnostic] = [] - for stage in ('l1', 'l2'): - items = raw.get(stage) - if not isinstance(items, list): - continue - for question_index, item in enumerate(items): - if not isinstance(item, dict) or 'context' not in item: - continue - context = item.pop('context') - if not isinstance(context, str) or not context.strip(): - continue - answers = item.get('answers') - if not isinstance(answers, list): - continue - for answer in answers: - if not isinstance(answer, dict): - continue - answer_context = answer.get('context') - parts = [context.strip()] - if isinstance(answer_context, str) and answer_context.strip(): - parts.append(answer_context.strip()) - answer['context'] = '\n\n'.join(parts) - diagnostics.append( - Diagnostic( - severity='warning', - code='question_context_moved_to_answers', - source_path=[stage, question_index, 'context'], - target_path=[stage, question_index, 'answers'], - message='Question-level context was copied to each answer because screening guidance is answer-specific.', - ), - ) - return diagnostics + """Keep item context separate from answer-specific guidance. + + Older code copied question context into every answer. That loses the + distinction required by the current prompt contract and duplicates + guidance. Reading is intentionally non-mutating and therefore safe for + both legacy and current v2 configurations. + """ + return [] def export_yaml(self, criteria: CriteriaConfigV2 | dict[str, Any]) -> str: model = criteria if isinstance( @@ -151,6 +126,30 @@ def export_yaml(self, criteria: CriteriaConfigV2 | dict[str, Any]) -> str: payload = model.model_dump(mode='json', exclude_none=True) return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=100) + @staticmethod + def required_citation_field_errors( + criteria: CriteriaConfigV2, + available_fields: set[str] | None = None, + ) -> list[dict[str, Any]]: + """Return save/use errors while allowing legacy criteria to be loaded.""" + errors: list[dict[str, Any]] = [] + fields = criteria.citation_fields + for key, label in (('title', 'Title'), ('abstract', 'Abstract')): + value = getattr(fields, key) + if not value: + errors.append({ + 'code': f'missing_required_{key}_field', 'path': [ + 'citation_fields', key, + ], 'message': f'{label} source column is required.', + }) + elif available_fields is not None and value not in available_fields: + errors.append({ + 'code': 'unavailable_required_citation_field', 'path': [ + 'citation_fields', key, + ], 'message': f'{label} source column {value!r} is not available.', + }) + return errors + def build_compatibility_projection( self, criteria: CriteriaConfigV2 | dict[str, Any], ) -> dict[str, Any]: @@ -188,6 +187,10 @@ def stage(items): grouped[category].append(item) return { 'schema_version': 2, + # Keep the canonical mappings/items alongside the legacy-shaped + # projection so batch pipelines can use the same source columns + # and guidance as the interactive endpoints. + 'citation_fields': model.citation_fields.model_dump(mode='json', exclude_none=True), 'l1': {'include': model.citation_fields.l1_include, **stage(model.l1)}, 'l2': stage(model.l2), 'parameters': { diff --git a/backend/api/jobs/pipelines/screening_executor.py b/backend/api/jobs/pipelines/screening_executor.py index 42bab348..5a71651d 100644 --- a/backend/api/jobs/pipelines/screening_executor.py +++ b/backend/api/jobs/pipelines/screening_executor.py @@ -5,6 +5,9 @@ from fastapi.concurrency import run_in_threadpool from ...citations import router as citations_router +from ...criteria.context import format_item_context +from ...criteria.context import format_title_abstract_context +from ...criteria.context import resolve_existing_human_value from ...extract.prompts import PARAMETER_PROMPT_JSON from ...extract.router import extract_fulltext_from_storage from ...screen.agentic_utils import build_critical_options @@ -94,15 +97,19 @@ async def _run_l1_for_citation( if not row: return (0, 0, 1) - include_cols = cits_dp_service.load_include_columns_from_criteria(sr) - if not include_cols: - include_cols = ['title', 'abstract'] - - citation_text = citations_router._build_combined_citation_from_row( - row, include_cols, - ) - cp = sr.get('criteria_parsed') or sr.get('criteria') or {} + citation_fields = cp.get( + 'citation_fields', + ) if isinstance(cp, dict) else None + if isinstance(citation_fields, dict) and (citation_fields.get('title') or citation_fields.get('abstract')): + citation_text = format_title_abstract_context(row, citation_fields) + else: + include_cols = cits_dp_service.load_include_columns_from_criteria(sr) or [ + 'title', 'abstract', + ] + citation_text = citations_router._build_combined_citation_from_row( + row, include_cols, + ) l1 = cp.get('l1') if isinstance(cp, dict) else None questions = (l1 or {}).get('questions') if isinstance(l1, dict) else [] possible = (l1 or {}).get( @@ -114,6 +121,8 @@ async def _run_l1_for_citation( questions = questions if isinstance(questions, list) else [] possible = possible if isinstance(possible, list) else [] addinfos = addinfos if isinstance(addinfos, list) else [] + l1_items = (l1 or {}).get('items') if isinstance(l1, dict) else [] + l1_items = l1_items if isinstance(l1_items, list) else [] if not questions: return (0, 1, 0) @@ -130,6 +139,13 @@ async def _run_l1_for_citation( xtra = addinfos[i] if i < len( addinfos, ) and isinstance(addinfos[i], str) else '' + item = l1_items[i] if i < len(l1_items) and isinstance(l1_items[i], dict) else { + 'question': q, 'answers': [{'label': opt} for opt in opts], + } + xtra = format_item_context(item) or xtra + human = resolve_existing_human_value(row, item) + if human.get('status') == 'matched': + continue col = snake_case_column(q) existing = row.get(col) if _should_skip_ai_output(existing, force=force): @@ -358,6 +374,10 @@ async def _run_l2_for_citation( l2_possible = l2_possible if isinstance(l2_possible, list) else [] l1_addinfos = l1_addinfos if isinstance(l1_addinfos, list) else [] l2_addinfos = l2_addinfos if isinstance(l2_addinfos, list) else [] + l1_items = (l1 or {}).get('items') if isinstance(l1, dict) else [] + l2_items = (l2 or {}).get('items') if isinstance(l2, dict) else [] + l1_items = l1_items if isinstance(l1_items, list) else [] + l2_items = l2_items if isinstance(l2_items, list) else [] merged: list[tuple[str, str, int]] = [] # (question, source_step, idx) seen_q: set[str] = set() @@ -375,12 +395,18 @@ async def _run_l2_for_citation( if not merged: return (0, 1, 0) - include_cols = cits_dp_service.load_include_columns_from_criteria(sr) or [ - 'title', 'abstract', - ] - citation_text = citations_router._build_combined_citation_from_row( - row, include_cols, - ) + citation_fields = cp.get( + 'citation_fields', + ) if isinstance(cp, dict) else None + if isinstance(citation_fields, dict) and (citation_fields.get('title') or citation_fields.get('abstract')): + citation_text = format_title_abstract_context(row, citation_fields) + else: + include_cols = cits_dp_service.load_include_columns_from_criteria(sr) or [ + 'title', 'abstract', + ] + citation_text = citations_router._build_combined_citation_from_row( + row, include_cols, + ) fulltext = row.get('fulltext') or citation_text # Tables/Figures context from row @@ -452,6 +478,9 @@ async def _run_l2_for_citation( xtra = l1_addinfos[idx] if idx < len( l1_addinfos, ) and isinstance(l1_addinfos[idx], str) else '' + item = l1_items[idx] if idx < len(l1_items) and isinstance(l1_items[idx], dict) else { + 'question': q, 'answers': [{'label': opt} for opt in opts], + } else: opts = l2_possible[idx] if idx < len( l2_possible, @@ -459,6 +488,13 @@ async def _run_l2_for_citation( xtra = l2_addinfos[idx] if idx < len( l2_addinfos, ) and isinstance(l2_addinfos[idx], str) else '' + item = l2_items[idx] if idx < len(l2_items) and isinstance(l2_items[idx], dict) else { + 'question': q, 'answers': [{'label': opt} for opt in opts], + } + xtra = format_item_context(item) or xtra + human = resolve_existing_human_value(row, item) + if human.get('status') == 'matched': + continue col = snake_case_column(q) existing = row.get(col) if _should_skip_ai_output(existing, force=force): @@ -652,8 +688,14 @@ async def _run_extract_for_citation( categories = categories if isinstance(categories, list) else [] possible = possible if isinstance(possible, list) else [] descs = descs if isinstance(descs, list) else [] + parameter_items = (params or {}).get( + 'items', + ) if isinstance(params, dict) else [] + parameter_items = parameter_items if isinstance( + parameter_items, list, + ) else [] - params_flat: list[tuple[str, str]] = [] + params_flat: list[tuple[str, str, dict[str, Any]]] = [] for i, _cat in enumerate(categories): arr = possible[i] if i < len(possible) and isinstance( possible[i], list, @@ -669,7 +711,14 @@ async def _run_extract_for_citation( if j < len(darr): d = str(darr[j]) d = d.replace('', '').replace('', '') - params_flat.append((name, d or name)) + item = next( + ( + candidate for candidate in parameter_items if isinstance( + candidate, dict, + ) and candidate.get('name') == name + ), {'name': name, 'description': d or name}, + ) + params_flat.append((name, d or name, item)) if not params_flat: return (0, 1, 0) @@ -761,7 +810,7 @@ def _extract_json_object(text: str) -> str | None: return t[start: i + 1] return None - for name, desc in params_flat: + for name, desc, item in params_flat: # More responsive pause/cancel: check between parameters if await run_in_threadpool(run_all_repo.is_canceled, job_id): raise PipelineCanceled() @@ -770,10 +819,13 @@ def _extract_json_object(text: str) -> str | None: existing = row.get(col) if _should_skip_ai_output(existing, force=force): continue + human = resolve_existing_human_value(row, item) + if human.get('status') == 'matched': + continue prompt = PARAMETER_PROMPT_JSON.format( parameter_name=name, - parameter_description=desc, + parameter_description=format_item_context(item) or desc, fulltext=fulltext, tables=tables_text, figures=figures_text, diff --git a/backend/api/screen/prompts.py b/backend/api/screen/prompts.py index 1ed53cb0..9214d7a8 100644 --- a/backend/api/screen/prompts.py +++ b/backend/api/screen/prompts.py @@ -98,6 +98,11 @@ Citation: {cit} +For title/abstract screening, interpret the citation using this structured metadata format: +Title: ... +Abstract: ... +Other fields: ... + Choose EXACTLY ONE of these options (exact text): {options} @@ -129,6 +134,11 @@ Citation: {cit} +Structured citation metadata: +Title: ... +Abstract: ... +Other fields: ... + The first model answered: "{screening_answer}" diff --git a/backend/api/screen/router.py b/backend/api/screen/router.py index 7c6b4dce..777437c8 100644 --- a/backend/api/screen/router.py +++ b/backend/api/screen/router.py @@ -24,6 +24,8 @@ from ..core.cit_utils import load_sr_and_check from ..core.config import settings from ..core.security import get_current_active_user +from ..criteria.context import format_item_context +from ..criteria.context import format_title_abstract_context from ..criteria.runtime import item_is_visible from ..services.azure_openai_client import azure_openai_client from ..services.cit_db_service import cits_dp_service @@ -840,8 +842,21 @@ async def classify_citation( ) # Build or use provided citation text (fall back to combined title/abstract when not provided) - citation_text = payload.citation_text or citations_router._build_combined_citation_from_row( - row, payload.include_columns, + citation_fields = canonical.get('citation_fields') or {} + if payload.citation_text: + citation_text = payload.citation_text + elif citation_fields.get('title') or citation_fields.get('abstract'): + citation_text = format_title_abstract_context(row, citation_fields) + else: + citation_text = citations_router._build_combined_citation_from_row( + row, payload.include_columns, + ) + + item_context = format_item_context( + source_item or { + 'question': payload.question, + 'answers': [{'label': option} for option in payload.options], + }, ) # Ensure LLM client is available @@ -919,7 +934,11 @@ async def classify_citation( prompt = PROMPT_JSON_TEMPLATE_FULLTEXT.format( question=payload.question, options=options_listed, - xtra=payload.xtra or '', + xtra='\n\n'.join( + part for part in ( + item_context, payload.xtra or '', + ) if part + ), fulltext=fulltext or citation_text, tables='\n'.join(tables_md_lines) if tables_md_lines else '(none)', figures='\n'.join(figures_lines) if figures_lines else '(none)', @@ -948,7 +967,11 @@ async def classify_citation( question=payload.question, cit=citation_text, options=options_listed, - xtra=payload.xtra or '', + xtra='\n\n'.join( + part for part in ( + item_context, payload.xtra or '', + ) if part + ), ) llm_response = await azure_openai_client.simple_chat( user_message=prompt, diff --git a/backend/api/services/citation_field_service.py b/backend/api/services/citation_field_service.py index 24678512..90cfef33 100644 --- a/backend/api/services/citation_field_service.py +++ b/backend/api/services/citation_field_service.py @@ -46,9 +46,11 @@ def build_citation_field_contract(review: dict[str, Any], columns: list[dict[str ) or criteria.get('include') or [] ] doi = configured.get('doi') + title = configured.get('title') + abstract = configured.get('abstract') available = {field['name'] for field in fields} unavailable = [ - value for value in [*selected, doi] + value for value in [*selected, title, abstract, doi] if value and value not in available ] return { diff --git a/backend/api/sr/router.py b/backend/api/sr/router.py index cd18f9de..ae42fc94 100644 --- a/backend/api/sr/router.py +++ b/backend/api/sr/router.py @@ -426,10 +426,19 @@ async def validate_criteria_config( criteria: CriteriaConfigV2, current_user: dict[str, Any] = Depends(get_current_active_user), ): - await _load_criteria_review(sr_id, current_user) + review = await _load_criteria_review(sr_id, current_user) + available = await run_in_threadpool(discover_citation_fields, review) + available_names = { + str(field.get('name')) + for field in available.get('fields', []) + } + errors = criteria_configuration_service.required_citation_field_errors( + criteria, available_names, + ) return { - 'valid': True, + 'valid': not errors, 'criteria': criteria.model_dump(mode='json', exclude_none=True), + 'errors': errors, 'warnings': [], } @@ -463,6 +472,20 @@ async def save_criteria_config( detail='This SR already has screening data. Pass force=true to confirm criteria invalidation.', ) try: + available = await run_in_threadpool(discover_citation_fields, review) + available_names = { + str(field.get('name')) + for field in available.get('fields', []) + } + required_errors = criteria_configuration_service.required_citation_field_errors( + payload.criteria, available_names, + ) + if required_errors: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={ + 'errors': required_errors, + }, + ) stored = criteria_configuration_service.normalize(review['criteria']) except (KeyError, ValueError): stored = None diff --git a/backend/tests/criteria_context_test.py b/backend/tests/criteria_context_test.py new file mode 100644 index 00000000..372adbb9 --- /dev/null +++ b/backend/tests/criteria_context_test.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import unittest + +from api.criteria.context import format_item_context +from api.criteria.context import format_title_abstract_context +from api.criteria.context import match_answer_label +from api.criteria.context import resolve_existing_human_value + + +class CriteriaContextTests(unittest.TestCase): + def test_title_abstract_format_uses_configured_headers(self) -> None: + row = { + 'Paper title': 'Configured title', + 'Paper abstract': 'Configured abstract', 'Year': 2025, + } + result = format_title_abstract_context( + row, { + 'title': 'Paper title', 'abstract': 'Paper abstract', 'l1_include': ['Paper title', 'Year'], + }, + ) + self.assertEqual( + result, 'Title: Configured title\nAbstract: Configured abstract\nOther fields: Year: 2025', + ) + + def test_answer_matching_is_formatting_only_and_preserves_raw_value(self) -> None: + matched = match_answer_label( + ' Yes ', [{'id': 'yes', 'label': 'Yes'}], + ) + self.assertEqual(matched['status'], 'matched') + self.assertEqual(matched['raw'], ' Yes ') + self.assertEqual( + match_answer_label( + 'Probably', [{'id': 'yes', 'label': 'Yes'}], + )['status'], 'unmatched', + ) + self.assertEqual( + match_answer_label( + ' ', [{'id': 'yes', 'label': 'Yes'}], + )['status'], 'blank', + ) + + def test_parameter_mapping_is_authoritative_when_valid(self) -> None: + result = resolve_existing_human_value( + {'reviewer value': '42'}, + {'answer_column': 'reviewer value', 'name': 'Rate', 'type': 'text'}, + ) + self.assertEqual(result['status'], 'matched') + self.assertEqual(result['value'], '42') + + def test_item_context_keeps_option_context_separate(self) -> None: + result = format_item_context({ + 'question': 'Eligible?', 'context': 'Item guidance', + 'answers': [{'label': 'Yes', 'context': 'Yes guidance'}], + }) + self.assertIn('Context: Item guidance', result) + self.assertIn('- Yes: Yes guidance', result) + + +if __name__ == '__main__': + unittest.main() diff --git a/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx b/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx index 3d4a7fd0..0f5f0bb0 100644 --- a/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx +++ b/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx @@ -8,6 +8,7 @@ import { getAuthToken, getTokenType } from '@/lib/auth' import { Wand2 } from 'lucide-react' import { useDictionary } from '@/app/[lang]/DictionaryProvider' import { needsHumanReviewForCriterion } from '@/components/can-sr/needsHumanReview' +import { ScreeningCitationContext, extractHumanAnswer, humanAnswerStatus, resolveConfiguredValue } from '@/components/can-sr/screening-citation-context' /* Title & Abstract single-citation viewer for L1 screening. @@ -92,6 +93,8 @@ function extractXmlTag(text: string, tag: string): string { type CriteriaData = { questions: string[] possible_answers: string[][] + items?: Array<{ answer_column?: string | null }> + citation_fields?: { title?: string | null; abstract?: string | null; l1_include?: string[] } } type LatestAgentRun = { @@ -403,6 +406,8 @@ export default function CanSrL1ScreenPage() { setCriteriaData({ questions: l1.questions, possible_answers: l1.possible_answers, + items: Array.isArray(l1.items) ? l1.items : [], + citation_fields: parsed?.citation_fields || {}, }) } else { // If structure is different attempt best-effort mapping @@ -413,6 +418,8 @@ export default function CanSrL1ScreenPage() { possible_answers: Array.isArray(possible_answers) ? possible_answers : [], + items: Array.isArray(l1?.items) ? l1.items : [], + citation_fields: parsed?.citation_fields || {}, }) } } @@ -440,9 +447,9 @@ export default function CanSrL1ScreenPage() { criteriaData.questions.forEach((q: string, idx: number) => { const llmCol = snakeCaseColumn(q) - const humanCol = humanScreenColumn(q) + const humanCol = criteriaData.items?.[idx]?.answer_column || humanScreenColumn(q) - const humanRaw = (citation as any)?.[humanCol] + const humanRaw = resolveConfiguredValue(citation as any, humanCol) const llmRaw = (citation as any)?.[llmCol] const parseMaybeJson = (v: any) => { @@ -463,8 +470,8 @@ export default function CanSrL1ScreenPage() { // 1) Prefer human_* for dropdown if (humanParsed && typeof humanParsed === 'object' && (humanParsed as any).selected !== undefined) { newSelections[idx] = (humanParsed as any).selected - } else if (typeof humanParsed === 'string' && humanParsed) { - newSelections[idx] = humanParsed + } else if (extractHumanAnswer(humanParsed)) { + newSelections[idx] = extractHumanAnswer(humanParsed) } // 2) Always populate AI panel from llm_* when available @@ -659,22 +666,10 @@ export default function CanSrL1ScreenPage() { return (
-
-

Citation #{citation.id}

-

- {citation.title} -

-
- -
-

{dict.screening.abstract}

-

- {citation.abstract || dict.screening.noAbstract} -

-
+
) - }, [citation, loadingCitation, dict, error]) + }, [citation, loadingCitation, dict, error, criteriaData]) if (!srId || !citationId) { // guard - redirect already handled in effect but keep safe render @@ -763,6 +758,8 @@ export default function CanSrL1ScreenPage() {
{criteriaData.questions.map((q, idx) => { const options = criteriaData.possible_answers[idx] || [] + const answerColumn = criteriaData.items?.[idx]?.answer_column || null + const answerStatus = humanAnswerStatus(citation!, answerColumn, options) const current = selections[idx] ?? '' const aiData = aiPanels[idx] const aiSelected = @@ -832,6 +829,13 @@ export default function CanSrL1ScreenPage() { ))} + {answerStatus !== 'unconfigured' && answerStatus !== 'matched' ? ( +

+ Human answer column “{answerColumn}”: {answerStatus === 'missing' ? 'not found in this citation' : answerStatus === 'blank' ? 'blank for this citation' : 'value does not match an available answer'}. +

+ ) : answerStatus === 'matched' ? ( +

Human answer loaded from “{answerColumn}”.

+ ) : null}
diff --git a/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx index bc612c30..2814ff47 100644 --- a/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx +++ b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx @@ -9,6 +9,7 @@ import { Wand2 } from 'lucide-react' import { getAuthToken, getTokenType } from '@/lib/auth' import { useDictionary } from '@/app/[lang]/DictionaryProvider' import { needsHumanReviewForCriterion } from '@/components/can-sr/needsHumanReview' +import { ScreeningCitationContext, extractHumanAnswer, humanAnswerStatus, resolveConfiguredValue } from '@/components/can-sr/screening-citation-context' type ValidationEntry = { user: string; validated_at: string } @@ -107,6 +108,8 @@ type CriteriaData = { questions: string[] possible_answers: string[][] additional_infos?: (string | null)[] // optional per-question extra guidance when available + items?: Array<{ answer_column?: string | null }> + citation_fields?: { title?: string | null; abstract?: string | null; l1_include?: string[] } } type LatestAgentRun = { @@ -553,11 +556,17 @@ export default function CanSrL2ScreenViewPage() { const mergedQuestions = [...l1Parsed.questions, ...l2Parsed.questions] const mergedAnswers = [...l1Parsed.possible_answers, ...l2Parsed.possible_answers] const mergedInfos = [...l1Parsed.additional_infos, ...l2Parsed.additional_infos] + const mergedItems = [ + ...(Array.isArray(l1Block?.items) ? l1Block.items : []), + ...(Array.isArray(l2Block?.items) ? l2Block.items : []), + ] setCriteriaData({ questions: mergedQuestions, possible_answers: mergedAnswers, additional_infos: mergedInfos, + items: mergedItems, + citation_fields: parsed?.citation_fields || {}, }) setSourceFlags([ ...l1Parsed.questions.map(() => 'l1'), @@ -586,7 +595,7 @@ export default function CanSrL2ScreenViewPage() { criteriaData.questions.forEach((q: string, idx: number) => { const llmCol = snakeCaseColumn(q) - const humanCol = humanScreenColumn(q) + const humanCol = criteriaData.items?.[idx]?.answer_column || humanScreenColumn(q) const criterionKey = llmCol.replace(/^llm_/, '') const fulltextRun = runsByCriterion[criterionKey]?.screening const titleAbstractRun = titleAbstractRunsByCriterion[criterionKey] @@ -597,7 +606,7 @@ export default function CanSrL2ScreenViewPage() { fulltextRun && citationFulltextMd5 && runFulltextMd5 === citationFulltextMd5, ) - const humanRaw = (citation as any)?.[humanCol] + const humanRaw = resolveConfiguredValue(citation as any, humanCol) const llmRaw = (citation as any)?.[llmCol] // Parse possible JSON payloads from DB @@ -627,6 +636,8 @@ export default function CanSrL2ScreenViewPage() { ) if (isFulltextHuman && (humanParsed as any).selected !== undefined) { newSelections[idx] = (humanParsed as any).selected + } else if (sourceFlags[idx] === 'l2' && extractHumanAnswer(humanParsed)) { + newSelections[idx] = extractHumanAnswer(humanParsed) } // 2) For L1 questions: show hint from prior L1 screening (llm_*), but do not prefill from it @@ -895,7 +906,9 @@ export default function CanSrL2ScreenViewPage() { return
{dict.screening.citationNotFound}
return ( - + + + /> +
) - }, [citation, loadingCitation, srId, citationId, fulltextCoords, fulltextPages, fulltextStr, panelsKeyed, parsedTables, parsedFigures, dict, error]) + }, [citation, criteriaData, loadingCitation, srId, citationId, fulltextCoords, fulltextPages, fulltextStr, panelsKeyed, parsedTables, parsedFigures, dict, error]) if (!srId || !citationId) { // guard - redirect already handled in effect but keep safe render @@ -1008,6 +1022,8 @@ export default function CanSrL2ScreenViewPage() {
{criteriaData.questions.map((q, idx) => { const options = criteriaData.possible_answers[idx] || [] + const answerColumn = criteriaData.items?.[idx]?.answer_column || null + const answerStatus = humanAnswerStatus(citation!, answerColumn, options) const current = selections[idx] ?? '' const aiData = aiPanels[idx] const aiSelected = @@ -1105,6 +1121,13 @@ export default function CanSrL2ScreenViewPage() { ))} + {answerStatus !== 'unconfigured' && answerStatus !== 'matched' ? ( +

+ Human answer column “{answerColumn}”: {answerStatus === 'missing' ? 'not found in this citation' : answerStatus === 'blank' ? 'blank for this citation' : 'value does not match an available answer'}. +

+ ) : answerStatus === 'matched' ? ( +

Human answer loaded from “{answerColumn}”.

+ ) : null}
diff --git a/frontend/components/can-sr/screening-citation-context.tsx b/frontend/components/can-sr/screening-citation-context.tsx new file mode 100644 index 00000000..9be4ba33 --- /dev/null +++ b/frontend/components/can-sr/screening-citation-context.tsx @@ -0,0 +1,84 @@ +'use client' + +import React from 'react' + +export type CitationFields = { + title?: string | null + abstract?: string | null + doi?: string | null + l1_include?: string[] +} + +export function resolveConfiguredValue(row: Record, header?: string | null) { + if (!header) return undefined + if (Object.prototype.hasOwnProperty.call(row, header)) return row[header] + const normalize = (value: string) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') + const key = normalize(header) + const matches = Object.keys(row).filter((candidate) => normalize(candidate) === key) + return matches.length === 1 ? row[matches[0]] : undefined +} + +export function extractHumanAnswer(value: any): string { + if (value === undefined || value === null) return '' + if (typeof value === 'object') return String(value.selected ?? value.value ?? '') + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return typeof parsed === 'object' ? String(parsed?.selected ?? parsed?.value ?? '') : value + } catch { return value.trim() } + } + return String(value) +} + +export function humanAnswerStatus( + row: Record, + answerColumn: string | null | undefined, + options: string[], +): 'unconfigured' | 'missing' | 'blank' | 'matched' | 'unmatched' { + if (!answerColumn) return 'unconfigured' + const raw = resolveConfiguredValue(row, answerColumn) + if (raw === undefined) return 'missing' + const answer = extractHumanAnswer(raw).trim() + if (!answer) return 'blank' + if (!options.length) return 'matched' + const normalized = answer.toLowerCase().replace(/\s+/g, ' ') + return options.some((option) => option.trim().toLowerCase().replace(/\s+/g, ' ') === normalized) + ? 'matched' + : 'unmatched' +} + +export function ScreeningCitationContext({ + citation, + fields, +}: { + citation: Record + fields?: CitationFields | null +}) { + const title = resolveConfiguredValue(citation, fields?.title) ?? citation.title + const abstract = resolveConfiguredValue(citation, fields?.abstract) ?? citation.abstract + const other = (fields?.l1_include || []) + .filter((header) => header !== fields?.title && header !== fields?.abstract) + .map((header) => ({ header, value: resolveConfiguredValue(citation, header) })) + .filter(({ value }) => value !== undefined && value !== null && String(value).trim() !== '') + + return ( +
+
+

Citation #{citation.id}

+

{String(title || '')}

+
+
+

Abstract

+

{String(abstract || 'No abstract available')}

+
+ {other.length > 0 && ( +
+

Other citation fields

+
+ {other.map(({ header, value }) =>
{header}:
{String(value)}
)} +
+
+ )} +
+ ) +} diff --git a/frontend/components/can-sr/setup/citation-field-selector.tsx b/frontend/components/can-sr/setup/citation-field-selector.tsx index 2e7e0dc9..114017aa 100644 --- a/frontend/components/can-sr/setup/citation-field-selector.tsx +++ b/frontend/components/can-sr/setup/citation-field-selector.tsx @@ -1,5 +1,5 @@ import { ArrowDown, ArrowUp, ChevronDown, ChevronUp, Plus, X } from 'lucide-react' -import { useEffect, useRef, useState, type Dispatch } from 'react' +import { useState, type Dispatch } from 'react' import type { CriteriaDraftAction, CriteriaDraftState } from './criteria-types' export type CitationFieldContract = { @@ -7,80 +7,69 @@ export type CitationFieldContract = { unavailable_configured_fields: string[] } +type Row = { kind: 'title' | 'abstract' | 'additional'; column: string | null; index?: number } + export default function CitationFieldSelector({ state, dispatch, contract, labels }: { state: CriteriaDraftState dispatch: Dispatch contract: CitationFieldContract labels: Record }) { - const selected = state.criteria.citation_fields.l1_include - const available = new Set(contract.fields.map((field) => field.name)) - const addable = contract.fields.filter((field) => !selected.includes(field.name)) - const [addMenuOpen, setAddMenuOpen] = useState(false) const [collapsed, setCollapsed] = useState(false) - const addMenuRef = useRef(null) - const setSelected = (value: string[]) => dispatch({ type: 'set-citation-fields', value }) + const available = new Set(contract.fields.map((field) => field.name)) + const title = state.criteria.citation_fields.title || null + const abstract = state.criteria.citation_fields.abstract || null + const additional = state.criteria.citation_fields.l1_include + const rows: Row[] = [ + { kind: 'title', column: title }, + { kind: 'abstract', column: abstract }, + ...additional.map((column, index) => ({ kind: 'additional' as const, column, index })), + ] + const selected = new Set([title, abstract, ...additional].filter(Boolean) as string[]) + const options = (current: string | null) => [ + ...(current && !available.has(current) ? [{ name: current, unavailable: true }] : []), + ...contract.fields.filter((field) => !selected.has(field.name) || field.name === current).map((field) => ({ name: field.name, unavailable: false })), + ] + const setColumn = (row: Row, value: string | null) => { + if (row.kind === 'title') dispatch({ type: 'set-title-field', value }) + else if (row.kind === 'abstract') dispatch({ type: 'set-abstract-field', value }) + else if (row.index !== undefined) dispatch({ type: 'set-citation-fields', value: additional.map((item, index) => index === row.index ? value || '' : item).filter(Boolean) }) + } const move = (index: number, offset: number) => { - const next = [...selected] - ;[next[index], next[index + offset]] = [next[index + offset], next[index]] - setSelected(next) + const target = index + offset + if (target < 0 || target >= additional.length) return + const next = [...additional] + ;[next[index], next[target]] = [next[target], next[index]] + dispatch({ type: 'set-citation-fields', value: next }) } - useEffect(() => { - if (!addMenuOpen) return - const closeOnOutsideClick = (event: MouseEvent) => { - if (!addMenuRef.current?.contains(event.target as Node)) setAddMenuOpen(false) - } - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') setAddMenuOpen(false) - } - document.addEventListener('mousedown', closeOnOutsideClick) - document.addEventListener('keydown', closeOnEscape) - return () => { - document.removeEventListener('mousedown', closeOnOutsideClick) - document.removeEventListener('keydown', closeOnEscape) - } - }, [addMenuOpen]) + const labelFor = (kind: Row['kind']) => kind === 'title' ? labels.titleField : kind === 'abstract' ? labels.abstractField : labels.additionalField + const statusFor = (row: Row) => !row.column ? labels.requiredField : available.has(row.column) ? labels.available : labels.unavailableField + return
- -

{labels.citationFields} ({selected.length})

-
- - {addMenuOpen ? : null} -
+

{labels.citationFields}

+ {!collapsed && }
{!collapsed ?
-

{labels.titleAbstractFieldsDescription}

-
- - - - - - {selected.length === 0 ? : selected.map((name, index) => - - - - )} - -
{labels.field}{labels.status}{labels.actions}
{labels.noSelectedFields}
{name}{available.has(name) ? labels.available : {labels.unavailableField}}
- - - -
-
- - +

{labels.titleAbstractFieldsDescription}

+
+ + + + {rows.map((row) => + + + + + )} + +
{labels.field}{labels.selection}{labels.status}{labels.actions}
{labelFor(row.kind)}{row.kind !== 'additional' && {labels.required}}{!row.column ? {statusFor(row)} : available.has(row.column) ? labels.available : {labels.unavailableField}}
{row.kind === 'additional' && row.index !== undefined && <>}
+
+ +
: null}
} diff --git a/frontend/components/can-sr/setup/criteria-builder.tsx b/frontend/components/can-sr/setup/criteria-builder.tsx index 3646ae0a..eb4f825b 100644 --- a/frontend/components/can-sr/setup/criteria-builder.tsx +++ b/frontend/components/can-sr/setup/criteria-builder.tsx @@ -31,6 +31,7 @@ function QuestionCard({ referencedAnswerIds, collapsed, onToggle, + citationFields, }: { question: ScreeningQuestion index: number @@ -44,6 +45,7 @@ function QuestionCard({ referencedAnswerIds: Set collapsed: boolean onToggle: () => void + citationFields: CitationFieldContract }) { const prefix = `${stage}-${question.id}` const [contextAnswerId, setContextAnswerId] = useState(null) @@ -69,6 +71,15 @@ function QuestionCard({ {!collapsed ?
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" /> +
+ +

{labels.answerColumnTooltip}

+ +
{labels.answers} {question.answers.map((answer, answerIndex) => ( @@ -222,13 +233,13 @@ export default function CriteriaBuilder({ state, dispatch, labels, diagnostics = {state.criteria[stage].length === 0 ?

{labels.noQuestions}

: null} {state.criteria[stage].map((question, index) => { const position = ordered.findIndex((item) => item.question.id === question.id) - return item.itemId === question.id)} sourceReferenced={conditions.some((condition) => condition.source_item_id === question.id)} referencedAnswerIds={new Set(conditions.filter((condition) => condition.source_item_id === question.id).map((condition) => condition.option_id))} collapsed={collapsedQuestions.has(question.id)} onToggle={() => setCollapsedQuestions((current) => { const next = new Set(current); if (next.has(question.id)) next.delete(question.id); else next.add(question.id); return next })} /> + return item.itemId === question.id)} sourceReferenced={conditions.some((condition) => condition.source_item_id === question.id)} referencedAnswerIds={new Set(conditions.filter((condition) => condition.source_item_id === question.id).map((condition) => condition.option_id))} collapsed={collapsedQuestions.has(question.id)} onToggle={() => setCollapsedQuestions((current) => { const next = new Set(current); if (next.has(question.id)) next.delete(question.id); else next.add(question.id); return next })} /> })}
: null} ))} - +
) } diff --git a/frontend/components/can-sr/setup/criteria-types.ts b/frontend/components/can-sr/setup/criteria-types.ts index cfce5835..d425e1dc 100644 --- a/frontend/components/can-sr/setup/criteria-types.ts +++ b/frontend/components/can-sr/setup/criteria-types.ts @@ -12,6 +12,7 @@ export type ScreeningQuestion = { question: string answers: ScreeningAnswer[] trigger: { all: Array<{ source_item_id: string; option_id: string }> } + answer_column?: string | null } export type ParameterOption = { @@ -28,6 +29,7 @@ type ParameterBase = { calculation?: string | null trigger: ScreeningQuestion['trigger'] legacy_category?: string | null + answer_column?: string | null } export type TextParameter = ParameterBase & { type: 'text' } @@ -40,7 +42,7 @@ export type Parameter = TextParameter | SelectionParameter export type CriteriaConfig = { schema_version: 2 - citation_fields: { l1_include: string[]; doi?: string | null } + citation_fields: { l1_include: string[]; title?: string | null; abstract?: string | null; doi?: string | null } l1: ScreeningQuestion[] l2: ScreeningQuestion[] parameters: Parameter[] @@ -55,7 +57,10 @@ export type CriteriaDraftState = { export type CriteriaDraftAction = | { type: 'replace'; criteria: CriteriaConfig; revision: number } | { type: 'set-citation-fields'; value: string[] } + | { type: 'set-title-field'; value: string | null } + | { type: 'set-abstract-field'; value: string | null } | { type: 'set-doi'; value: string | null } + | { type: 'set-answer-column'; itemId: string; value: string | null } | { type: 'add-question'; stage: 'l1' | 'l2' } | { type: 'delete-question'; stage: 'l1' | 'l2'; questionId: string } | { type: 'move-question'; stage: 'l1' | 'l2'; questionId: string; direction: -1 | 1 } @@ -81,7 +86,7 @@ export type CriteriaDraftAction = export const emptyCriteria = (): CriteriaConfig => ({ schema_version: 2, - citation_fields: { l1_include: [], doi: null }, + citation_fields: { l1_include: [], title: null, abstract: null, doi: null }, l1: [], l2: [], parameters: [], @@ -147,6 +152,14 @@ export function criteriaDraftReducer( if (action.type === 'set-doi') { return { ...state, dirty: true, criteria: { ...state.criteria, citation_fields: { ...state.criteria.citation_fields, doi: action.value } } } } + if (action.type === 'set-title-field' || action.type === 'set-abstract-field') { + const key = action.type === 'set-title-field' ? 'title' : 'abstract' + return { ...state, dirty: true, criteria: { ...state.criteria, citation_fields: { ...state.criteria.citation_fields, [key]: action.value } } } + } + if (action.type === 'set-answer-column') { + const updateItem = (item: T): T => item.id === action.itemId ? { ...item, answer_column: action.value } : item + return { ...state, dirty: true, criteria: { ...state.criteria, l1: state.criteria.l1.map(updateItem), l2: state.criteria.l2.map(updateItem), parameters: state.criteria.parameters.map(updateItem) } } + } if (action.type === 'add-question') { return updateStage(state, action.stage, (questions) => [...questions, createQuestion()]) } diff --git a/frontend/components/can-sr/setup/criteria-validation.ts b/frontend/components/can-sr/setup/criteria-validation.ts index 07236c8f..2913bbb4 100644 --- a/frontend/components/can-sr/setup/criteria-validation.ts +++ b/frontend/components/can-sr/setup/criteria-validation.ts @@ -10,6 +10,9 @@ export function validateCriteriaDraft(criteria: CriteriaConfig): CriteriaDiagnos const available = new Map | null>() const add = (path: string, message: string, itemId?: string) => diagnostics.push({ path, message, itemId }) + if (!criteria.citation_fields.title?.trim()) add('citation_fields.title', 'A Title source column is required.') + if (!criteria.citation_fields.abstract?.trim()) add('citation_fields.abstract', 'An Abstract source column is required.') + const validateId = (id: string, path: string, itemId: string) => { if (!idPattern.test(id)) add(path, 'The stable ID has an invalid format.', itemId) if (seenIds.has(id)) add(path, 'The stable ID must be unique across the review.', itemId) diff --git a/frontend/components/can-sr/setup/parameter-builder.tsx b/frontend/components/can-sr/setup/parameter-builder.tsx index 039beb6e..d7560900 100644 --- a/frontend/components/can-sr/setup/parameter-builder.tsx +++ b/frontend/components/can-sr/setup/parameter-builder.tsx @@ -6,6 +6,7 @@ import type { CriteriaDraftAction, CriteriaDraftState, Parameter, ParameterOptio import type { CriteriaDiagnostic } from './criteria-validation' import ContextEditorDialog from './context-editor-dialog' import ParameterDetailsDialog, { type ParameterDetails } from './parameter-details-dialog' +import type { CitationFieldContract } from './citation-field-selector' type TriggerSource = { stage: 'l1' | 'l2' | 'parameters'; id: string; label: string; options: ParameterOption[] } @@ -14,7 +15,7 @@ const questionSource = (stage: 'l1' | 'l2', question: ScreeningQuestion): Trigge options: question.answers.map(({ id, label, context }) => ({ id, label, context })), }) -function ParameterCard({ parameter, index, count, sources, dependants, referencedOptionIds, dispatch, labels, diagnostics, collapsed, onToggle }: { +function ParameterCard({ parameter, index, count, sources, dependants, referencedOptionIds, dispatch, labels, diagnostics, collapsed, onToggle, citationFields }: { parameter: Parameter index: number count: number @@ -26,6 +27,7 @@ function ParameterCard({ parameter, index, count, sources, dependants, reference diagnostics: CriteriaDiagnostic[] collapsed: boolean onToggle: () => void + citationFields: CitationFieldContract }) { const prefix = `parameter-${parameter.id}` const [contextOptionId, setContextOptionId] = useState(null) @@ -55,6 +57,11 @@ function ParameterCard({ parameter, index, count, sources, dependants, reference
update('name', event.target.value)} className="mt-1 w-full rounded-md border px-3 py-2" />
+
+ +

{labels.answerColumnTooltip}

+ +
{typeChangeBlocked ?

{labels.typeChangeBlocked}: {dependants.join(', ')}

: null}

{labels.parameterDetails}

{detailCount ? `${detailCount} ${labels.detailsConfigured}` : labels.noDetailsConfigured}

@@ -62,7 +69,7 @@ function ParameterCard({ parameter, index, count, sources, dependants, reference
{parameter.type === 'selection' ?
{labels.selectionOptions} - + {parameter.options.map((option, optionIndex) =>
dispatch({ type: 'update-option', parameterId: parameter.id, optionId: option.id, field: 'label', value: event.target.value })} className="mt-1 w-full rounded border px-2 py-1.5" />{option.context?.trim() ?

{labels.contextAdded}

: null}
@@ -94,7 +101,7 @@ function ParameterCard({ parameter, index, count, sources, dependants, reference } -export default function ParameterBuilder({ state, dispatch, labels, diagnostics = [] }: { state: CriteriaDraftState; dispatch: Dispatch; labels: Record; diagnostics?: CriteriaDiagnostic[] }) { +export default function ParameterBuilder({ state, dispatch, labels, diagnostics = [], citationFields = { fields: [], unavailable_configured_fields: [] } }: { state: CriteriaDraftState; dispatch: Dispatch; labels: Record; diagnostics?: CriteriaDiagnostic[]; citationFields?: CitationFieldContract }) { const [sectionCollapsed, setSectionCollapsed] = useState(false) const [collapsedParameters, setCollapsedParameters] = useState>(() => new Set()) const pendingParameterCount = useRef(null) @@ -141,7 +148,7 @@ export default function ParameterBuilder({ state, dispatch, labels, diagnostics {state.criteria.parameters.map((parameter, index) => { const earlierParameters: TriggerSource[] = state.criteria.parameters.slice(0, index).filter((item) => item.type === 'selection').map((item) => ({ stage: 'parameters', id: item.id, label: item.name, options: item.options })) const dependants = state.criteria.parameters.filter((item) => item.id !== parameter.id && item.trigger.all.some((condition) => condition.source_item_id === parameter.id)).map((item) => itemLabels.get(item.id) || item.id) - return condition.source_item_id === parameter.id).map((condition) => condition.option_id))} dispatch={dispatch} labels={labels} diagnostics={diagnostics.filter((item) => item.itemId === parameter.id)} collapsed={collapsedParameters.has(parameter.id)} onToggle={() => setCollapsedParameters((current) => { const next = new Set(current); if (next.has(parameter.id)) next.delete(parameter.id); else next.add(parameter.id); return next })} /> + return condition.source_item_id === parameter.id).map((condition) => condition.option_id))} dispatch={dispatch} labels={labels} citationFields={citationFields} diagnostics={diagnostics.filter((item) => item.itemId === parameter.id)} collapsed={collapsedParameters.has(parameter.id)} onToggle={() => setCollapsedParameters((current) => { const next = new Set(current); if (next.has(parameter.id)) next.delete(parameter.id); else next.add(parameter.id); return next })} /> })}
: null} diff --git a/frontend/dictionaries/en.json b/frontend/dictionaries/en.json index 095281a8..0a4876fe 100644 --- a/frontend/dictionaries/en.json +++ b/frontend/dictionaries/en.json @@ -149,21 +149,146 @@ "saving": "Saving...", "savedSuccessfully": "Saved successfully", "criteriaBuilder": { - "builderTitle": "Criteria Builder", "builderDescription": "Configure title/abstract fields and screening questions.", - "citationFields": "Title/Abstract Fields", "l1Fields": "L1 fields (comma separated)", "doiField": "DOI source field", - "titleAbstractFieldsDescription": "Choose and order the citation fields used for title and abstract screening.", "field": "Field", "status": "Status", "actions": "Actions", "available": "Available", "noSelectedFields": "No title/abstract fields configured.", "addField": "Add field", "noCitationFields": "Upload a CSV or RIS file to discover fields.", "unavailableField": "Not found in the current upload", "removeField": "Remove field", "noDoiField": "No DOI field", - "l1": "Title and abstract screening (L1)", "l2": "Full-text screening (L2)", "question": "Question", "questionText": "Question text", "answerContext": "Answer context", - "answers": "Answers", "answerLabel": "Answer label", "decision": "Decision", "include": "Include", "exclude": "Exclude", - "addQuestion": "Add question", "addAnswer": "Add answer", "deleteQuestion": "Delete question", "deleteAnswer": "Delete answer", "moveUp": "Move up", "moveDown": "Move down", "noQuestions": "No questions configured.", "expand": "Expand", "collapse": "Collapse", "minimizeAll": "Minimize section", "maximizeSection": "Show section with items minimized", "maximizeAll": "Expand all items", "untitledQuestion": "Untitled question", "untitledParameter": "Untitled parameter", "errors": "errors", - "editAnswerContext": "Edit answer context", "editOptionContext": "Edit option context", "answerContextFor": "Add guidance for the answer", "optionContextFor": "Add guidance for the option", "contextAdded": "Context added", "saveContext": "Save context", "cancel": "Cancel", + "builderTitle": "Criteria Builder", + "builderDescription": "Configure title/abstract fields and screening questions.", + "citationFields": "Title/Abstract Fields", + "l1Fields": "L1 fields (comma separated)", + "doiField": "DOI source field", + "titleAbstractFieldsDescription": "Choose the required Title and Abstract columns, then add and order optional citation fields.", + "field": "Field", + "status": "Status", + "actions": "Actions", + "available": "Available", + "noSelectedFields": "No title/abstract fields configured.", + "addField": "Add field", + "noCitationFields": "Upload a CSV or RIS file to discover fields.", + "unavailableField": "Not found in the current upload", + "removeField": "Remove field", + "noDoiField": "No DOI field", + "l1": "Title and abstract screening (L1)", + "l2": "Full-text screening (L2)", + "question": "Question", + "questionText": "Question text", + "answerContext": "Answer context", "answerColumn": "Answer column", "addAnswerColumn": "Add Answer Column", "answerColumnTooltip": "Match an existing human answer from an imported file when one exists. Valid matches are authoritative; blank or unmatched values are warned about.", "noAnswerColumn": "No answer column", "unavailableField": "Unavailable field", + "answers": "Answers", + "answerLabel": "Answer label", + "decision": "Decision", + "include": "Include", + "exclude": "Exclude", + "addQuestion": "Add question", + "addAnswer": "Add answer", + "deleteQuestion": "Delete question", + "deleteAnswer": "Delete answer", + "moveUp": "Move up", + "moveDown": "Move down", + "noQuestions": "No questions configured.", + "expand": "Expand", + "collapse": "Collapse", + "minimizeAll": "Minimize section", + "maximizeSection": "Show section with items minimized", + "maximizeAll": "Expand all items", + "untitledQuestion": "Untitled question", + "untitledParameter": "Untitled parameter", + "errors": "errors", + "editAnswerContext": "Edit answer context", + "editOptionContext": "Edit option context", + "answerContextFor": "Add guidance for the answer", + "optionContextFor": "Add guidance for the option", + "contextAdded": "Context added", + "saveContext": "Save context", + "cancel": "Continue editing", "deleteDependencyWarning": "This item may be referenced by a later condition. Delete it and leave affected conditions marked for repair?", - "triggers": "Conditional visibility", "triggerDescription": "Show this question only when all of the following are true.", "alwaysShown": "Always shown", "conditionsConfigured": "conditions configured", "triggerSource": "Earlier question", "triggerAnswer": "Required answer", "addTrigger": "Add condition", "removeTrigger": "Remove condition", "invalidReference": "Invalid reference", "triggerOrderError": "This condition references a missing, current, or later question. Retarget it or remove the condition.", "missingAnswerError": "The referenced answer no longer exists. Select another answer or remove the condition.", - "parameters": "Extraction parameters", "parameter": "Parameter", "addParameter": "Add parameter", "deleteParameter": "Delete parameter", "moveUpParameter": "Move parameter up", "moveDownParameter": "Move parameter down", "noParameters": "No extraction parameters configured.", "parameterName": "Name", "parameterDescription": "Description", "parameterType": "Type", "freeText": "Free text", "selectionList": "Selection from list", "unitInstructions": "Units and reporting instructions", "calculation": "Calculation instructions", "parameterDetails": "Description and instructions", "editDetails": "Edit details", "editParameterDetails": "Edit parameter details", "parameterDetailsFor": "Configure description and instructions for", "detailsConfigured": "of 3 details configured", "noDetailsConfigured": "No description or instructions configured", "saveDetails": "Save details", "selectionOptions": "Selection options", "selectionMode": "Selection mode", "singleSelection": "One answer", "multipleSelection": "Multiple answers", "optionLabel": "Option label", "optionContext": "Option context", "addOption": "Add option", "deleteOption": "Delete option", "typeChangeBlocked": "Free text is unavailable because later parameters depend on this list. Remove or retarget these conditions first", - "validationPassed": "Draft validation passed.", "validationFailed": "Fix validation errors before saving.", "validationErrors": "{count} validation error(s)", "questionsCount": "questions", "parametersCount": "parameters", - "importPreviewTitle": "Review YAML import", "importPreviewDescription": "The backend normalized this file into canonical criteria. Review the summary and warnings before replacing your current draft.", "importFormat": "Detected format", "legacyYaml": "Legacy YAML (normalized to v2)", "canonicalYaml": "Canonical criteria v2", "importWarnings": "Import notices", "importNoWarnings": "No import warnings were reported.", "importConfirmationRequired": "This migration contains inferred or ambiguous values that require review before the first save.", "cancelImport": "Keep current draft", "replaceDraft": "Replace draft", "importReady": "YAML import is ready for review.", "importCancelled": "YAML import cancelled; the current draft was preserved.", - "conflictTitle": "A newer criteria revision is available", "conflictDescription": "Another editor saved criteria after you loaded this draft. Reload the latest revision before making further changes.", "conflictPreserved": "Your local draft has not been overwritten. Download it before reloading if you need to retain your changes.", "reloadTitle": "Discard unsaved criteria changes?", "reloadDescription": "Reloading replaces your current draft with the latest saved configuration.", "exportLocalDraft": "Download local draft", "discardAndReload": "Discard and reload", "cancel": "Continue editing", "conflictDetected": "Save stopped because a newer criteria revision exists.", - "importYaml": "Import", "downloadYaml": "Download", "reload": "Reload", "save": "Save criteria", "loading": "Loading criteria…", "saving": "Saving…", "saved": "Saved successfully.", "unsaved": "Unsaved changes", "imported": "YAML imported into the draft.", "confirmSaveTitle": "Save criteria changes?", "confirmSaveDescription": "Updating these criteria may invalidate existing screening results.", "cancel": "Continue editing", - "loadFailed": "Unable to load criteria.", "saveFailed": "Unable to save criteria.", "importFailed": "Unable to import YAML.", "downloadFailed": "Unable to download YAML.", "migrationWarning": "Legacy YAML was normalized. Review inferred decisions before saving.", "confirmInvalidation": "I understand this change may invalidate existing screening results." + "triggers": "Conditional visibility", + "triggerDescription": "Show this question only when all of the following are true.", + "alwaysShown": "Always shown", + "conditionsConfigured": "conditions configured", + "triggerSource": "Earlier question", + "triggerAnswer": "Required answer", + "addTrigger": "Add condition", + "removeTrigger": "Remove condition", + "invalidReference": "Invalid reference", + "triggerOrderError": "This condition references a missing, current, or later question. Retarget it or remove the condition.", + "missingAnswerError": "The referenced answer no longer exists. Select another answer or remove the condition.", + "parameters": "Extraction parameters", + "parameter": "Parameter", + "addParameter": "Add parameter", + "deleteParameter": "Delete parameter", + "moveUpParameter": "Move parameter up", + "moveDownParameter": "Move parameter down", + "noParameters": "No extraction parameters configured.", + "parameterName": "Name", + "parameterDescription": "Description", + "parameterType": "Type", + "freeText": "Free text", + "selectionList": "Selection from list", + "unitInstructions": "Units and reporting instructions", + "calculation": "Calculation instructions", + "parameterDetails": "Description and instructions", + "editDetails": "Edit details", + "editParameterDetails": "Edit parameter details", + "parameterDetailsFor": "Configure description and instructions for", + "detailsConfigured": "of 3 details configured", + "noDetailsConfigured": "No description or instructions configured", + "saveDetails": "Save details", + "selectionOptions": "Selection options", + "selectionMode": "Selection mode", + "singleSelection": "Select one answer", + "multipleSelection": "Select multiple answers", + "optionLabel": "Option label", + "optionContext": "Option context", + "addOption": "Add option", + "deleteOption": "Delete option", + "typeChangeBlocked": "Free text is unavailable because later parameters depend on this list. Remove or retarget these conditions first", + "validationPassed": "Draft validation passed.", + "validationFailed": "Fix validation errors before saving.", + "validationErrors": "{count} validation error(s)", + "questionsCount": "questions", + "parametersCount": "parameters", + "importPreviewTitle": "Review YAML import", + "importPreviewDescription": "The backend normalized this file into canonical criteria. Review the summary and warnings before replacing your current draft.", + "importFormat": "Detected format", + "legacyYaml": "Legacy YAML (normalized to v2)", + "canonicalYaml": "Canonical criteria v2", + "importWarnings": "Import notices", + "importNoWarnings": "No import warnings were reported.", + "importConfirmationRequired": "This migration contains inferred or ambiguous values that require review before the first save.", + "cancelImport": "Keep current draft", + "replaceDraft": "Replace draft", + "importReady": "YAML import is ready for review.", + "importCancelled": "YAML import cancelled; the current draft was preserved.", + "conflictTitle": "A newer criteria revision is available", + "conflictDescription": "Another editor saved criteria after you loaded this draft. Reload the latest revision before making further changes.", + "conflictPreserved": "Your local draft has not been overwritten. Download it before reloading if you need to retain your changes.", + "reloadTitle": "Discard unsaved criteria changes?", + "reloadDescription": "Reloading replaces your current draft with the latest saved configuration.", + "exportLocalDraft": "Download local draft", + "discardAndReload": "Discard and reload", + "conflictDetected": "Save stopped because a newer criteria revision exists.", + "importYaml": "Import", + "downloadYaml": "Download", + "reload": "Reload", + "save": "Save criteria", + "loading": "Loading criteria…", + "saving": "Saving…", + "saved": "Saved successfully.", + "unsaved": "Unsaved changes", + "imported": "YAML imported into the draft.", + "confirmSaveTitle": "Save criteria changes?", + "confirmSaveDescription": "Updating these criteria may invalidate existing screening results.", + "loadFailed": "Unable to load criteria.", + "saveFailed": "Unable to save criteria.", + "importFailed": "Unable to import YAML.", + "downloadFailed": "Unable to download YAML.", + "migrationWarning": "Legacy YAML was normalized. Review inferred decisions before saving.", + "confirmInvalidation": "I understand this change may invalidate existing screening results.", + "selection": "Selection", + "required": "Required", + "requiredField": "Required selection", + "selectColumn": "Select a column", + "titleField": "Title", + "abstractField": "Abstract", + "additionalField": "Additional field", + "addAdditionalField": "Add additional field" } }, "search": { diff --git a/frontend/dictionaries/fr.json b/frontend/dictionaries/fr.json index 7cc57053..e6c83727 100644 --- a/frontend/dictionaries/fr.json +++ b/frontend/dictionaries/fr.json @@ -149,21 +149,146 @@ "saving": "Enregistrement...", "savedSuccessfully": "Enregistré avec succès", "criteriaBuilder": { - "builderTitle": "Générateur de critères", "builderDescription": "Configurez les champs de titre/résumé et les questions.", - "citationFields": "Champs de titre/résumé", "l1Fields": "Champs L1 (séparés par des virgules)", "doiField": "Champ source du DOI", - "titleAbstractFieldsDescription": "Choisissez et ordonnez les champs de citation utilisés pour la sélection du titre et du résumé.", "field": "Champ", "status": "État", "actions": "Actions", "available": "Disponible", "noSelectedFields": "Aucun champ de titre/résumé configuré.", "addField": "Ajouter un champ", "noCitationFields": "Téléversez un fichier CSV ou RIS pour découvrir les champs.", "unavailableField": "Introuvable dans le téléversement actuel", "removeField": "Supprimer le champ", "noDoiField": "Aucun champ DOI", - "l1": "Sélection titre et résumé (L1)", "l2": "Sélection du texte intégral (L2)", "question": "Question", "questionText": "Texte de la question", "answerContext": "Contexte de la réponse", - "answers": "Réponses", "answerLabel": "Libellé de réponse", "decision": "Décision", "include": "Inclure", "exclude": "Exclure", - "addQuestion": "Ajouter une question", "addAnswer": "Ajouter une réponse", "deleteQuestion": "Supprimer la question", "deleteAnswer": "Supprimer la réponse", "moveUp": "Monter", "moveDown": "Descendre", "noQuestions": "Aucune question configurée.", "expand": "Développer", "collapse": "Réduire", "minimizeAll": "Réduire la section", "maximizeSection": "Afficher la section avec les éléments réduits", "maximizeAll": "Développer tous les éléments", "untitledQuestion": "Question sans titre", "untitledParameter": "Paramètre sans titre", "errors": "erreurs", - "editAnswerContext": "Modifier le contexte de la réponse", "editOptionContext": "Modifier le contexte de l’option", "answerContextFor": "Ajouter des directives pour la réponse", "optionContextFor": "Ajouter des directives pour l’option", "contextAdded": "Contexte ajouté", "saveContext": "Enregistrer le contexte", "cancel": "Annuler", + "builderTitle": "Générateur de critères", + "builderDescription": "Configurez les champs de titre/résumé et les questions.", + "citationFields": "Champs de titre/résumé", + "l1Fields": "Champs L1 (séparés par des virgules)", + "doiField": "Champ source du DOI", + "titleAbstractFieldsDescription": "Choisissez les colonnes obligatoires Titre et Résumé, puis ajoutez et ordonnez les champs de citation facultatifs.", + "field": "Champ", + "status": "État", + "actions": "Actions", + "available": "Disponible", + "noSelectedFields": "Aucun champ de titre/résumé configuré.", + "addField": "Ajouter un champ", + "noCitationFields": "Téléversez un fichier CSV ou RIS pour découvrir les champs.", + "unavailableField": "Introuvable dans le téléversement actuel", + "removeField": "Supprimer le champ", + "noDoiField": "Aucun champ DOI", + "l1": "Sélection titre et résumé (L1)", + "l2": "Sélection du texte intégral (L2)", + "question": "Question", + "questionText": "Texte de la question", + "answerContext": "Contexte de réponse", "answerColumn": "Colonne de réponse", "addAnswerColumn": "Ajouter une colonne de réponse", "answerColumnTooltip": "Associer une réponse humaine existante provenant d’un fichier importé. Les correspondances valides sont prioritaires; les valeurs vides ou non correspondantes sont signalées.", "noAnswerColumn": "Aucune colonne de réponse", "unavailableField": "Colonne indisponible", + "answers": "Réponses", + "answerLabel": "Libellé de réponse", + "decision": "Décision", + "include": "Inclure", + "exclude": "Exclure", + "addQuestion": "Ajouter une question", + "addAnswer": "Ajouter une réponse", + "deleteQuestion": "Supprimer la question", + "deleteAnswer": "Supprimer la réponse", + "moveUp": "Monter", + "moveDown": "Descendre", + "noQuestions": "Aucune question configurée.", + "expand": "Développer", + "collapse": "Réduire", + "minimizeAll": "Réduire la section", + "maximizeSection": "Afficher la section avec les éléments réduits", + "maximizeAll": "Développer tous les éléments", + "untitledQuestion": "Question sans titre", + "untitledParameter": "Paramètre sans titre", + "errors": "erreurs", + "editAnswerContext": "Modifier le contexte de la réponse", + "editOptionContext": "Modifier le contexte de l’option", + "answerContextFor": "Ajouter des directives pour la réponse", + "optionContextFor": "Ajouter des directives pour l’option", + "contextAdded": "Contexte ajouté", + "saveContext": "Enregistrer le contexte", + "cancel": "Continuer la modification", "deleteDependencyWarning": "Cet élément peut être référencé par une condition ultérieure. Le supprimer et laisser les conditions touchées marquées pour correction?", - "triggers": "Visibilité conditionnelle", "triggerDescription": "Afficher cette question uniquement lorsque toutes les conditions suivantes sont remplies.", "alwaysShown": "Toujours affichée", "conditionsConfigured": "conditions configurées", "triggerSource": "Question précédente", "triggerAnswer": "Réponse requise", "addTrigger": "Ajouter une condition", "removeTrigger": "Supprimer une condition", "invalidReference": "Référence non valide", "triggerOrderError": "Cette condition fait référence à une question manquante, courante ou ultérieure. Modifiez-la ou supprimez-la.", "missingAnswerError": "La réponse référencée n’existe plus. Sélectionnez une autre réponse ou supprimez la condition.", - "parameters": "Paramètres d’extraction", "parameter": "Paramètre", "addParameter": "Ajouter un paramètre", "deleteParameter": "Supprimer le paramètre", "moveUpParameter": "Monter le paramètre", "moveDownParameter": "Descendre le paramètre", "noParameters": "Aucun paramètre d’extraction configuré.", "parameterName": "Nom", "parameterDescription": "Description", "parameterType": "Type", "freeText": "Texte libre", "selectionList": "Sélection dans une liste", "unitInstructions": "Unités et instructions de présentation", "calculation": "Instructions de calcul", "parameterDetails": "Description et instructions", "editDetails": "Modifier les détails", "editParameterDetails": "Modifier les détails du paramètre", "parameterDetailsFor": "Configurer la description et les instructions pour", "detailsConfigured": "détails sur 3 configurés", "noDetailsConfigured": "Aucune description ni instruction configurée", "saveDetails": "Enregistrer les détails", "selectionOptions": "Options de sélection", "selectionMode": "Mode de sélection", "singleSelection": "Une réponse", "multipleSelection": "Plusieurs réponses", "optionLabel": "Libellé de l’option", "optionContext": "Contexte de l’option", "addOption": "Ajouter une option", "deleteOption": "Supprimer l’option", "typeChangeBlocked": "Le texte libre est indisponible car des paramètres ultérieurs dépendent de cette liste. Supprimez ou modifiez d’abord ces conditions", - "validationPassed": "La validation du brouillon a réussi.", "validationFailed": "Corrigez les erreurs de validation avant d’enregistrer.", "validationErrors": "{count} erreur(s) de validation", "questionsCount": "questions", "parametersCount": "paramètres", - "importPreviewTitle": "Vérifier l’importation YAML", "importPreviewDescription": "Le serveur a normalisé ce fichier en critères canoniques. Vérifiez le résumé et les avertissements avant de remplacer le brouillon actuel.", "importFormat": "Format détecté", "legacyYaml": "YAML hérité (normalisé en v2)", "canonicalYaml": "Critères canoniques v2", "importWarnings": "Avis d’importation", "importNoWarnings": "Aucun avertissement d’importation n’a été signalé.", "importConfirmationRequired": "Cette migration contient des valeurs déduites ou ambiguës qui doivent être vérifiées avant le premier enregistrement.", "cancelImport": "Conserver le brouillon actuel", "replaceDraft": "Remplacer le brouillon", "importReady": "L’importation YAML est prête à être vérifiée.", "importCancelled": "Importation YAML annulée; le brouillon actuel a été conservé.", - "conflictTitle": "Une version plus récente des critères est disponible", "conflictDescription": "Une autre personne a enregistré les critères après le chargement de ce brouillon. Rechargez la dernière version avant de poursuivre.", "conflictPreserved": "Votre brouillon local n’a pas été remplacé. Téléchargez-le avant de recharger si vous devez conserver vos modifications.", "reloadTitle": "Abandonner les modifications non enregistrées?", "reloadDescription": "Le rechargement remplace le brouillon actuel par la dernière configuration enregistrée.", "exportLocalDraft": "Télécharger le brouillon local", "discardAndReload": "Abandonner et recharger", "cancel": "Continuer la modification", "conflictDetected": "L’enregistrement a été interrompu, car une version plus récente des critères existe.", - "importYaml": "Importer", "downloadYaml": "Télécharger", "reload": "Recharger", "save": "Enregistrer les critères", "loading": "Chargement des critères…", "saving": "Enregistrement…", "saved": "Enregistré avec succès.", "unsaved": "Modifications non enregistrées", "imported": "YAML importé dans le brouillon.", "confirmSaveTitle": "Enregistrer les modifications des critères?", "confirmSaveDescription": "La mise à jour de ces critères peut invalider les résultats de sélection existants.", "cancel": "Continuer la modification", - "loadFailed": "Impossible de charger les critères.", "saveFailed": "Impossible d’enregistrer les critères.", "importFailed": "Impossible d’importer le YAML.", "downloadFailed": "Impossible de télécharger le YAML.", "migrationWarning": "Le YAML hérité a été normalisé. Vérifiez les décisions déduites avant l’enregistrement.", "confirmInvalidation": "Je comprends que ce changement peut invalider les résultats de sélection existants." + "triggers": "Visibilité conditionnelle", + "triggerDescription": "Afficher cette question uniquement lorsque toutes les conditions suivantes sont remplies.", + "alwaysShown": "Toujours affichée", + "conditionsConfigured": "conditions configurées", + "triggerSource": "Question précédente", + "triggerAnswer": "Réponse requise", + "addTrigger": "Ajouter une condition", + "removeTrigger": "Supprimer une condition", + "invalidReference": "Référence non valide", + "triggerOrderError": "Cette condition fait référence à une question manquante, courante ou ultérieure. Modifiez-la ou supprimez-la.", + "missingAnswerError": "La réponse référencée n’existe plus. Sélectionnez une autre réponse ou supprimez la condition.", + "parameters": "Paramètres d’extraction", + "parameter": "Paramètre", + "addParameter": "Ajouter un paramètre", + "deleteParameter": "Supprimer le paramètre", + "moveUpParameter": "Monter le paramètre", + "moveDownParameter": "Descendre le paramètre", + "noParameters": "Aucun paramètre d’extraction configuré.", + "parameterName": "Nom", + "parameterDescription": "Description", + "parameterType": "Type", + "freeText": "Texte libre", + "selectionList": "Sélection dans une liste", + "unitInstructions": "Unités et instructions de présentation", + "calculation": "Instructions de calcul", + "parameterDetails": "Description et instructions", + "editDetails": "Modifier les détails", + "editParameterDetails": "Modifier les détails du paramètre", + "parameterDetailsFor": "Configurer la description et les instructions pour", + "detailsConfigured": "détails sur 3 configurés", + "noDetailsConfigured": "Aucune description ni instruction configurée", + "saveDetails": "Enregistrer les détails", + "selectionOptions": "Options de sélection", + "selectionMode": "Mode de sélection", + "singleSelection": "Sélectionner une réponse", + "multipleSelection": "Sélectionner plusieurs réponses", + "optionLabel": "Libellé de l’option", + "optionContext": "Contexte de l’option", + "addOption": "Ajouter une option", + "deleteOption": "Supprimer l’option", + "typeChangeBlocked": "Le texte libre est indisponible car des paramètres ultérieurs dépendent de cette liste. Supprimez ou modifiez d’abord ces conditions", + "validationPassed": "La validation du brouillon a réussi.", + "validationFailed": "Corrigez les erreurs de validation avant d’enregistrer.", + "validationErrors": "{count} erreur(s) de validation", + "questionsCount": "questions", + "parametersCount": "paramètres", + "importPreviewTitle": "Vérifier l’importation YAML", + "importPreviewDescription": "Le serveur a normalisé ce fichier en critères canoniques. Vérifiez le résumé et les avertissements avant de remplacer le brouillon actuel.", + "importFormat": "Format détecté", + "legacyYaml": "YAML hérité (normalisé en v2)", + "canonicalYaml": "Critères canoniques v2", + "importWarnings": "Avis d’importation", + "importNoWarnings": "Aucun avertissement d’importation n’a été signalé.", + "importConfirmationRequired": "Cette migration contient des valeurs déduites ou ambiguës qui doivent être vérifiées avant le premier enregistrement.", + "cancelImport": "Conserver le brouillon actuel", + "replaceDraft": "Remplacer le brouillon", + "importReady": "L’importation YAML est prête à être vérifiée.", + "importCancelled": "Importation YAML annulée; le brouillon actuel a été conservé.", + "conflictTitle": "Une version plus récente des critères est disponible", + "conflictDescription": "Une autre personne a enregistré les critères après le chargement de ce brouillon. Rechargez la dernière version avant de poursuivre.", + "conflictPreserved": "Votre brouillon local n’a pas été remplacé. Téléchargez-le avant de recharger si vous devez conserver vos modifications.", + "reloadTitle": "Abandonner les modifications non enregistrées?", + "reloadDescription": "Le rechargement remplace le brouillon actuel par la dernière configuration enregistrée.", + "exportLocalDraft": "Télécharger le brouillon local", + "discardAndReload": "Abandonner et recharger", + "conflictDetected": "L’enregistrement a été interrompu, car une version plus récente des critères existe.", + "importYaml": "Importer", + "downloadYaml": "Télécharger", + "reload": "Recharger", + "save": "Enregistrer les critères", + "loading": "Chargement des critères…", + "saving": "Enregistrement…", + "saved": "Enregistré avec succès.", + "unsaved": "Modifications non enregistrées", + "imported": "YAML importé dans le brouillon.", + "confirmSaveTitle": "Enregistrer les modifications des critères?", + "confirmSaveDescription": "La mise à jour de ces critères peut invalider les résultats de sélection existants.", + "loadFailed": "Impossible de charger les critères.", + "saveFailed": "Impossible d’enregistrer les critères.", + "importFailed": "Impossible d’importer le YAML.", + "downloadFailed": "Impossible de télécharger le YAML.", + "migrationWarning": "Le YAML hérité a été normalisé. Vérifiez les décisions déduites avant l’enregistrement.", + "confirmInvalidation": "Je comprends que ce changement peut invalider les résultats de sélection existants.", + "selection": "Sélection", + "required": "Obligatoire", + "requiredField": "Sélection obligatoire", + "selectColumn": "Sélectionner une colonne", + "titleField": "Titre", + "abstractField": "Résumé", + "additionalField": "Champ supplémentaire", + "addAdditionalField": "Ajouter un champ supplémentaire" } }, "search": { From bdc221ab2edd35d71c2d3b21ad817e034c5b09a8 Mon Sep 17 00:00:00 2001 From: bing1100 Date: Tue, 11 Aug 2026 02:32:40 -0400 Subject: [PATCH 2/2] fixes and issues --- backend/api/criteria/service.py | 22 +- backend/api/extract/prompts.py | 11 +- backend/api/extract/router.py | 20 - .../api/jobs/pipelines/screening_executor.py | 102 +- .../api/jobs/pipelines/screening_pipeline.py | 8 +- backend/api/jobs/router.py | 24 +- backend/api/screen/router.py | 33 +- .../api/services/citation_field_service.py | 4 + backend/api/services/sr_db_service.py | 5 +- backend/api/sr/router.py | 16 +- backend/tests/citation_field_service_test.py | 21 + backend/tests/criteria_config_router_test.py | 28 + backend/tests/criteria_context_test.py | 23 + backend/tests/extract_prompt_test.py | 9 + .../tests/fulltext_screening_prompt_test.py | 21 + backend/tests/screen_agentic_utils_test.py | 14 + backend/tests/screening_executor_skip_test.py | 37 + .../app/[lang]/can-sr/l1-screen/view/page.tsx | 2 - .../app/[lang]/can-sr/l2-screen/view/page.tsx | 1289 +++++++++++------ frontend/app/[lang]/can-sr/setup/page.tsx | 3 + .../components/can-sr/CitationListPage.tsx | 35 +- .../can-sr/PDFBoundingBoxViewer.tsx | 6 +- frontend/components/can-sr/PagedList.tsx | 2 + .../can-sr/ScreeningMetricsPanel.tsx | 425 +++--- .../screening-citation-context.test.tsx | 14 + .../can-sr/screening-citation-context.tsx | 88 +- .../can-sr/setup/criteria-builder.tsx | 24 +- .../can-sr/setup/criteria-editor.tsx | 43 +- .../components/can-sr/setup/criteria-types.ts | 2 +- .../can-sr/setup/criteria-validation.test.tsx | 11 + .../can-sr/setup/criteria-validation.ts | 12 +- .../can-sr/setup/parameter-builder.tsx | 16 +- frontend/dictionaries/en.json | 5 +- frontend/dictionaries/fr.json | 5 +- 34 files changed, 1577 insertions(+), 803 deletions(-) create mode 100644 backend/tests/extract_prompt_test.py create mode 100644 backend/tests/fulltext_screening_prompt_test.py create mode 100644 backend/tests/screening_executor_skip_test.py create mode 100644 frontend/components/can-sr/screening-citation-context.test.tsx diff --git a/backend/api/criteria/service.py b/backend/api/criteria/service.py index 16103479..91f82d06 100644 --- a/backend/api/criteria/service.py +++ b/backend/api/criteria/service.py @@ -19,6 +19,12 @@ LEGACY_KEYS = {'include', 'criteria', 'l2_criteria', 'parameters'} LEGACY_ONLY_KEYS = {'include', 'criteria', 'l2_criteria'} MAX_YAML_BYTES = 1_000_000 +DEFAULT_CITATION_FIELDS = ('Title', 'Abstract') + + +def _with_default_citation_fields(values: list[str]) -> list[str]: + """Keep the fixed Title and Abstract fields in every criteria document.""" + return [value for value in dict.fromkeys(values) if value not in DEFAULT_CITATION_FIELDS] class Diagnostic(BaseModel): @@ -94,6 +100,19 @@ def normalize(self, raw: dict[str, Any], *, source_kind: str = 'backend_load') - f'v2 criteria cannot contain legacy keys: {sorted(mixed)}', ) normalized = deepcopy(raw) + citation_fields = normalized.get('citation_fields') + if citation_fields is None: + normalized['citation_fields'] = { + 'title': 'Title', 'abstract': 'Abstract', 'l1_include': [], + } + elif isinstance(citation_fields, dict): + configured = citation_fields.get('l1_include', []) + if isinstance(configured, list) and all(isinstance(value, str) for value in configured): + citation_fields['l1_include'] = _with_default_citation_fields( + configured, + ) + citation_fields.setdefault('title', 'Title') + citation_fields.setdefault('abstract', 'Abstract') diagnostics = self._move_question_context_to_answers(normalized) return CriteriaLoadResult( criteria=CriteriaConfigV2.model_validate(normalized), @@ -220,6 +239,7 @@ def _migrate_legacy(self, legacy: dict[str, Any], *, source_kind: str) -> Criter value.strip() for value in include ), ) + normalized_include = _with_default_citation_fields(normalized_include) if len(normalized_include) != len(include): diagnostics.append( Diagnostic( @@ -326,7 +346,7 @@ def questions(key: str, stage: str) -> list[dict[str, Any]]: l2 = questions('l2_criteria', 'l2') criteria = CriteriaConfigV2.model_validate({ 'schema_version': 2, - 'citation_fields': {'l1_include': normalized_include, 'doi': None}, + 'citation_fields': {'title': 'Title', 'abstract': 'Abstract', 'l1_include': normalized_include, 'doi': None}, 'l1': l1, 'l2': l2, 'parameters': converted_parameters, }) canonical_source = json.dumps( diff --git a/backend/api/extract/prompts.py b/backend/api/extract/prompts.py index b9166967..6c3ba399 100644 --- a/backend/api/extract/prompts.py +++ b/backend/api/extract/prompts.py @@ -12,17 +12,18 @@ - "found": a boolean (true/false) indicating whether the parameter was located or could be confidently derived. - "value": the extracted value as a string (or null if not found). - "explanation": a concise explanation (1-4 sentences) describing why this value was chosen or how it was derived. -- "evidence_sentences": an array of integers indicating the sentence indices you used as evidence (e.g. [2, 5]). If there are no supporting sentences, return an empty array. -- "evidence_tables": an array of integers indicating table numbers used (e.g. [1, 2]) or []. -- "evidence_figures": an array of integers indicating figure numbers used (e.g. [3]) or []. +- "evidence_sentences": an array of integers indicating the sentence indices you used as evidence (e.g. [2, 5]). Use this for both positive and negative conclusions. +- "evidence_tables": an array of integers indicating table numbers used (e.g. [1, 2]) or []. Use this for both positive and negative conclusions. +- "evidence_figures": an array of integers indicating figure numbers used (e.g. [3]) or []. Use this for both positive and negative conclusions. Requirements: - If the parameter is explicitly present, return the value exactly as found (preserve units/format) and list the sentence indices. - If the parameter must be computed or approximated, include the computed value and explain the computation in "explanation", and list the sentences used for calculation. -- If the parameter is not present and cannot be deduced, set "found": false, "value": null, "explanation": briefly state why not found, and "evidence_sentences": []. +- If the parameter is not present and cannot be deduced, set "found": false and "value": null, explain what the article does and does not report, and list the sentence indices that support that conclusion. Do not return an empty evidence_sentences array when relevant sentences support the explanation. An empty array is appropriate only when the full text contains no relevant supporting sentence. +- Every factual claim in "explanation" must be grounded in the supplied full text, tables, or figures. In particular, a negative result still requires evidence for the reported quantities, definitions, or limitations that justify why the parameter cannot be extracted. - If a calculation is defined for the parameter, with a description of variables to be computed, find those variables and walk through the computation in the explanation. - Do NOT include any extra keys, XML, or human commentary. The output must be parseable by json.loads. -- If a table or figure is referenced, ensure the explanation references the table/figure number and what was extracted from it. +- If a sentence, table, or figure is referenced, ensure the explanation references the sentence/table/figure number and what was extracted from it. Inputs available for formatting: - {parameter_name} (a short name for the parameter) diff --git a/backend/api/extract/router.py b/backend/api/extract/router.py index f36cb258..864ca2d3 100644 --- a/backend/api/extract/router.py +++ b/backend/api/extract/router.py @@ -460,26 +460,6 @@ def _norm_int_list(v: Any) -> list[int]: detail='Citation not found to update', ) - # Auto-fill human_param_* from llm_param_* if missing (never overwrite) - try: - col_human = col_name.replace('llm_param_', 'human_param_', 1) - human_payload = { - **stored, - 'autofilled': True, - 'source': 'llm', - 'timestamp': datetime.utcnow().isoformat() + 'Z', - } - await run_in_threadpool( - cits_dp_service.copy_jsonb_if_empty, - citation_id, - col_name, - col_human, - human_payload, - table_name, - ) - except Exception: - pass - return {'status': 'success', 'sr_id': sr_id, 'citation_id': citation_id, 'column': col_name, 'extraction': stored} diff --git a/backend/api/jobs/pipelines/screening_executor.py b/backend/api/jobs/pipelines/screening_executor.py index 5a71651d..02d7b18b 100644 --- a/backend/api/jobs/pipelines/screening_executor.py +++ b/backend/api/jobs/pipelines/screening_executor.py @@ -30,9 +30,11 @@ from .control import wait_if_paused -def _should_skip_ai_output(existing_value: Any, *, force: bool) -> bool: +def _should_skip_ai_output( + existing_value: Any, *, force: bool, skip_existing_ai: bool, +) -> bool: """Return True if we should skip doing work because output already exists.""" - if force: + if force or not skip_existing_ai: return False # Missing output is NULL/empty per planning doc. if existing_value is None: @@ -42,6 +44,13 @@ def _should_skip_ai_output(existing_value: Any, *, force: bool) -> bool: return True +def _should_skip_human_answer( + human_status: Any, *, force: bool, skip_existing_human: bool, +) -> bool: + """Return True only when the explicit human-answer skip option is enabled.""" + return not force and skip_existing_human and human_status == 'matched' + + def _eligible_ids(*, sr_id: str, table_name: str, step: str) -> list[int]: """Return eligible citation ids for server-computed kickoff mode. @@ -91,6 +100,8 @@ async def _run_l1_for_citation( citation_id: int, model: str | None, force: bool, + skip_existing_ai: bool = False, + skip_existing_human: bool = False, ) -> tuple[int, int, int]: """Returns (done, skipped, failed) increments for this citation.""" row = await run_in_threadpool(cits_dp_service.get_citation_by_id, citation_id, table_name) @@ -144,11 +155,17 @@ async def _run_l1_for_citation( } xtra = format_item_context(item) or xtra human = resolve_existing_human_value(row, item) - if human.get('status') == 'matched': + if _should_skip_human_answer( + human.get('status'), + force=force, + skip_existing_human=skip_existing_human, + ): continue col = snake_case_column(q) existing = row.get(col) - if _should_skip_ai_output(existing, force=force): + if _should_skip_ai_output( + existing, force=force, skip_existing_ai=skip_existing_ai, + ): continue if not azure_openai_client.is_configured(): @@ -262,25 +279,6 @@ async def _call_agent(prompt: str): await run_in_threadpool(cits_dp_service.update_jsonb_column, citation_id, col, classification_json, table_name) - # Best-effort autofill human_ if empty - try: - core = snake_case(q, max_len=56) - human_col = f"human_{core}" if core else 'human_col' - human_payload = { - **classification_json, - 'autofilled': True, 'source': 'llm', - } - await run_in_threadpool( - cits_dp_service.copy_jsonb_if_empty, - citation_id, - col, - human_col, - human_payload, - table_name, - ) - except Exception: - pass - any_ran = True # Update derived decisions (uses fresh row inside) @@ -332,6 +330,8 @@ async def _run_l2_for_citation( citation_id: int, model: str | None, force: bool, + skip_existing_ai: bool = False, + skip_existing_human: bool = False, ) -> tuple[int, int, int]: row = await run_in_threadpool(cits_dp_service.get_citation_by_id, citation_id, table_name) if not row or not row.get('fulltext_url'): @@ -493,11 +493,17 @@ async def _run_l2_for_citation( } xtra = format_item_context(item) or xtra human = resolve_existing_human_value(row, item) - if human.get('status') == 'matched': + if _should_skip_human_answer( + human.get('status'), + force=force, + skip_existing_human=skip_existing_human, + ): continue col = snake_case_column(q) existing = row.get(col) - if _should_skip_ai_output(existing, force=force): + if _should_skip_ai_output( + existing, force=force, skip_existing_ai=skip_existing_ai, + ): continue # --- Agentic (screening + critical) --- @@ -619,25 +625,6 @@ async def _call_agent(prompt: str): await run_in_threadpool(cits_dp_service.update_jsonb_column, citation_id, col, classification_json, table_name) - # Best-effort autofill human - try: - core = snake_case(q, max_len=56) - human_col = f"human_{core}" if core else 'human_col' - human_payload = { - **classification_json, - 'autofilled': True, 'source': 'llm', - } - await run_in_threadpool( - cits_dp_service.copy_jsonb_if_empty, - citation_id, - col, - human_col, - human_payload, - table_name, - ) - except Exception: - pass - any_ran = True try: @@ -659,6 +646,8 @@ async def _run_extract_for_citation( citation_id: int, model: str | None, force: bool, + skip_existing_ai: bool = False, + skip_existing_human: bool = False, ) -> tuple[int, int, int]: row = await run_in_threadpool(cits_dp_service.get_citation_by_id, citation_id, table_name) if not row or not row.get('fulltext_url'): @@ -817,10 +806,16 @@ def _extract_json_object(text: str) -> str | None: await wait_if_paused(job_id) col = snake_case_param(name) existing = row.get(col) - if _should_skip_ai_output(existing, force=force): + if _should_skip_ai_output( + existing, force=force, skip_existing_ai=skip_existing_ai, + ): continue human = resolve_existing_human_value(row, item) - if human.get('status') == 'matched': + if _should_skip_human_answer( + human.get('status'), + force=force, + skip_existing_human=skip_existing_human, + ): continue prompt = PARAMETER_PROMPT_JSON.format( @@ -872,21 +867,6 @@ def _extract_json_object(text: str) -> str | None: await run_in_threadpool(cits_dp_service.update_jsonb_column, citation_id, col, stored, table_name) - # best-effort autofill human_param - try: - human_col = col.replace('llm_param_', 'human_param_', 1) - human_payload = {**stored, 'autofilled': True, 'source': 'llm'} - await run_in_threadpool( - cits_dp_service.copy_jsonb_if_empty, - citation_id, - col, - human_col, - human_payload, - table_name, - ) - except Exception: - pass - any_ran = True if any_ran: diff --git a/backend/api/jobs/pipelines/screening_pipeline.py b/backend/api/jobs/pipelines/screening_pipeline.py index d2899329..b77bdd4c 100644 --- a/backend/api/jobs/pipelines/screening_pipeline.py +++ b/backend/api/jobs/pipelines/screening_pipeline.py @@ -50,7 +50,9 @@ async def execute_item( try: executor = self._executors[context.step] except KeyError as exc: - raise ValueError(f"Unsupported screening step: '{context.step}'") from exc + raise ValueError( + f"Unsupported screening step: '{context.step}'", + ) from exc common = { 'job_id': context.job_id, @@ -59,6 +61,8 @@ async def execute_item( 'citation_id': int(work_item), 'model': context.model, 'force': bool(context.config.get('force')), + 'skip_existing_ai': bool(context.config.get('skip_existing_ai')), + 'skip_existing_human': bool(context.config.get('skip_existing_human')), } if context.step in {'l2', 'extract'}: common['sr_id'] = context.sr_id @@ -75,4 +79,4 @@ def format_phase(self, work_item: int) -> str: return f'citation {work_item}' def error_stage(self, context: JobContext) -> str: - return context.step \ No newline at end of file + return context.step diff --git a/backend/api/jobs/router.py b/backend/api/jobs/router.py index c9abc96c..8b356bd7 100644 --- a/backend/api/jobs/router.py +++ b/backend/api/jobs/router.py @@ -23,9 +23,9 @@ from .procrastinate_app import jobs_enabled from .procrastinate_app import worker_concurrency from .run_all_repo import run_all_repo +from .run_all_tasks import enqueue_available_chunks from .run_all_tasks import run_all_chunk from .run_all_tasks import run_all_start -from .run_all_tasks import enqueue_available_chunks from .scheduler_service import InvalidJobTransition from .scheduler_service import SchedulerService # Import task objects so we can enqueue via Task.defer_async (Procrastinate 3.2.x) @@ -63,7 +63,13 @@ class RunAllStartRequest(BaseModel): step: str = Field(..., description='l1 | l2 | extract') model: str | None = Field(None, description='LLM model name') force: bool = Field( - False, description='If true, overwrite existing outputs', + False, description='Legacy option: overwrite existing AI outputs and ignore human answers', + ) + skip_existing_ai: bool = Field( + False, description='Skip criteria that already have an AI answer', + ) + skip_existing_human: bool = Field( + False, description='Skip criteria that already have a human answer', ) # NOTE: backend currently forces chunk_size=1 to ensure fair interleaving # of multiple active run-all jobs. @@ -143,6 +149,14 @@ async def start_run_all( # If legacy llm_* outputs exist but normalized agent runs are missing, we must # regenerate results to populate screening_agent_runs. # We enforce this by auto-enabling force overwrite. + # The explicit skip options are preferred. Keep `force` as a backwards- + # compatible alias for the old behavior, which reruns everything. + skip_existing_ai = bool( + payload.skip_existing_ai, + ) and not bool(payload.force) + skip_existing_human = bool( + payload.skip_existing_human, + ) and not bool(payload.force) force = bool(payload.force) try: table_name = (screening or {}).get('table_name') or 'citations' @@ -197,6 +211,8 @@ async def start_run_all( model=normalized_model, meta={ 'force': force, + 'skip_existing_ai': skip_existing_ai, + 'skip_existing_human': skip_existing_human, 'chunk_size': int(payload.chunk_size), 'explicit_ids': bool(sanitized_ids is not None), 'legacy_auto_force': (force and (not bool(payload.force))), @@ -274,7 +290,9 @@ async def start_job( if payload.pipeline_key != 'pdf_linkage': raise HTTPException(status_code=400, detail='Unsupported pipeline_key') if not jobs_enabled(): - raise HTTPException(status_code=503, detail='Background jobs are disabled') + raise HTTPException( + status_code=503, detail='Background jobs are disabled', + ) await load_sr_and_check(sr_id, current_user, srdb_service) await run_in_threadpool(run_all_repo.ensure_tables) existing = await run_in_threadpool(run_all_repo.get_active_job_for_sr, sr_id) diff --git a/backend/api/screen/router.py b/backend/api/screen/router.py index 777437c8..6a0b2e6b 100644 --- a/backend/api/screen/router.py +++ b/backend/api/screen/router.py @@ -695,12 +695,16 @@ def _parse_selected_from_human_payload(v: Any) -> str | None: try: obj = json.loads(s) if isinstance(obj, dict): + if obj.get('source') == 'llm': + return None sel = obj.get('selected') return str(sel).strip() if isinstance(sel, str) else None except Exception: return s return None if isinstance(v, dict): + if v.get('source') == 'llm': + return None sel = v.get('selected') return str(sel).strip() if isinstance(sel, str) else None return None @@ -1049,15 +1053,6 @@ async def classify_citation( # Persist into Postgres under a dynamic column name derived from question col_name = snake_case_column(payload.question) - # Human mirror column name (same slug as llm_, but prefixed human_) - try: - col_core_h = snake_case( - payload.question, max_len=56, - ) if snake_case else '' - except Exception: - col_core_h = '' - human_col_name = f"human_{col_core_h}" if col_core_h else 'human_col' - try: updated = await run_in_threadpool(cits_dp_service.update_jsonb_column, citation_id, col_name, classification_json, table_name) except RuntimeError as rexc: @@ -1076,26 +1071,6 @@ async def classify_citation( detail='Citation not found to update', ) - # Auto-fill human_* from llm_* if missing (never overwrite) - try: - human_payload = { - **classification_json, - 'autofilled': True, - 'source': 'llm', - 'timestamp': datetime.utcnow().isoformat() + 'Z', - } - await run_in_threadpool( - cits_dp_service.copy_jsonb_if_empty, - citation_id, - col_name, - human_col_name, - human_payload, - table_name, - ) - except Exception: - # best-effort - pass - await update_inclusion_decision(sr, citation_id, payload.screening_step, 'llm') return {'status': 'success', 'sr_id': sr_id, 'citation_id': citation_id, 'column': col_name, 'classification': classification_json} diff --git a/backend/api/services/citation_field_service.py b/backend/api/services/citation_field_service.py index 90cfef33..30d1fbd0 100644 --- a/backend/api/services/citation_field_service.py +++ b/backend/api/services/citation_field_service.py @@ -10,6 +10,7 @@ 'human_l1_decision', 'human_l2_decision', } SYSTEM_PREFIXES = ('llm_', 'human_', 'fulltext', 'parameters_', 'l1_', 'l2_') +CANONICAL_FIELD_NAMES = {'title': 'Title', 'abstract': 'Abstract'} def _is_user_field(name: str) -> bool: @@ -26,6 +27,9 @@ def build_citation_field_contract(review: dict[str, Any], columns: list[dict[str ).strip() if not name or not _is_user_field(name): continue + name = CANONICAL_FIELD_NAMES.get(name.casefold(), name) + if any(field['name'] == name for field in fields): + continue fields.append({ 'name': name, 'data_type': str( column.get('data_type') or 'text', diff --git a/backend/api/services/sr_db_service.py b/backend/api/services/sr_db_service.py index 727f874c..2f0c205e 100644 --- a/backend/api/services/sr_db_service.py +++ b/backend/api/services/sr_db_service.py @@ -642,7 +642,10 @@ def save_criteria_config( logger.exception( 'Failed to save canonical criteria config: %s', exc, ) - raise + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f'Failed to save criteria configuration: {exc}', + ) from exc def list_systematic_reviews_for_user(self, user_email: str) -> list[dict[str, Any]]: """ diff --git a/backend/api/sr/router.py b/backend/api/sr/router.py index ae42fc94..51132be1 100644 --- a/backend/api/sr/router.py +++ b/backend/api/sr/router.py @@ -397,9 +397,14 @@ async def get_criteria_config( review['criteria_yaml'], source_kind='backend_load', ) else: - raise ValueError( - 'No usable criteria configuration is stored for this review.', + empty_criteria = CriteriaConfigV2( + citation_fields={'title': 'Title', 'abstract': 'Abstract'}, ) + return { + 'criteria': empty_criteria.model_dump(mode='json', exclude_none=True), + 'revision': review.get('criteria_revision', 0), + 'warnings': [], + } except ValueError as exc: raise _criteria_error(exc) from exc @@ -486,7 +491,12 @@ async def save_criteria_config( 'errors': required_errors, }, ) - stored = criteria_configuration_service.normalize(review['criteria']) + previous_criteria = review.get('criteria') + stored = None + if isinstance(previous_criteria, dict): + stored = criteria_configuration_service.normalize( + previous_criteria, + ) except (KeyError, ValueError): stored = None if stored: diff --git a/backend/tests/citation_field_service_test.py b/backend/tests/citation_field_service_test.py index 6dba2c3f..3eb27288 100644 --- a/backend/tests/citation_field_service_test.py +++ b/backend/tests/citation_field_service_test.py @@ -30,3 +30,24 @@ def test_preserves_order_filters_runtime_columns_and_reports_unavailable(self): def test_supports_review_without_upload(self): self.assertEqual(build_citation_field_contract({}, [])['fields'], []) + + def test_canonicalizes_normalized_uploaded_title_and_abstract_columns(self): + result = build_citation_field_contract( + { + 'criteria': { + 'citation_fields': { + 'title': 'Title', 'abstract': 'Abstract', 'l1_include': [], + }, + }, + }, + [{'column_name': name, 'data_type': 'text'} for name in [ + 'id', 'title', 'abstract', + ]], + ) + self.assertEqual( + result['fields'], [ + {'name': 'Title', 'data_type': 'text'}, + {'name': 'Abstract', 'data_type': 'text'}, + ], + ) + self.assertEqual(result['unavailable_configured_fields'], []) diff --git a/backend/tests/criteria_config_router_test.py b/backend/tests/criteria_config_router_test.py index e61464dc..e053ccbb 100644 --- a/backend/tests/criteria_config_router_test.py +++ b/backend/tests/criteria_config_router_test.py @@ -113,6 +113,34 @@ async def test_save_requires_matching_legacy_migration_fingerprint(self, load_re caught.exception.detail['fingerprint'].startswith('sha256:'), ) + @patch('api.sr.router.srdb_service.save_criteria_config') + @patch('api.sr.router.discover_citation_fields') + @patch('api.sr.router._load_criteria_review', new_callable=AsyncMock) + async def test_save_initial_config_when_criteria_is_missing( + self, + load_review: AsyncMock, + discover, + save, + ) -> None: + load_review.return_value = { + 'criteria': None, + 'criteria_revision': 0, + } + discover.return_value = { + 'fields': [{'name': 'Title'}, {'name': 'Abstract'}], + 'unavailable_configured_fields': [], + } + save.return_value = {'revision': 1} + payload = CriteriaConfigSaveRequest( + expected_revision=0, + criteria=CriteriaConfigV2.model_validate(EMPTY_CONFIG), + ) + + response = await save_criteria_config('review-id', payload, USER) + + self.assertEqual(response['revision'], 1) + save.assert_called_once() + if __name__ == '__main__': unittest.main() diff --git a/backend/tests/criteria_context_test.py b/backend/tests/criteria_context_test.py index 372adbb9..ea2f353c 100644 --- a/backend/tests/criteria_context_test.py +++ b/backend/tests/criteria_context_test.py @@ -6,6 +6,7 @@ from api.criteria.context import format_title_abstract_context from api.criteria.context import match_answer_label from api.criteria.context import resolve_existing_human_value +from api.screen.router import _parse_selected_from_human_payload class CriteriaContextTests(unittest.TestCase): @@ -56,6 +57,28 @@ def test_item_context_keeps_option_context_separate(self) -> None: self.assertIn('Context: Item guidance', result) self.assertIn('- Yes: Yes guidance', result) + def test_metrics_ignore_legacy_llm_autofilled_human_values(self) -> None: + self.assertIsNone( + _parse_selected_from_human_payload( + {'selected': 'Yes', 'source': 'llm', 'autofilled': True}, + ), + ) + self.assertEqual( + _parse_selected_from_human_payload( + {'selected': 'Yes', 'source': 'csv_upload', 'autofilled': True}, + ), + 'Yes', + ) + self.assertEqual( + _parse_selected_from_human_payload( + { + 'selected': 'No', 'human': True, + 'reviewer': 'reviewer@example.com', + }, + ), + 'No', + ) + if __name__ == '__main__': unittest.main() diff --git a/backend/tests/extract_prompt_test.py b/backend/tests/extract_prompt_test.py new file mode 100644 index 00000000..a64e77e3 --- /dev/null +++ b/backend/tests/extract_prompt_test.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from api.extract.prompts import PARAMETER_PROMPT_JSON + + +def test_parameter_prompt_requires_evidence_for_negative_results(): + assert 'Use this for both positive and negative conclusions.' in PARAMETER_PROMPT_JSON + assert 'list the sentence indices that support that conclusion' in PARAMETER_PROMPT_JSON + assert 'Do not return an empty evidence_sentences array when relevant sentences support the explanation.' in PARAMETER_PROMPT_JSON diff --git a/backend/tests/fulltext_screening_prompt_test.py b/backend/tests/fulltext_screening_prompt_test.py new file mode 100644 index 00000000..ccd6a332 --- /dev/null +++ b/backend/tests/fulltext_screening_prompt_test.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from api.screen.prompts import PROMPT_XML_TEMPLATE_FULLTEXT + + +def test_fulltext_screening_prompt_contains_all_evidence_context(): + prompt = PROMPT_XML_TEMPLATE_FULLTEXT.format( + question='Does the study qualify?', + options='Include\nExclude', + xtra='Population guidance', + fulltext='[0] Full text sentence', + tables='Table [T1]\n| result |', + figures='Figure [F2] caption: outcome', + ) + + assert '[0] Full text sentence' in prompt + assert 'Table [T1]' in prompt + assert 'Figure [F2]' in prompt + assert '' in prompt + assert '' in prompt + assert '' in prompt diff --git a/backend/tests/screen_agentic_utils_test.py b/backend/tests/screen_agentic_utils_test.py index 4ce754af..3f8233f7 100644 --- a/backend/tests/screen_agentic_utils_test.py +++ b/backend/tests/screen_agentic_utils_test.py @@ -34,6 +34,20 @@ def test_invalid_confidence_is_reported_missing(): assert validate_agent_response(parsed, stage='screening') == ['confidence'] +def test_fulltext_evidence_is_parsed_and_deduplicated(): + parsed = parse_agent_xml( + 'Include0.8' + 'Supported by the results.' + '2, 5, 2, invalid' + '1, 1' + '3, bad, 4', + ) + + assert parsed.evidence_sentences == [2, 5] + assert parsed.evidence_tables == [1] + assert parsed.evidence_figures == [3, 4] + + @pytest.mark.asyncio async def test_screening_repairs_missing_rationale_once(): responses = iter([ diff --git a/backend/tests/screening_executor_skip_test.py b/backend/tests/screening_executor_skip_test.py new file mode 100644 index 00000000..19e5f545 --- /dev/null +++ b/backend/tests/screening_executor_skip_test.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from api.jobs.pipelines.screening_executor import _should_skip_ai_output +from api.jobs.pipelines.screening_executor import _should_skip_human_answer + + +def test_existing_ai_answer_is_skipped_only_when_requested() -> None: + assert not _should_skip_ai_output( + 'existing', force=False, skip_existing_ai=False, + ) + assert _should_skip_ai_output( + 'existing', force=False, skip_existing_ai=True, + ) + assert not _should_skip_ai_output( + None, force=False, skip_existing_ai=True, + ) + + +def test_existing_human_answer_is_skipped_only_when_requested() -> None: + assert not _should_skip_human_answer( + 'matched', force=False, skip_existing_human=False, + ) + assert _should_skip_human_answer( + 'matched', force=False, skip_existing_human=True, + ) + assert not _should_skip_human_answer( + 'blank', force=False, skip_existing_human=True, + ) + + +def test_force_preserves_rerun_everything_behavior() -> None: + assert not _should_skip_ai_output( + 'existing', force=True, skip_existing_ai=True, + ) + assert not _should_skip_human_answer( + 'matched', force=True, skip_existing_human=True, + ) diff --git a/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx b/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx index 0f5f0bb0..2f122899 100644 --- a/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx +++ b/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx @@ -833,8 +833,6 @@ export default function CanSrL1ScreenPage() {

Human answer column “{answerColumn}”: {answerStatus === 'missing' ? 'not found in this citation' : answerStatus === 'blank' ? 'blank for this citation' : 'value does not match an available answer'}.

- ) : answerStatus === 'matched' ? ( -

Human answer loaded from “{answerColumn}”.

) : null} diff --git a/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx index 2814ff47..09396811 100644 --- a/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx +++ b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx @@ -4,12 +4,19 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import GCHeader, { SRHeader } from '@/components/can-sr/headers' import { ModelSelector } from '@/components/chat' -import PDFBoundingBoxViewer, { PDFBoundingBoxViewerHandle } from '@/components/can-sr/PDFBoundingBoxViewer' +import PDFBoundingBoxViewer, { + PDFBoundingBoxViewerHandle, +} from '@/components/can-sr/PDFBoundingBoxViewer' import { Wand2 } from 'lucide-react' import { getAuthToken, getTokenType } from '@/lib/auth' import { useDictionary } from '@/app/[lang]/DictionaryProvider' import { needsHumanReviewForCriterion } from '@/components/can-sr/needsHumanReview' -import { ScreeningCitationContext, extractHumanAnswer, humanAnswerStatus, resolveConfiguredValue } from '@/components/can-sr/screening-citation-context' +import { + ScreeningCitationContext, + extractHumanAnswer, + humanAnswerStatus, + resolveConfiguredValue, +} from '@/components/can-sr/screening-citation-context' type ValidationEntry = { user: string; validated_at: string } @@ -109,7 +116,11 @@ type CriteriaData = { possible_answers: string[][] additional_infos?: (string | null)[] // optional per-question extra guidance when available items?: Array<{ answer_column?: string | null }> - citation_fields?: { title?: string | null; abstract?: string | null; l1_include?: string[] } + citation_fields?: { + title?: string | null + abstract?: string | null + l1_include?: string[] + } } type LatestAgentRun = { @@ -146,7 +157,9 @@ export default function CanSrL2ScreenViewPage() { const [citationIdList, setCitationIdList] = useState([]) // Autosave indicator per question - const [saveStatus, setSaveStatus] = useState>({}) + const [saveStatus, setSaveStatus] = useState< + Record + >({}) // UI state: human selections keyed by question index const [selections, setSelections] = useState>({}) @@ -161,37 +174,54 @@ export default function CanSrL2ScreenViewPage() { // Agentic runs (screening_agent_runs) for this citation const [agentRuns, setAgentRuns] = useState([]) - const [titleAbstractRuns, setTitleAbstractRuns] = useState([]) + const [titleAbstractRuns, setTitleAbstractRuns] = useState( + [], + ) const [, setLoadingRuns] = useState(false) // Per-criterion thresholds for this SR/step - const [thresholdByCriterionKey, setThresholdByCriterionKey] = useState | null>(null) + const [thresholdByCriterionKey, setThresholdByCriterionKey] = useState | null>(null) const [validating, setValidating] = useState(false) const [userEmail, setUserEmail] = useState(null) // Per-criterion AI status (keyed by question index) - const [criterionStatus, setCriterionStatus] = useState>({}) + const [criterionStatus, setCriterionStatus] = useState< + Record + >({}) // Run-all progress - const [runAllProgress, setRunAllProgress] = useState<{ done: number; total: number } | null>(null) + const [runAllProgress, setRunAllProgress] = useState<{ + done: number + total: number + } | null>(null) const [runningAll, setRunningAll] = useState(false) const [runAllError, setRunAllError] = useState(null) const citationKey = `${srId || ''}:${citationId || ''}` const currentCitationKeyRef = useRef(citationKey) currentCitationKeyRef.current = citationKey - const l2Validations = useMemo(() => parseValidations((citation as any)?.l2_validations), [citation]) + const l2Validations = useMemo( + () => parseValidations((citation as any)?.l2_validations), + [citation], + ) const l2Checked = useMemo(() => { const me = String(userEmail || '') if (!me) return false return l2Validations.some((v) => v.user === me) }, [l2Validations, userEmail]) const l2ValidationsSorted = useMemo(() => { - return [...l2Validations].sort((a, b) => String(b.validated_at || '').localeCompare(String(a.validated_at || ''))) + return [...l2Validations].sort((a, b) => + String(b.validated_at || '').localeCompare(String(a.validated_at || '')), + ) }, [l2Validations]) // Fulltext PDF viewer linkage const [fulltextCoords, setFulltextCoords] = useState(null) - const [fulltextPages, setFulltextPages] = useState<{ width: number; height: number }[] | null>(null) + const [fulltextPages, setFulltextPages] = useState< + { width: number; height: number }[] | null + >(null) const [fulltextStr, setFulltextStr] = useState(null) const viewerRef = useRef(null) @@ -215,7 +245,9 @@ export default function CanSrL2ScreenViewPage() { const j = await res.json().catch(() => ({})) const t = res.ok ? j?.screening_thresholds : null const stepMap = t && typeof t === 'object' ? (t as any).l2 : null - setThresholdByCriterionKey(stepMap && typeof stepMap === 'object' ? stepMap : {}) + setThresholdByCriterionKey( + stepMap && typeof stepMap === 'object' ? stepMap : {}, + ) } catch { setThresholdByCriterionKey({}) } @@ -262,9 +294,13 @@ export default function CanSrL2ScreenViewPage() { if (navSessionKey) { const raw = window.sessionStorage.getItem(navSessionKey) const parsed = raw ? JSON.parse(raw) : null - if (parsed && typeof parsed.startIndex === 'number') startedAt = parsed.startIndex + if (parsed && typeof parsed.startIndex === 'number') + startedAt = parsed.startIndex if (!raw && idx >= 0) { - window.sessionStorage.setItem(navSessionKey, JSON.stringify({ startIndex: idx, startedAt: Date.now() })) + window.sessionStorage.setItem( + navSessionKey, + JSON.stringify({ startIndex: idx, startedAt: Date.now() }), + ) startedAt = idx } } @@ -292,88 +328,119 @@ export default function CanSrL2ScreenViewPage() { }, []) // Load citation row (and ensure fulltext is extracted if missing) - const fetchCitationById = useCallback(async (id: string, signal?: AbortSignal) => { - if (!srId || !id) return - const requestKey = `${srId}:${id}` - setLoadingCitation(true) - try { - const headers = getAuthHeaders() - const res = await fetch( - `/api/can-sr/citations/get?sr_id=${encodeURIComponent(srId)}&citation_id=${encodeURIComponent(id)}`, - { method: 'GET', headers, signal }, - ) - const data = await res.json().catch(() => ({})) - if (currentCitationKeyRef.current !== requestKey) return - if (!res.ok) { - setError( - data?.error || data?.detail || `Failed to load citation (${res.status})`, + const fetchCitationById = useCallback( + async (id: string, signal?: AbortSignal) => { + if (!srId || !id) return + const requestKey = `${srId}:${id}` + setLoadingCitation(true) + try { + const headers = getAuthHeaders() + const res = await fetch( + `/api/can-sr/citations/get?sr_id=${encodeURIComponent(srId)}&citation_id=${encodeURIComponent(id)}`, + { method: 'GET', headers, signal }, ) - setCitation(null) - } else { - setCitation(data || null) - - // Extract overlay data - const parseJson = (v: any) => { - if (!v) return null - try { - return typeof v === 'string' ? JSON.parse(v) : v - } catch { - return null + const data = await res.json().catch(() => ({})) + if (currentCitationKeyRef.current !== requestKey) return + if (!res.ok) { + setError( + data?.error || + data?.detail || + `Failed to load citation (${res.status})`, + ) + setCitation(null) + } else { + setCitation(data || null) + + // Extract overlay data + const parseJson = (v: any) => { + if (!v) return null + try { + return typeof v === 'string' ? JSON.parse(v) : v + } catch { + return null + } } - } - const ft = typeof (data as any).fulltext === 'string' ? (data as any).fulltext : null - setFulltextStr(ft) - const coordsAny = parseJson((data as any).fulltext_coords) ?? (data as any).fulltext_coords - setFulltextCoords(Array.isArray(coordsAny) ? coordsAny : null) - const pagesAny = parseJson((data as any).fulltext_pages) ?? (data as any).fulltext_pages - setFulltextPages(Array.isArray(pagesAny) ? pagesAny : null) - - // If coords/pages missing, trigger backend extraction then refetch row - const needExtract = - !Array.isArray(coordsAny) || coordsAny.length === 0 || !Array.isArray(pagesAny) || pagesAny.length === 0 - if (needExtract) { - try { - const res2 = await fetch( - `/api/can-sr/citations/full-text?action=extract&sr_id=${encodeURIComponent( - srId || '', - )}&citation_id=${encodeURIComponent(String(id || ''))}`, - { method: 'POST', headers, signal }, - ) - if (res2.ok && currentCitationKeyRef.current === requestKey) { - const res3 = await fetch( - `/api/can-sr/citations/get?sr_id=${encodeURIComponent(srId)}&citation_id=${encodeURIComponent( - String(id), - )}`, - { headers, signal }, + const ft = + typeof (data as any).fulltext === 'string' + ? (data as any).fulltext + : null + setFulltextStr(ft) + const coordsAny = + parseJson((data as any).fulltext_coords) ?? + (data as any).fulltext_coords + setFulltextCoords(Array.isArray(coordsAny) ? coordsAny : null) + const pagesAny = + parseJson((data as any).fulltext_pages) ?? + (data as any).fulltext_pages + setFulltextPages(Array.isArray(pagesAny) ? pagesAny : null) + + // If coords/pages missing, trigger backend extraction then refetch row + const needExtract = + !Array.isArray(coordsAny) || + coordsAny.length === 0 || + !Array.isArray(pagesAny) || + pagesAny.length === 0 + if (needExtract) { + try { + const res2 = await fetch( + `/api/can-sr/citations/full-text?action=extract&sr_id=${encodeURIComponent( + srId || '', + )}&citation_id=${encodeURIComponent(String(id || ''))}`, + { method: 'POST', headers, signal }, ) - const row2 = await res3.json().catch(() => ({})) - if (currentCitationKeyRef.current !== requestKey) return - if (res3.ok) setCitation(row2 || null) - - const ft2 = typeof (row2 as any).fulltext === 'string' ? (row2 as any).fulltext : null - setFulltextStr(ft2) - - const coordsAny2 = parseJson((row2 as any).fulltext_coords) ?? (row2 as any).fulltext_coords - setFulltextCoords(Array.isArray(coordsAny2) ? coordsAny2 : null) - - const pagesAny2 = parseJson((row2 as any).fulltext_pages) ?? (row2 as any).fulltext_pages - setFulltextPages(Array.isArray(pagesAny2) ? pagesAny2 : null) + if (res2.ok && currentCitationKeyRef.current === requestKey) { + const res3 = await fetch( + `/api/can-sr/citations/get?sr_id=${encodeURIComponent(srId)}&citation_id=${encodeURIComponent( + String(id), + )}`, + { headers, signal }, + ) + const row2 = await res3.json().catch(() => ({})) + if (currentCitationKeyRef.current !== requestKey) return + if (res3.ok) setCitation(row2 || null) + + const ft2 = + typeof (row2 as any).fulltext === 'string' + ? (row2 as any).fulltext + : null + setFulltextStr(ft2) + + const coordsAny2 = + parseJson((row2 as any).fulltext_coords) ?? + (row2 as any).fulltext_coords + setFulltextCoords(Array.isArray(coordsAny2) ? coordsAny2 : null) + + const pagesAny2 = + parseJson((row2 as any).fulltext_pages) ?? + (row2 as any).fulltext_pages + setFulltextPages(Array.isArray(pagesAny2) ? pagesAny2 : null) + } + } catch (err: any) { + if ( + err?.name === 'AbortError' || + currentCitationKeyRef.current !== requestKey + ) + return + console.warn('Failed to extract fulltext for overlay', err) } - } catch (err: any) { - if (err?.name === 'AbortError' || currentCitationKeyRef.current !== requestKey) return - console.warn('Failed to extract fulltext for overlay', err) } } + } catch (err: any) { + if ( + err?.name === 'AbortError' || + currentCitationKeyRef.current !== requestKey + ) + return + console.error('Citation fetch error', err) + setError(err?.message || 'Network error while fetching citation') + } finally { + if (currentCitationKeyRef.current === requestKey) + setLoadingCitation(false) } - } catch (err: any) { - if (err?.name === 'AbortError' || currentCitationKeyRef.current !== requestKey) return - console.error('Citation fetch error', err) - setError(err?.message || 'Network error while fetching citation') - } finally { - if (currentCitationKeyRef.current === requestKey) setLoadingCitation(false) - } - }, [srId]) + }, + [srId], + ) useEffect(() => { if (!srId || !citationId) return @@ -400,7 +467,8 @@ export default function CanSrL2ScreenViewPage() { // Re-usable loader so we can refresh after triggering an agentic run. async function loadRuns(context?: { srId: string; citationId: string }) { - const runContext = context || (srId && citationId ? { srId, citationId } : null) + const runContext = + context || (srId && citationId ? { srId, citationId } : null) if (!runContext) return const requestKey = `${runContext.srId}:${runContext.citationId}` setLoadingRuns(true) @@ -442,16 +510,22 @@ export default function CanSrL2ScreenViewPage() { ) const data = await res.json().catch(() => ({})) if (currentCitationKeyRef.current !== requestKey) return - setTitleAbstractRuns(res.ok && Array.isArray(data?.runs) ? data.runs : []) + setTitleAbstractRuns( + res.ok && Array.isArray(data?.runs) ? data.runs : [], + ) } catch { - if (currentCitationKeyRef.current === requestKey) setTitleAbstractRuns([]) + if (currentCitationKeyRef.current === requestKey) + setTitleAbstractRuns([]) } })() // eslint-disable-next-line react-hooks/exhaustive-deps }, [srId, citationId]) const runsByCriterion = useMemo(() => { - const by: Record = {} + const by: Record< + string, + { screening?: LatestAgentRun; critical?: LatestAgentRun } + > = {} for (const r of agentRuns) { const key = String((r as any)?.criterion_key || '') if (!key) continue @@ -494,7 +568,11 @@ export default function CanSrL2ScreenViewPage() { const parseBlock = (block: any) => { if (!block) { - return { questions: [], possible_answers: [], additional_infos: [] as (string | null)[] } + return { + questions: [], + possible_answers: [], + additional_infos: [] as (string | null)[], + } } if (!Array.isArray(block?.questions)) { // mapping form: { question: { option: description } } @@ -522,17 +600,27 @@ export default function CanSrL2ScreenViewPage() { const clean = String(d).replace(/\s+/g, ' ').trim() return `- ${opt}: ${clean}` }) - addInfos.push(guidanceParts.length ? guidanceParts.join('\n') : null) + addInfos.push( + guidanceParts.length ? guidanceParts.join('\n') : null, + ) }) - return { questions: qArr, possible_answers: ansArr, additional_infos: addInfos } + return { + questions: qArr, + possible_answers: ansArr, + additional_infos: addInfos, + } } catch { const questions = block?.questions || [] const possible_answers = block?.possible_answers || [] const additional_infos = block?.additional_infos || [] return { questions: Array.isArray(questions) ? questions : [], - possible_answers: Array.isArray(possible_answers) ? possible_answers : [], - additional_infos: Array.isArray(additional_infos) ? additional_infos : [], + possible_answers: Array.isArray(possible_answers) + ? possible_answers + : [], + additional_infos: Array.isArray(additional_infos) + ? additional_infos + : [], } } } else { @@ -541,8 +629,12 @@ export default function CanSrL2ScreenViewPage() { const additional_infos = block?.additional_infos || [] return { questions: Array.isArray(questions) ? questions : [], - possible_answers: Array.isArray(possible_answers) ? possible_answers : [], - additional_infos: Array.isArray(additional_infos) ? additional_infos : [], + possible_answers: Array.isArray(possible_answers) + ? possible_answers + : [], + additional_infos: Array.isArray(additional_infos) + ? additional_infos + : [], } } } @@ -554,8 +646,14 @@ export default function CanSrL2ScreenViewPage() { const l2Parsed = parseBlock(l2Block) const mergedQuestions = [...l1Parsed.questions, ...l2Parsed.questions] - const mergedAnswers = [...l1Parsed.possible_answers, ...l2Parsed.possible_answers] - const mergedInfos = [...l1Parsed.additional_infos, ...l2Parsed.additional_infos] + const mergedAnswers = [ + ...l1Parsed.possible_answers, + ...l2Parsed.possible_answers, + ] + const mergedInfos = [ + ...l1Parsed.additional_infos, + ...l2Parsed.additional_infos, + ] const mergedItems = [ ...(Array.isArray(l1Block?.items) ? l1Block.items : []), ...(Array.isArray(l2Block?.items) ? l2Block.items : []), @@ -595,7 +693,8 @@ export default function CanSrL2ScreenViewPage() { criteriaData.questions.forEach((q: string, idx: number) => { const llmCol = snakeCaseColumn(q) - const humanCol = criteriaData.items?.[idx]?.answer_column || humanScreenColumn(q) + const humanCol = + criteriaData.items?.[idx]?.answer_column || humanScreenColumn(q) const criterionKey = llmCol.replace(/^llm_/, '') const fulltextRun = runsByCriterion[criterionKey]?.screening const titleAbstractRun = titleAbstractRunsByCriterion[criterionKey] @@ -603,7 +702,9 @@ export default function CanSrL2ScreenViewPage() { const runGuardrails = parseObject(fulltextRun?.guardrails) const runFulltextMd5 = String(runGuardrails?.fulltext_md5 || '') const isCurrentFulltextRun = Boolean( - fulltextRun && citationFulltextMd5 && runFulltextMd5 === citationFulltextMd5, + fulltextRun && + citationFulltextMd5 && + runFulltextMd5 === citationFulltextMd5, ) const humanRaw = resolveConfiguredValue(citation as any, humanCol) @@ -630,10 +731,12 @@ export default function CanSrL2ScreenViewPage() { // human_* is historically shared by L1 and L2. Only accept values that // explicitly identify themselves as Full Text/L2; an untagged legacy value // is ambiguous and must not appear as a previous Full Text review. - const isFulltextHuman = humanParsed && typeof humanParsed === 'object' && ( - (humanParsed as any).pipeline === 'fulltext' || - String((humanParsed as any).screening_step || '').toLowerCase() === 'l2' - ) + const isFulltextHuman = + humanParsed && + typeof humanParsed === 'object' && + ((humanParsed as any).pipeline === 'fulltext' || + String((humanParsed as any).screening_step || '').toLowerCase() === + 'l2') if (isFulltextHuman && (humanParsed as any).selected !== undefined) { newSelections[idx] = (humanParsed as any).selected } else if (sourceFlags[idx] === 'l2' && extractHumanAnswer(humanParsed)) { @@ -645,7 +748,8 @@ export default function CanSrL2ScreenViewPage() { // The shared llm_* column may have been overwritten by either pipeline. // Use the pipeline-specific run so a full-text result cannot masquerade // as the earlier Title/Abstract answer (or vice versa). - if (titleAbstractRun?.answer != null) newHints[idx] = String(titleAbstractRun.answer) + if (titleAbstractRun?.answer != null) + newHints[idx] = String(titleAbstractRun.answer) } // A Full Text AI panel must only exist when there is a persisted fulltext @@ -654,9 +758,13 @@ export default function CanSrL2ScreenViewPage() { // The latest agent-run endpoint returns the audit answer, while the // persisted llm_* payload contains the evidence arrays. Do not discard // that payload merely because answer normalization changed the value. - const persistedFulltext = llmParsed && typeof llmParsed === 'object' && - ((llmParsed as any).pipeline === 'fulltext' || !(llmParsed as any).pipeline) && - (!(llmParsed as any).fulltext_md5 || (llmParsed as any).fulltext_md5 === citationFulltextMd5) + const persistedFulltext = + llmParsed && + typeof llmParsed === 'object' && + ((llmParsed as any).pipeline === 'fulltext' || + !(llmParsed as any).pipeline) && + (!(llmParsed as any).fulltext_md5 || + (llmParsed as any).fulltext_md5 === citationFulltextMd5) newAiPanels[idx] = { ...(persistedFulltext ? llmParsed : {}), selected: fulltextRun.answer ?? '', @@ -668,9 +776,13 @@ export default function CanSrL2ScreenViewPage() { } // For L2 questions: if no human selection present, allow llm_* to prefill the dropdown - const hasSelection = newSelections[idx] !== undefined && newSelections[idx] !== '' + const hasSelection = + newSelections[idx] !== undefined && newSelections[idx] !== '' if (sourceFlags[idx] === 'l2' && !hasSelection) { - const aiSelected = (newAiPanels[idx] && typeof newAiPanels[idx].selected === 'string') ? newAiPanels[idx].selected : null + const aiSelected = + newAiPanels[idx] && typeof newAiPanels[idx].selected === 'string' + ? newAiPanels[idx].selected + : null if (aiSelected) { newSelections[idx] = aiSelected } @@ -681,7 +793,13 @@ export default function CanSrL2ScreenViewPage() { setAiPanels(newAiPanels) setPanelOpen(newPanelOpen) setHintByIndex(newHints) - }, [citation, criteriaData, sourceFlags, runsByCriterion, titleAbstractRunsByCriterion]) + }, [ + citation, + criteriaData, + sourceFlags, + runsByCriterion, + titleAbstractRunsByCriterion, + ]) // Persist human classification async function postHumanClassifyPayload( @@ -731,27 +849,51 @@ export default function CanSrL2ScreenViewPage() { if (!criteriaData) return const question = criteriaData.questions[questionIndex] const ok = await postHumanClassifyPayload(question, value) - setSaveStatus((prev) => ({ ...prev, [questionIndex]: ok ? 'saved' : 'error' })) + setSaveStatus((prev) => ({ + ...prev, + [questionIndex]: ok ? 'saved' : 'error', + })) } // Run agentic fulltext screening for a single criterion (per-question AI button) async function classifyQuestion( questionIndex: number, - context?: { srId: string; citationId: string; questions: string[]; model: string }, + context?: { + srId: string + citationId: string + questions: string[] + model: string + }, ): Promise { - const runContext = context || (srId && citationId && criteriaData - ? { srId, citationId, questions: criteriaData.questions, model: selectedModel } - : null) + const runContext = + context || + (srId && citationId && criteriaData + ? { + srId, + citationId, + questions: criteriaData.questions, + model: selectedModel, + } + : null) if (!runContext) return false const requestKey = `${runContext.srId}:${runContext.citationId}` if (currentCitationKeyRef.current !== requestKey) return false const q = runContext.questions[questionIndex] if (!q) return false setRunAllError(null) - const ck = q.trim().toLowerCase().replace(/[^\w]+/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').slice(0, 56) + const ck = q + .trim() + .toLowerCase() + .replace(/[^\w]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 56) setCriterionStatus((prev) => ({ ...prev, [questionIndex]: 'running' })) try { - const headers = { 'Content-Type': 'application/json', ...getAuthHeaders() } + const headers = { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + } const res = await fetch('/api/can-sr/screen/fulltext/run', { method: 'POST', headers, @@ -766,8 +908,16 @@ export default function CanSrL2ScreenViewPage() { }), }) const runData = await res.json().catch(() => ({})) - if (!res.ok) throw new Error(runData?.detail || runData?.error || `AI request failed (${res.status})`) - if (String(runData?.citation_id ?? runContext.citationId) !== runContext.citationId) { + if (!res.ok) + throw new Error( + runData?.detail || + runData?.error || + `AI request failed (${res.status})`, + ) + if ( + String(runData?.citation_id ?? runContext.citationId) !== + runContext.citationId + ) { throw new Error('AI response did not match the requested citation') } if (currentCitationKeyRef.current !== requestKey) return false @@ -781,20 +931,33 @@ export default function CanSrL2ScreenViewPage() { { method: 'GET', headers: getAuthHeaders() }, ) const citData = await citRes.json().catch(() => ({})) - if (currentCitationKeyRef.current === requestKey && citRes.ok && citData) { + if ( + currentCitationKeyRef.current === requestKey && + citRes.ok && + citData + ) { const llmRaw = citData[llmColName] let llmParsed = llmRaw if (typeof llmRaw === 'string') { - try { llmParsed = JSON.parse(llmRaw) } catch { llmParsed = llmRaw } + try { + llmParsed = JSON.parse(llmRaw) + } catch { + llmParsed = llmRaw + } } if (llmParsed && typeof llmParsed === 'object') { setAiPanels((prev) => ({ ...prev, [questionIndex]: llmParsed })) } } - } catch { /* best-effort */ } + } catch { + /* best-effort */ + } // Refresh agent runs (no PDF state changes) - await loadRuns({ srId: runContext.srId, citationId: runContext.citationId }) + await loadRuns({ + srId: runContext.srId, + citationId: runContext.citationId, + }) if (currentCitationKeyRef.current !== requestKey) return false setCriterionStatus((prev) => ({ ...prev, [questionIndex]: 'done' })) return true @@ -802,7 +965,9 @@ export default function CanSrL2ScreenViewPage() { console.error('Classify API error', err) if (currentCitationKeyRef.current === requestKey) { setCriterionStatus((prev) => ({ ...prev, [questionIndex]: 'error' })) - setRunAllError(err instanceof Error ? err.message : 'Full Text AI failed') + setRunAllError( + err instanceof Error ? err.message : 'Full Text AI failed', + ) } return false } @@ -811,13 +976,22 @@ export default function CanSrL2ScreenViewPage() { // Run all criteria sequentially (Run All AI button) async function runAllAI() { if (!srId || !citationId || !criteriaData || runningAll) return - const context = { srId, citationId, questions: [...criteriaData.questions], model: selectedModel } + const context = { + srId, + citationId, + questions: [...criteriaData.questions], + model: selectedModel, + } const requestKey = `${srId}:${citationId}` const total = context.questions.length setRunningAll(true) setRunAllProgress({ done: 0, total }) setRunAllError(null) - setCriterionStatus(Object.fromEntries(context.questions.map((_, index) => [index, 'queued']))) + setCriterionStatus( + Object.fromEntries( + context.questions.map((_, index) => [index, 'queued']), + ), + ) try { const failures: number[] = [] for (let index = 0; index < context.questions.length; index++) { @@ -841,7 +1015,11 @@ export default function CanSrL2ScreenViewPage() { } catch (err: any) { console.error('Run All AI error', err) setRunAllError(err?.message || 'Run All AI failed') - setCriterionStatus((prev) => Object.fromEntries(Object.keys(prev).map((key) => [Number(key), 'error']))) + setCriterionStatus((prev) => + Object.fromEntries( + Object.keys(prev).map((key) => [Number(key), 'error']), + ), + ) } } @@ -887,49 +1065,81 @@ export default function CanSrL2ScreenViewPage() { const scrollToArtifact = (kind: 'table' | 'figure', idx: number) => { const list = kind === 'table' ? parsedTables : parsedFigures const item = list.find((x: any) => Number(x?.index) === Number(idx)) - console.log('[artifact-click]', { kind, idx, hasViewer: !!viewerRef.current, item }) if (!item || !viewerRef.current) return const bbox = item?.bounding_box // We store normalized boxes as an array of {page,x,y,width,height} - const first = Array.isArray(bbox) ? bbox[0] : null - console.log('[artifact-bbox]', { kind, idx, bbox, first }) + const first = Array.isArray(bbox) + ? bbox.find( + (box: any) => + box && + typeof box === 'object' && + Number.isFinite( + Number(box?.page ?? box?.page_number ?? box?.pageNum), + ), + ) + : null if (!first) return viewerRef.current.scrollToCoord(first) } const workspace = useMemo(() => { - if (error) - return
{error}
+ if (error) return
{error}
if (loadingCitation) - return
{dict.screening.loadingCitation}
+ return ( +
+ {dict.screening.loadingCitation} +
+ ) if (!citation) - return
{dict.screening.citationNotFound}
+ return ( +
+ {dict.screening.citationNotFound} +
+ ) return (
- +
) - }, [citation, criteriaData, loadingCitation, srId, citationId, fulltextCoords, fulltextPages, fulltextStr, panelsKeyed, parsedTables, parsedFigures, dict, error]) + }, [ + citation, + criteriaData, + loadingCitation, + srId, + citationId, + fulltextCoords, + fulltextPages, + fulltextStr, + panelsKeyed, + parsedTables, + parsedFigures, + dict, + error, + ]) if (!srId || !citationId) { // guard - redirect already handled in effect but keep safe render @@ -951,23 +1161,29 @@ export default function CanSrL2ScreenViewPage() { } /> -
- +
{/* Filter/session progress bar */} {progressInfo.total > 0 && progressInfo.idx >= 0 ? (
- Progress: {progressInfo.idx + 1} / {progressInfo.total} + Progress:{' '} + {progressInfo.idx + 1} /{' '} + {progressInfo.total}
- Since start: {Math.max(0, progressInfo.idx - progressInfo.startedAt + 1)} + Since start:{' '} + + {Math.max(0, progressInfo.idx - progressInfo.startedAt + 1)} +
@@ -975,31 +1191,53 @@ export default function CanSrL2ScreenViewPage() {
{/* Workspace (left) */} -
- {workspace} -
+
{workspace}
{/* Selection sidebar (right) */}