diff --git a/backend/api/services/citation_export_service.py b/backend/api/services/citation_export_service.py index a59534cb..05ecccb6 100644 --- a/backend/api/services/citation_export_service.py +++ b/backend/api/services/citation_export_service.py @@ -13,7 +13,9 @@ from ..citations.export_models import ExportDimension from ..citations.export_models import ExportGroup from ..citations.export_models import ExportItem -from .cit_db_service import cits_dp_service, snake_case, snake_case_param +from .cit_db_service import cits_dp_service +from .cit_db_service import snake_case +from .cit_db_service import snake_case_param class ExportValidationError(ValueError): @@ -35,7 +37,8 @@ class ExportValidationError(ValueError): ('citation.journal', 'Journal', ('journal', 'secondary_title'), 'Journal', True), ('citation.year', 'Year', ('year', 'publication_year'), 'Year', True), ('citation.doi', 'DOI', ('doi',), 'DOI', True), - ('citation.type', 'Reference type', ('type', 'type_of_reference'), 'Reference type', True), + ('citation.type', 'Reference type', + ('type', 'type_of_reference'), 'Reference type', True), ('citation.keywords', 'Keywords', ('keywords',), 'Keywords', True), ('citation.notes', 'Notes', ('notes',), 'Notes', False), ) @@ -80,22 +83,38 @@ def _dimensions(spec) -> list[ExportDimension]: return [ExportDimension(id=i, label=label, default_selected=default) for i, label, default in spec] +def _screening_llm_candidates(stage: str, key: str) -> tuple[str, ...]: + """Return preferred-to-legacy screening AI column candidates.""" + return (f'llm_{stage}_{key}', f'llm_{key}') + + +def _screening_human_candidates(stage: str, key: str) -> tuple[str, ...]: + """Return preferred-to-legacy screening human column candidates.""" + return (f'human_{stage}_{key}', f'human_{key}') + + class CitationExportService: def build_schema( self, sr: dict[str, Any], table_name: str, columns: list[dict[str, str]] | None = None, ) -> CitationExportSchema: - columns = columns if columns is not None else cits_dp_service.get_table_columns(table_name) - existing = {str(c['column_name']).casefold(): str(c['column_name']) for c in columns} + columns = columns if columns is not None else cits_dp_service.get_table_columns( + table_name) + existing = {str(c['column_name']).casefold(): str( + c['column_name']) for c in columns} citation_items: list[ExportItem] = [] for semantic_id, label, aliases, _header, default in CITATION_FIELDS: - physical = next((existing[a.casefold()] for a in aliases if a.casefold() in existing), None) + physical = next((existing[a.casefold()] + for a in aliases if a.casefold() in existing), None) if physical and not _denied(physical): - citation_items.append(ExportItem(id=semantic_id, label=label, default_selected=default)) + citation_items.append(ExportItem( + id=semantic_id, label=label, default_selected=default)) cp = (sr or {}).get('criteria_parsed') or {} - l1 = self._screen_items('l1', (cp.get('l1') or {}).get('questions') or [], existing) - l2 = self._screen_items('l2', (cp.get('l2') or {}).get('questions') or [], existing) + l1 = self._screen_items( + 'l1', (cp.get('l1') or {}).get('questions') or [], existing) + l2 = self._screen_items( + 'l2', (cp.get('l2') or {}).get('questions') or [], existing) params = self._parameter_items(cp.get('parameters') or {}, existing) # A shared physical key in two current stages is ambiguous and must not be guessed. @@ -112,12 +131,18 @@ def build_schema( ) all_keys[key] = stage - return CitationExportSchema(groups=[ - ExportGroup(id='citation', label='Citation details', items=citation_items), - ExportGroup(id='l1', label='L1 screening', dimensions=_dimensions(SCREEN_DIMENSIONS), items=l1), - ExportGroup(id='l2', label='L2 screening', dimensions=_dimensions(SCREEN_DIMENSIONS), items=l2), - ExportGroup(id='parameters', label='Extracted parameters', dimensions=_dimensions(PARAM_DIMENSIONS), items=params), - ]) + return CitationExportSchema( + groups=[ + ExportGroup(id='citation', label='Citation details', + items=citation_items), + ExportGroup(id='l1', label='L1 screening', + dimensions=_dimensions(SCREEN_DIMENSIONS), items=l1), + ExportGroup(id='l2', label='L2 screening', + dimensions=_dimensions(SCREEN_DIMENSIONS), items=l2), + ExportGroup(id='parameters', label='Extracted parameters', + dimensions=_dimensions(PARAM_DIMENSIONS), items=params), + ], + ) def _screen_items(self, stage: str, questions: list[Any], existing: dict[str, str]) -> list[ExportItem]: items: list[ExportItem] = [] @@ -127,18 +152,22 @@ def _screen_items(self, stage: str, questions: list[Any], existing: dict[str, st continue key = screening_key(value) if key in seen: - raise ExportValidationError(f'Current {stage.upper()} questions resolve to duplicate key {key!r}') + raise ExportValidationError( + f'Current {stage.upper()} questions resolve to duplicate key {key!r}') seen.add(key) available = [] - if f'human_{key}'.casefold() in existing: + if any(candidate.casefold() in existing for candidate in _screening_human_candidates(stage, key)): available.append('human_answer') - if f'llm_{key}'.casefold() in existing: - available.extend(['ai_answer', 'ai_explanation', 'confidence', 'evidence']) - items.append(ExportItem( - id=f'{stage}.{key}', label=value, - default_selected='human_answer' in available, - available_dimensions=available, - )) + if any(candidate.casefold() in existing for candidate in _screening_llm_candidates(stage, key)): + available.extend( + ['ai_answer', 'ai_explanation', 'confidence', 'evidence']) + items.append( + ExportItem( + id=f'{stage}.{key}', label=value, + default_selected='human_answer' in available, + available_dimensions=available, + ), + ) return items def _parameter_items(self, params: dict[str, Any], existing: dict[str, str]) -> list[ExportItem]: @@ -147,23 +176,28 @@ def _parameter_items(self, params: dict[str, Any], existing: dict[str, str]) -> items: list[ExportItem] = [] seen: set[str] = set() for index, names in enumerate(possible): - category = str(categories[index]) if index < len(categories) else None + category = str(categories[index]) if index < len( + categories) else None for value in names if isinstance(names, list) else []: if not isinstance(value, str) or not value.strip(): continue key = parameter_key(value) if key in seen: - raise ExportValidationError(f'Current parameters resolve to duplicate key {key!r}') + raise ExportValidationError( + f'Current parameters resolve to duplicate key {key!r}') seen.add(key) available = [] if f'human_param_{key}'.casefold() in existing: available.append('human_value') if f'llm_param_{key}'.casefold() in existing: - available.extend(['ai_value', 'ai_explanation', 'evidence']) - items.append(ExportItem( - id=f'parameters.{key}', label=value, category=category, - available_dimensions=available, - )) + available.extend( + ['ai_value', 'ai_explanation', 'evidence']) + items.append( + ExportItem( + id=f'parameters.{key}', label=value, category=category, + available_dimensions=available, + ), + ) return items def resolve(self, schema: CitationExportSchema, request: CitationExportRequest) -> list[OutputField]: @@ -171,19 +205,24 @@ def resolve(self, schema: CitationExportSchema, request: CitationExportRequest) selected: dict[str, tuple[set[str], set[str]]] = {} for selection in request.selections: if selection.group in selected: - raise ExportValidationError(f'Duplicate selection group: {selection.group}') + raise ExportValidationError( + f'Duplicate selection group: {selection.group}') group = groups.get(selection.group) if not group: - raise ExportValidationError(f'Unknown export group: {selection.group}') + raise ExportValidationError( + f'Unknown export group: {selection.group}') valid_items = {item.id for item in group.items} valid_dimensions = {dimension.id for dimension in group.dimensions} unknown_items = set(selection.items) - valid_items unknown_dimensions = set(selection.dimensions) - valid_dimensions if unknown_items or unknown_dimensions: - raise ExportValidationError('Export selection contains stale or unknown fields') + raise ExportValidationError( + 'Export selection contains stale or unknown fields') if group.id != 'citation' and selection.items and not selection.dimensions: - raise ExportValidationError(f'{group.id} requires at least one dimension') - selected[group.id] = (set(selection.items), set(selection.dimensions)) + raise ExportValidationError( + f'{group.id} requires at least one dimension') + selected[group.id] = (set(selection.items), + set(selection.dimensions)) fields: list[OutputField] = [] for group in schema.groups: @@ -192,7 +231,8 @@ def resolve(self, schema: CitationExportSchema, request: CitationExportRequest) if item.id not in item_ids: continue if group.id == 'citation': - registry = next(row for row in CITATION_FIELDS if row[0] == item.id) + registry = next( + row for row in CITATION_FIELDS if row[0] == item.id) # build_schema proved one alias exists; resolve again from current metadata below. fields.append(OutputField(registry[3], registry[2][0])) continue @@ -200,27 +240,38 @@ def resolve(self, schema: CitationExportSchema, request: CitationExportRequest) key = item.id.split('.', 1)[1] applicable = dimensions & available if not applicable: - raise ExportValidationError(f'Requested data is unavailable for {item.id}') + raise ExportValidationError( + f'Requested data is unavailable for {item.id}') for dimension in [d.id for d in group.dimensions if d.id in applicable]: - fields.extend(self._dimension_fields(group.id, item.label, key, dimension)) + fields.extend(self._dimension_fields( + group.id, item.label, key, dimension)) if not fields: - raise ExportValidationError('At least one output field must be selected') + raise ExportValidationError( + 'At least one output field must be selected') return fields def _dimension_fields(self, group: str, label: str, key: str, dimension: str) -> list[OutputField]: if group in ('l1', 'l2'): source = 'human' if dimension == 'human_answer' else 'llm' - prop = {'human_answer': 'selected', 'ai_answer': 'selected', 'ai_explanation': 'explanation', - 'confidence': 'confidence', 'evidence': 'evidence_sentences'}[dimension] - suffix = {'human_answer': 'Human answer', 'ai_answer': 'AI answer', - 'ai_explanation': 'AI explanation', 'confidence': 'Confidence', 'evidence': 'Evidence'}[dimension] - return [OutputField(f'{group.upper()} | {label} | {suffix}', f'{source}_{key}', prop)] + prop = { + 'human_answer': 'selected', 'ai_answer': 'selected', 'ai_explanation': 'explanation', + 'confidence': 'confidence', 'evidence': 'evidence_sentences', + }[dimension] + suffix = { + 'human_answer': 'Human answer', 'ai_answer': 'AI answer', + 'ai_explanation': 'AI explanation', 'confidence': 'Confidence', 'evidence': 'Evidence', + }[dimension] + return [OutputField(f'{group.upper()} | {label} | {suffix}', f'{source}_{group}_{key}', prop)] source = 'human' if dimension == 'human_value' else 'llm' column = f'{source}_param_{key}' if dimension in ('human_value', 'ai_value'): prefix = 'Human' if source == 'human' else 'AI' - return [OutputField(f'Parameter | {label} | {prefix} found', column, 'found'), - OutputField(f'Parameter | {label} | {prefix} value', column, 'value')] + return [ + OutputField( + f'Parameter | {label} | {prefix} found', column, 'found'), + OutputField( + f'Parameter | {label} | {prefix} value', column, 'value'), + ] prop = 'explanation' if dimension == 'ai_explanation' else 'evidence_sentences' suffix = 'AI explanation' if dimension == 'ai_explanation' else 'Evidence' return [OutputField(f'Parameter | {label} | {suffix}', column, prop)] @@ -229,38 +280,59 @@ def export_csv( self, table_name: str, sr: dict[str, Any], request: CitationExportRequest, ) -> bytes: columns = cits_dp_service.get_table_columns(table_name) - actual = {str(c['column_name']).casefold(): str(c['column_name']) for c in columns} + actual = {str(c['column_name']).casefold(): str( + c['column_name']) for c in columns} schema = self.build_schema(sr, table_name, columns) fields = self.resolve(schema, request) # Resolve citation aliases and preserve only identifiers proven by current metadata. resolved = [] for field in fields: physical = actual.get(field.column.casefold()) + if not physical and field.column.startswith('human_l1_'): + legacy = f"human_{field.column.removeprefix('human_l1_')}" + physical = actual.get(legacy.casefold()) + if not physical and field.column.startswith('human_l2_'): + legacy = f"human_{field.column.removeprefix('human_l2_')}" + physical = actual.get(legacy.casefold()) + if not physical and field.column.startswith('llm_l1_'): + legacy = f"llm_{field.column.removeprefix('llm_l1_')}" + physical = actual.get(legacy.casefold()) + if not physical and field.column.startswith('llm_l2_'): + legacy = f"llm_{field.column.removeprefix('llm_l2_')}" + physical = actual.get(legacy.casefold()) if not physical and field.property is None: - registry = next(row for row in CITATION_FIELDS if row[3] == field.header) - physical = next((actual.get(alias.casefold()) for alias in registry[2] if actual.get(alias.casefold())), None) + registry = next( + row for row in CITATION_FIELDS if row[3] == field.header) + physical = next((actual.get(alias.casefold()) + for alias in registry[2] if actual.get(alias.casefold())), None) if not physical or _denied(physical): - raise ExportValidationError(f'Export field is no longer available: {field.header}') - resolved.append(OutputField(field.header, physical, field.property)) + raise ExportValidationError( + f'Export field is no longer available: {field.header}') + resolved.append(OutputField( + field.header, physical, field.property)) scope = request.row_scope if scope.kind in ('l1_included', 'l2_included'): - cits_dp_service.backfill_human_decisions((sr or {}).get('criteria_parsed') or {}, table_name) + cits_dp_service.backfill_human_decisions( + (sr or {}).get('criteria_parsed') or {}, table_name) rows = cits_dp_service.fetch_export_rows( - table_name, sorted({field.column for field in resolved}), scope.kind, + table_name, sorted( + {field.column for field in resolved}), scope.kind, scope.citation_ids, ) if scope.kind == 'citation_ids': requested_ids = set(scope.citation_ids or []) returned_ids = {int(row['id']) for row in rows} if requested_ids != returned_ids: - raise ExportValidationError('One or more citation IDs do not belong to this review') + raise ExportValidationError( + 'One or more citation IDs do not belong to this review') output = io.StringIO(newline='') writer = csv.writer(output) writer.writerow([field.header for field in resolved]) for row in rows: - writer.writerow([self._cell(row.get(field.column), field.property) for field in resolved]) + writer.writerow( + [self._cell(row.get(field.column), field.property) for field in resolved]) return output.getvalue().encode('utf-8-sig') def _cell(self, value: Any, prop: str | None) -> str: @@ -283,4 +355,4 @@ def _cell(self, value: Any, prop: str | None) -> str: return text -citation_export_service = CitationExportService() \ No newline at end of file +citation_export_service = CitationExportService() diff --git a/backend/tests/citation_export_service_test.py b/backend/tests/citation_export_service_test.py index f56a0dc0..f19ace52 100644 --- a/backend/tests/citation_export_service_test.py +++ b/backend/tests/citation_export_service_test.py @@ -32,7 +32,8 @@ def test_schema_is_current_config_allowlist_and_excludes_sensitive_columns(): schema = citation_export_service.build_schema( _sr(), 'unused', _columns( 'id', 'title', 'fulltext_url', 'prompt', - 'human_current_question', 'llm_current_question', + 'human_l1_current_question', 'llm_l1_current_question', + 'human_l2_current_full_text_question', 'llm_l2_current_full_text_question', 'human_stale_old_question', 'human_param_attack_rate', ), ) @@ -158,8 +159,8 @@ def test_parameter_dimensions_expand_in_stable_order(): def test_select_all_dimensions_uses_each_items_available_intersection(): schema = citation_export_service.build_schema( _sr(), 'unused', _columns( - 'id', 'human_current_question', - 'llm_current_full_text_question', + 'id', 'human_l1_current_question', + 'llm_l2_current_full_text_question', ), ) request = CitationExportRequest( @@ -187,7 +188,7 @@ def test_select_all_dimensions_uses_each_items_available_intersection(): def test_csv_is_ordered_normalized_and_formula_safe(monkeypatch): - columns = _columns('id', 'title', 'human_current_question') + columns = _columns('id', 'title', 'human_l1_current_question') monkeypatch.setattr( cits_dp_service, 'get_table_columns', lambda _table: columns, @@ -196,7 +197,7 @@ def test_csv_is_ordered_normalized_and_formula_safe(monkeypatch): cits_dp_service, 'fetch_export_rows', lambda *_args, **_kwargs: [{ 'id': 1, 'title': '=HYPERLINK("bad")', - 'human_current_question': {'selected': 'Include, yes'}, + 'human_l1_current_question': {'selected': 'Include, yes'}, }], ) request = CitationExportRequest( @@ -217,6 +218,70 @@ def test_csv_is_ordered_normalized_and_formula_safe(monkeypatch): assert rows[1] == ['\'=HYPERLINK("bad")', 'Include, yes'] +def test_export_uses_stage_prefixed_screening_columns(monkeypatch): + columns = _columns( + 'human_current_full_text_question', + 'human_l1_current_question', + 'llm_l1_current_question', + 'human_l2_current_full_text_question', + 'llm_l2_current_full_text_question', + ) + requested_columns = [] + monkeypatch.setattr( + cits_dp_service, 'get_table_columns', + lambda _table: columns, + ) + monkeypatch.setattr( + cits_dp_service, 'fetch_export_rows', + lambda _table, export_columns, *_args, **_kwargs: requested_columns.extend(export_columns) or [{ + 'id': 1, + 'human_l1_current_question': {'selected': 'Include'}, + 'llm_l1_current_question': {'selected': 'Include', 'confidence': 0.93}, + 'human_current_full_text_question': {'selected': 'Exclude'}, + 'human_l2_current_full_text_question': {'selected': 'Exclude'}, + 'llm_l2_current_full_text_question': { + 'selected': 'Exclude', + 'confidence': 0.81, + 'explanation': 'Full-text rationale', + 'evidence_sentences': ['Sentence 1'], + }, + }], + ) + request = CitationExportRequest( + selections=[ + { + 'group': 'l1', + 'items': ['l1.current_question'], + 'dimensions': ['human_answer', 'ai_answer'], + }, + { + 'group': 'l2', + 'items': ['l2.current_full_text_question'], + 'dimensions': ['human_answer', 'ai_answer', 'confidence'], + }, + ], + ) + + body = citation_export_service.export_csv( + 'citations', _sr(), request).decode('utf-8-sig') + rows = list(csv.reader(io.StringIO(body))) + + assert requested_columns == [ + 'human_current_full_text_question', + 'human_l2_current_full_text_question', + 'llm_l1_current_question', + 'llm_l2_current_full_text_question', + ] + assert rows[0] == [ + 'L1 | Current question? | Human answer', + 'L1 | Current question? | AI answer', + 'L2 | Current full text question? | Human answer', + 'L2 | Current full text question? | AI answer', + 'L2 | Current full text question? | Confidence', + ] + assert rows[1] == ['Include', 'Include', 'Exclude', 'Exclude', '0.81'] + + def test_citation_id_scope_must_be_entirely_owned(monkeypatch): monkeypatch.setattr( cits_dp_service, 'get_table_columns', 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 8df3f63b..8c96e94a 100644 --- a/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx +++ b/frontend/app/[lang]/can-sr/l1-screen/view/page.tsx @@ -8,7 +8,13 @@ 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, resolveDisplayedAnswer } from '@/components/can-sr/screening-citation-context' +import { + ScreeningCitationContext, + resolveConfiguredAnswerWarning, + resolveDisplayedAnswer, + resolveScreeningAnswerValue, + screeningAnswerColumn, +} from '@/components/can-sr/screening-citation-context' /* Title & Abstract single-citation viewer for L1 screening. @@ -47,15 +53,6 @@ function snakeCaseColumn(name: string) { return `llm_${s}`.slice(0, 60) } -/** - * Human classification column for screening. - * Example: question -> human_question - */ -function humanScreenColumn(name: string) { - const base = snakeCaseColumn(name) - return base.replace(/^llm_/, 'human_') -} - type ValidationEntry = { user: string; validated_at: string } function parseValidations(v: any): ValidationEntry[] { @@ -295,8 +292,8 @@ export default function CanSrL1ScreenPage() { if (!res.ok) { setError( data?.error || - data?.detail || - `Failed to load citation (${res.status})`, + data?.detail || + `Failed to load citation (${res.status})`, ) setCitation(null) } else { @@ -447,9 +444,14 @@ export default function CanSrL1ScreenPage() { criteriaData.questions.forEach((q: string, idx: number) => { const llmCol = snakeCaseColumn(q) - const humanCol = criteriaData.items?.[idx]?.answer_column || humanScreenColumn(q) + const configuredAnswerColumn = criteriaData.items?.[idx]?.answer_column - const humanRaw = resolveConfiguredValue(citation as any, humanCol) + const humanRaw = resolveScreeningAnswerValue( + citation as any, + q, + 'l1', + 'human', + ) const llmRaw = (citation as any)?.[`llm_l1_${llmCol.replace(/^llm_/, '')}`] ?? (citation as any)?.[llmCol] @@ -539,6 +541,25 @@ export default function CanSrL1ScreenPage() { if (!criteriaData) return const question = criteriaData.questions[questionIndex] const ok = await postHumanClassifyPayload(question, value) + if (ok) { + const configuredAnswerColumn = + criteriaData.items?.[questionIndex]?.answer_column + setCitation((prev) => { + if (!prev) return prev + const next = { + ...prev, + [screeningAnswerColumn(question, 'human', 'l1')]: value, + } + if ( + configuredAnswerColumn && + configuredAnswerColumn.trim().length > 0 && + !(configuredAnswerColumn in next) + ) { + next[configuredAnswerColumn] = value + } + return next + }) + } setSaveStatus((prev) => ({ ...prev, [questionIndex]: ok ? 'saved' : 'error' })) } @@ -582,23 +603,78 @@ export default function CanSrL1ScreenPage() { ) throw new Error('AI response did not match the requested citation') if (currentCitationKeyRef.current !== requestKey) return false + const criteriaResult = Array.isArray(runData?.criteria) + ? runData.criteria.find( + (item: any) => + String(item?.criterion_key || '') === ck + || String(item?.question || '') === q, + ) + : null + const immediateAiPanel = criteriaResult?.screening + ? { + selected: criteriaResult.screening.answer ?? '', + confidence: criteriaResult.screening.confidence, + explanation: criteriaResult.screening.rationale ?? '', + evidence_sentences: [], + evidence_tables: [], + evidence_figures: [], + source: 'agentic', + pipeline: 'title_abstract', + } + : null + if (immediateAiPanel) { + const configuredAnswerColumn = criteriaData?.items?.[questionIndex]?.answer_column + const displayed = resolveDisplayedAnswer( + resolveScreeningAnswerValue( + citation as any, + q, + 'l1', + 'human', + ), + immediateAiPanel, + ) + setAiPanels((prev) => ({ ...prev, [questionIndex]: immediateAiPanel })) + if (displayed) { + setSelections((prev) => ({ ...prev, [questionIndex]: displayed })) + } + } + // Targeted update: fetch only the llm_* column for this criterion and update // aiPanels directly — no full page re-render. try { - const llmColName = `llm_${ck}` + const llmColName = screeningAnswerColumn(q, 'llm', 'l1') + const configuredAnswerColumn = criteriaData?.items?.[questionIndex]?.answer_column const citRes = await fetch( `/api/can-sr/citations/get?sr_id=${encodeURIComponent(runContext.srId)}&citation_id=${encodeURIComponent(runContext.citationId)}`, { method: 'GET', headers: getAuthHeaders() }, ) const citData = await citRes.json().catch(() => ({})) if (currentCitationKeyRef.current === requestKey && citRes.ok && citData) { - const llmRaw = citData[llmColName] + const llmRaw = + resolveScreeningAnswerValue(citData, q, 'l1', 'llm', [`llm_${ck}`]) + ?? citData[llmColName] let llmParsed = llmRaw if (typeof llmRaw === 'string') { try { llmParsed = JSON.parse(llmRaw) } catch { llmParsed = llmRaw } } - if (llmParsed && typeof llmParsed === 'object') { - setAiPanels((prev) => ({ ...prev, [questionIndex]: llmParsed })) + const refreshedAiPanel = + llmParsed && typeof llmParsed === 'object' + ? llmParsed + : typeof llmParsed === 'string' && llmParsed + ? { selected: llmParsed } + : null + if (refreshedAiPanel) { + setAiPanels((prev) => ({ ...prev, [questionIndex]: refreshedAiPanel })) + const humanRaw = resolveScreeningAnswerValue( + citData, + q, + 'l1', + 'human', + ) + const displayed = resolveDisplayedAnswer(humanRaw, refreshedAiPanel) + if (displayed) { + setSelections((prev) => ({ ...prev, [questionIndex]: displayed })) + } } } } catch { /* best-effort */ } @@ -748,8 +824,16 @@ 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'}. + Fallback human answer source column “{answerWarning.column}”:{' '} + {answerWarning.status === 'missing' + ? 'not found in this citation' + : answerWarning.status === 'blank' + ? 'blank for this citation' + : 'value does not match an available answer'}.
) : null}
- Human answer column “{answerColumn}”:{' '}
- {answerStatus === 'missing'
+ Fallback human answer source column “
+ {answerWarning.column}”:{' '}
+ {answerWarning.status === 'missing'
? 'not found in this citation'
- : answerStatus === 'blank'
+ : answerWarning.status === 'blank'
? 'blank for this citation'
: 'value does not match an available answer'}
.
diff --git a/frontend/components/can-sr/PagedList.tsx b/frontend/components/can-sr/PagedList.tsx
index ec0ea8be..f94ab5a1 100644
--- a/frontend/components/can-sr/PagedList.tsx
+++ b/frontend/components/can-sr/PagedList.tsx
@@ -6,6 +6,9 @@ import { Bot, Check } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
import { SimpleTooltip} from '@/components/ui/tooltip'
+import {
+ resolveScreeningAnswerValue,
+} from '@/components/can-sr/screening-citation-context'
type CitationInfo = {
citationIds: number[]
@@ -90,6 +93,18 @@ function criterionKeyFromQuestion(question: string) {
return s.slice(0, 56)
}
+function screeningAnswerFallbackHeaders(
+ question: string,
+ step: 'l1' | 'l2',
+ prefix: 'human' | 'llm',
+) {
+ const headers: string[] = [snakeCaseColumn(question, prefix === 'llm')]
+ if (step === 'l2') {
+ headers.unshift(`${prefix}_l1_${criterionKeyFromQuestion(question)}`)
+ }
+ return headers
+}
+
export default function PagedList({
citationIds,
srId,
@@ -218,10 +233,19 @@ export default function PagedList({
let classified = true
let verified = true
for (const question of questions) {
- const llmQuestion = snakeCaseColumn(question, true)
- const humanQuestion = snakeCaseColumn(question, false)
- if (!row?.[llmQuestion]) classified = false
- if (!row?.[humanQuestion]) verified = false
+ const stage = screeningStep === 'l2' ? 'l2' : screeningStep === 'l1' ? 'l1' : null
+ const llmValue = stage
+ ? resolveScreeningAnswerValue(row, question, stage, 'llm', [
+ ...screeningAnswerFallbackHeaders(question, stage, 'llm'),
+ ])
+ : row?.[snakeCaseColumn(question, true)]
+ const humanValue = stage
+ ? resolveScreeningAnswerValue(row, question, stage, 'human', [
+ ...screeningAnswerFallbackHeaders(question, stage, 'human'),
+ ])
+ : row?.[snakeCaseColumn(question, false)]
+ if (!llmValue) classified = false
+ if (!humanValue) verified = false
}
if (classified) nextLlm[id] = true
@@ -333,8 +357,11 @@ export default function PagedList({
for (const q of questions || []) {
if (!q) continue
- const llmCol = snakeCaseColumn(q, true)
- const llmVal = row?.[llmCol]
+ const llmVal = screeningStep === 'l1' || screeningStep === 'l2'
+ ? resolveScreeningAnswerValue(row, q, screeningStep, 'llm', [
+ ...screeningAnswerFallbackHeaders(q, screeningStep, 'llm'),
+ ])
+ : row?.[snakeCaseColumn(q, true)]
if (!llmVal) continue
hasAnyLlm = true
let conf: number | null = null
@@ -427,8 +454,14 @@ export default function PagedList({
const needs = computeNeedsValidation(id, row)
const runs = latestRunsByCitation[id] || []
const notScreened = (!runs.length) && !questions.some((q) => {
- const llmCol = snakeCaseColumn(q, true)
- return Boolean(row?.[llmCol])
+ if (screeningStep === 'l1' || screeningStep === 'l2') {
+ return Boolean(
+ resolveScreeningAnswerValue(row, q, screeningStep, 'llm', [
+ ...screeningAnswerFallbackHeaders(q, screeningStep, 'llm'),
+ ]),
+ )
+ }
+ return Boolean(row?.[snakeCaseColumn(q, true)])
})
const unvalidated = !validated
if (filterMode === 'all') return true
diff --git a/frontend/components/can-sr/screening-citation-context.test.tsx b/frontend/components/can-sr/screening-citation-context.test.tsx
index 46e8326c..7966fd9d 100644
--- a/frontend/components/can-sr/screening-citation-context.test.tsx
+++ b/frontend/components/can-sr/screening-citation-context.test.tsx
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import {
humanAnswerStatus,
+ resolveScreeningAnswerValue,
resolveDisplayedAnswer,
resolveConfiguredValue,
ScreeningCitationContext,
@@ -28,6 +29,36 @@ describe('screening citation context helpers', () => {
expect(resolveDisplayedAnswer('', JSON.stringify({ selected: 'Include' }))).toBe('Include')
expect(resolveDisplayedAnswer(null, null)).toBe('')
})
+
+ it('falls back to legacy human screening columns', () => {
+ expect(
+ resolveScreeningAnswerValue(
+ { human_current_question: { selected: 'Include' } },
+ 'Current question?',
+ 'l1',
+ 'human',
+ ),
+ ).toEqual({ selected: 'Include' })
+ })
+
+ it('allows l2 human answers to fall back to l1 and legacy human columns', () => {
+ expect(
+ resolveScreeningAnswerValue(
+ { human_l1_current_full_text_question: { selected: 'Exclude' } },
+ 'Current full text question?',
+ 'l2',
+ 'human',
+ ),
+ ).toEqual({ selected: 'Exclude' })
+ expect(
+ resolveScreeningAnswerValue(
+ { human_current_full_text_question: { selected: 'Exclude' } },
+ 'Current full text question?',
+ 'l2',
+ 'human',
+ ),
+ ).toEqual({ selected: 'Exclude' })
+ })
})
describe('ScreeningCitationContext', () => {
diff --git a/frontend/components/can-sr/screening-citation-context.tsx b/frontend/components/can-sr/screening-citation-context.tsx
index 99fb4bd8..1e504116 100644
--- a/frontend/components/can-sr/screening-citation-context.tsx
+++ b/frontend/components/can-sr/screening-citation-context.tsx
@@ -28,6 +28,70 @@ export function resolveConfiguredValue(
return matches.length === 1 ? row[matches[0]] : undefined
}
+function normalizeScreeningKey(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .replace(/_+/g, '_')
+}
+
+function legacyScreeningAnswerColumn(
+ question: string,
+ prefix: 'human' | 'llm',
+) {
+ const core = normalizeScreeningKey(question)
+ if (!core) return `${prefix}_col`
+ return `${prefix}_${core}`.slice(0, 60)
+}
+
+function screeningAnswerCandidateHeaders(
+ question: string,
+ stage: 'l1' | 'l2',
+ prefix: 'human' | 'llm',
+) {
+ const headers = [screeningAnswerColumn(question, prefix, stage)]
+ if (stage === 'l2') {
+ headers.push(screeningAnswerColumn(question, prefix, 'l1'))
+ }
+ headers.push(legacyScreeningAnswerColumn(question, prefix))
+ return headers
+}
+
+export function screeningAnswerColumn(
+ question: string,
+ prefix: 'human' | 'llm',
+ stage: 'l1' | 'l2',
+) {
+ const core = normalizeScreeningKey(question).slice(0, 56)
+ if (!core) return `${prefix}_col`
+ return `${prefix}_${stage}_${core}`.slice(0, 63)
+}
+
+export function resolveScreeningAnswerValue(
+ row: Record