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..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), @@ -110,39 +129,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 +145,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 +206,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': { @@ -217,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( @@ -323,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 42bab348..02d7b18b 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 @@ -27,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: @@ -39,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. @@ -88,21 +100,27 @@ 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) 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 +132,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,9 +150,22 @@ 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 _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(): @@ -246,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) @@ -316,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'): @@ -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,9 +488,22 @@ 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 _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) --- @@ -583,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: @@ -623,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'): @@ -652,8 +677,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 +700,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,19 +799,28 @@ 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() 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 _should_skip_human_answer( + human.get('status'), + force=force, + skip_existing_human=skip_existing_human, + ): 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, @@ -820,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/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..6a0b2e6b 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 @@ -693,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 @@ -840,8 +846,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 +938,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 +971,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, @@ -1026,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: @@ -1053,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 24678512..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', @@ -46,9 +50,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/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 cd18f9de..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 @@ -426,10 +431,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,7 +477,26 @@ async def save_criteria_config( detail='This SR already has screening data. Pass force=true to confirm criteria invalidation.', ) try: - stored = criteria_configuration_service.normalize(review['criteria']) + 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, + }, + ) + 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 new file mode 100644 index 00000000..ea2f353c --- /dev/null +++ b/backend/tests/criteria_context_test.py @@ -0,0 +1,84 @@ +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 +from api.screen.router import _parse_selected_from_human_payload + + +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) + + 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 3d4a7fd0..2f122899 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,11 @@ 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'}. +

+ ) : 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..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,11 +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' type ValidationEntry = { user: string; validated_at: string } @@ -107,6 +115,12 @@ 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 = { @@ -143,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>({}) @@ -158,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) @@ -212,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({}) } @@ -259,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 } } @@ -289,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 @@ -397,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) @@ -439,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 @@ -491,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 } } @@ -519,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 { @@ -538,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 + : [], } } } @@ -551,13 +646,25 @@ 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 : []), + ] setCriteriaData({ questions: mergedQuestions, possible_answers: mergedAnswers, additional_infos: mergedInfos, + items: mergedItems, + citation_fields: parsed?.citation_fields || {}, }) setSourceFlags([ ...l1Parsed.questions.map(() => 'l1'), @@ -586,7 +693,8 @@ 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] @@ -594,10 +702,12 @@ 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 = (citation as any)?.[humanCol] + const humanRaw = resolveConfiguredValue(citation as any, humanCol) const llmRaw = (citation as any)?.[llmCol] // Parse possible JSON payloads from DB @@ -621,12 +731,16 @@ 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)) { + newSelections[idx] = extractHumanAnswer(humanParsed) } // 2) For L1 questions: show hint from prior L1 screening (llm_*), but do not prefill from it @@ -634,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 @@ -643,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 ?? '', @@ -657,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 } @@ -670,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( @@ -720,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, @@ -755,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 @@ -770,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 @@ -791,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 } @@ -800,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++) { @@ -830,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']), + ), + ) } } @@ -876,46 +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, 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 @@ -937,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)} +
@@ -961,31 +1191,53 @@ export default function CanSrL2ScreenViewPage() {
{/* Workspace (left) */} -
- {workspace} -
+
{workspace}
{/* Selection sidebar (right) */}