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() {
{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 answerColumn = + screeningAnswerColumn(q, 'human', 'l1') + const configuredAnswerColumn = + criteriaData.items?.[idx]?.answer_column || null + const answerWarning = resolveConfiguredAnswerWarning( + citation!, + answerColumn, + configuredAnswerColumn, + options, + ) const current = selections[idx] ?? '' const aiData = aiPanels[idx] // Per-question highlight aligned with list/back-end logic. @@ -792,8 +876,8 @@ export default function CanSrL1ScreenPage() {
@@ -816,9 +900,14 @@ export default function CanSrL1ScreenPage() { ))} - {answerStatus !== 'unconfigured' && answerStatus !== 'matched' ? ( + {answerWarning ? (

- 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}
@@ -908,20 +997,20 @@ export default function CanSrL1ScreenPage() {
- {hasAgentic && crit ? ( -
-
- {critDisagrees ? ( - - Critical review recommends a different answer: {String((crit as any)?.answer ?? '—')} - - ) : ( - Critical review supports the screening answer - )} -
-
Judgment confidence: {String((crit as any)?.confidence ?? '—')}
-
- ) : null} + {hasAgentic && crit ? ( +
+
+ {critDisagrees ? ( + + Critical review recommends a different answer: {String((crit as any)?.answer ?? '—')} + + ) : ( + Critical review supports the screening answer + )} +
+
Judgment confidence: {String((crit as any)?.confidence ?? '—')}
+
+ ) : null} ) : 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 40c9f273..0011de20 100644 --- a/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx +++ b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx @@ -14,9 +14,11 @@ import { needsHumanReviewForCriterion } from '@/components/can-sr/needsHumanRevi import { ScreeningCitationContext, extractHumanAnswer, - humanAnswerStatus, + resolveConfiguredAnswerWarning, resolveConfiguredValue, + resolveScreeningAnswerValue, resolveDisplayedAnswer, + screeningAnswerColumn, } from '@/components/can-sr/screening-citation-context' type ValidationEntry = { user: string; validated_at: string } @@ -102,20 +104,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_') -} - -function stageColumn(name: string, prefix: 'llm' | 'human', stage: 'l1' | 'l2') { - const core = snakeCaseColumn(name).replace(/^llm_/, '') - return `${prefix}_${stage}_${core}`.slice(0, 64) -} - /* Types */ type CriteriaData = { questions: string[] @@ -699,12 +687,9 @@ export default function CanSrL2ScreenViewPage() { criteriaData.questions.forEach((q: string, idx: number) => { const sourceStage = sourceFlags[idx] === 'l1' ? 'l1' : 'l2' - const llmCol = stageColumn(q, 'llm', 'l2') - const humanCol = stageColumn(q, 'human', 'l2') - const l1HumanCol = stageColumn(q, 'human', 'l1') + const llmCol = screeningAnswerColumn(q, 'llm', 'l2') + const l1HumanCol = screeningAnswerColumn(q, 'human', 'l1') const legacyLlmCol = snakeCaseColumn(q) - const legacyHumanCol = - criteriaData.items?.[idx]?.answer_column || humanScreenColumn(q) const criterionKey = llmCol.replace(/^llm_l2_/, '') const fulltextRun = runsByCriterion[criterionKey]?.screening const titleAbstractRun = titleAbstractRunsByCriterion[criterionKey] @@ -717,7 +702,12 @@ export default function CanSrL2ScreenViewPage() { runFulltextMd5 === citationFulltextMd5, ) - const humanRaw = resolveConfiguredValue(citation as any, humanCol) + const humanRaw = resolveScreeningAnswerValue( + citation as any, + q, + 'l2', + 'human', + ) // set-answer mirrors retrospective L1 answers into human_l2_*. The L1 // fallback keeps older rows usable and lets this screen display the // copied answer before a reviewer changes it. @@ -725,10 +715,6 @@ export default function CanSrL2ScreenViewPage() { sourceStage === 'l1' ? resolveConfiguredValue(citation as any, l1HumanCol) : undefined - const legacyHumanRaw = - sourceStage === 'l1' - ? resolveConfiguredValue(citation as any, legacyHumanCol) - : undefined const llmRaw = (citation as any)?.[llmCol] ?? (citation as any)?.[legacyLlmCol] @@ -779,8 +765,6 @@ export default function CanSrL2ScreenViewPage() { newSelections[idx] = (humanParsed as any).selected } else if (extractHumanAnswer(parsedCopiedHuman)) { newSelections[idx] = extractHumanAnswer(parsedCopiedHuman) - } else if (extractHumanAnswer(legacyHumanRaw)) { - newSelections[idx] = extractHumanAnswer(legacyHumanRaw) } else if (sourceFlags[idx] === 'l2' && extractHumanAnswer(humanParsed)) { newSelections[idx] = extractHumanAnswer(humanParsed) } @@ -821,7 +805,7 @@ export default function CanSrL2ScreenViewPage() { // The control is display-only AI fallback. A later user change still // goes through human_classify and therefore creates a human answer. const displayed = resolveDisplayedAnswer( - newSelections[idx] ?? parsedCopiedHuman ?? legacyHumanRaw, + newSelections[idx] ?? parsedCopiedHuman, newAiPanels[idx], ) if (displayed) newSelections[idx] = displayed @@ -888,6 +872,25 @@ export default function CanSrL2ScreenViewPage() { 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', 'l2')]: value, + } + if ( + configuredAnswerColumn && + configuredAnswerColumn.trim().length > 0 && + !(configuredAnswerColumn in next) + ) { + next[configuredAnswerColumn] = value + } + return next + }) + } setSaveStatus((prev) => ({ ...prev, [questionIndex]: ok ? 'saved' : 'error', @@ -964,7 +967,7 @@ export default function CanSrL2ScreenViewPage() { // Targeted update: fetch only the llm_* column for this criterion and update // aiPanels directly — avoids re-rendering the PDF viewer (no setCitation call). try { - const llmColName = `llm_${ck}` + const llmColName = screeningAnswerColumn(q, 'llm', 'l2') const citRes = await fetch( `/api/can-sr/citations/get?sr_id=${encodeURIComponent(runContext.srId)}&citation_id=${encodeURIComponent(runContext.citationId)}`, { method: 'GET', headers: getAuthHeaders() }, @@ -1304,10 +1307,13 @@ export default function CanSrL2ScreenViewPage() { {criteriaData.questions.map((q, idx) => { const options = criteriaData.possible_answers[idx] || [] const answerColumn = + screeningAnswerColumn(q, 'human', 'l2') + const configuredAnswerColumn = criteriaData.items?.[idx]?.answer_column || null - const answerStatus = humanAnswerStatus( + const answerWarning = resolveConfiguredAnswerWarning( citation!, answerColumn, + configuredAnswerColumn, options, ) const current = selections[idx] ?? '' @@ -1449,16 +1455,16 @@ export default function CanSrL2ScreenViewPage() { ))} - {answerStatus !== 'unconfigured' && - answerStatus !== 'matched' ? ( + {answerWarning ? (

- 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 | null | undefined, + question: string, + stage: 'l1' | 'l2', + prefix: 'human' | 'llm', + fallbackHeaders: Array = [], +) { + const headers = [ + ...screeningAnswerCandidateHeaders(question, stage, prefix), + ...fallbackHeaders, + ].filter( + (header): header is string => + typeof header === 'string' && header.trim().length > 0, + ) + + for (const header of headers) { + const value = resolveConfiguredValue(row, header) + if (value !== undefined) return value + } + + return undefined +} + export function extractHumanAnswer(value: any): string { if (value === undefined || value === null) return '' if (typeof value === 'object') @@ -57,8 +121,25 @@ export function humanAnswerStatus( answerColumn: string | null | undefined, options: string[], ): 'unconfigured' | 'missing' | 'blank' | 'matched' | 'unmatched' { - if (!answerColumn) return 'unconfigured' - const raw = resolveConfiguredValue(row, answerColumn) + return humanAnswerStatusForColumns(row, [answerColumn], options) +} + +export function humanAnswerStatusForColumns( + row: Record | null | undefined, + answerColumns: Array, + options: string[], +): 'unconfigured' | 'missing' | 'blank' | 'matched' | 'unmatched' { + const headers = answerColumns.filter( + (header): header is string => + typeof header === 'string' && header.trim().length > 0, + ) + if (!headers.length) return 'unconfigured' + + let raw: any = undefined + for (const header of headers) { + raw = resolveConfiguredValue(row, header) + if (raw !== undefined) break + } if (raw === undefined) return 'missing' const answer = extractHumanAnswer(raw).trim() if (!answer) return 'blank' @@ -71,6 +152,37 @@ export function humanAnswerStatus( : 'unmatched' } +export function resolveConfiguredAnswerWarning( + row: Record | null | undefined, + answerColumn: string | null | undefined, + configuredAnswerColumn: string | null | undefined, + options: string[], +): { + column: string + status: 'missing' | 'blank' | 'unmatched' +} | null { + if ( + !configuredAnswerColumn || + configuredAnswerColumn.trim().length === 0 || + configuredAnswerColumn === answerColumn + ) { + return null + } + + const status = humanAnswerStatus(row, configuredAnswerColumn, options) + if ( + status === 'unconfigured' || + status === 'matched' + ) { + return null + } + + return { + column: configuredAnswerColumn, + status, + } +} + export function ScreeningCitationContext({ citation, fields,