Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 128 additions & 56 deletions backend/api/services/citation_export_service.py

Large diffs are not rendered by default.

75 changes: 70 additions & 5 deletions backend/tests/citation_export_service_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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',
Expand Down
165 changes: 127 additions & 38 deletions frontend/app/[lang]/can-sr/l1-screen/view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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' }))
}

Expand Down Expand Up @@ -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 */ }
Expand Down Expand Up @@ -748,8 +824,16 @@ export default function CanSrL1ScreenPage() {
<div className="space-y-4">
{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.
Expand Down Expand Up @@ -792,8 +876,8 @@ export default function CanSrL1ScreenPage() {
<div
key={idx}
className={
'rounded-md border-2 p-3 ' +
(needsHuman ? 'border-amber-400' : 'border-gray-100')
'rounded-md border-2 p-3 ' +
(needsHuman ? 'border-amber-400' : 'border-gray-100')
}
>
<div className="flex items-start justify-between">
Expand All @@ -816,9 +900,14 @@ export default function CanSrL1ScreenPage() {
</option>
))}
</select>
{answerStatus !== 'unconfigured' && answerStatus !== 'matched' ? (
{answerWarning ? (
<p className="mt-1 text-xs text-amber-700" role="status">
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'}.
</p>
) : null}
</div>
Expand Down Expand Up @@ -908,20 +997,20 @@ export default function CanSrL1ScreenPage() {
</div>
</div>

{hasAgentic && crit ? (
<div className="mt-3 rounded-md border border-gray-100 bg-gray-50 p-2 text-xs text-gray-700">
<div className="mt-1 font-semibold text-gray-800">
{critDisagrees ? (
<span className="text-amber-700">
Critical review recommends a different answer: {String((crit as any)?.answer ?? '—')}
</span>
) : (
<span className="text-emerald-700">Critical review supports the screening answer</span>
)}
</div>
<div>Judgment confidence: {String((crit as any)?.confidence ?? '—')}</div>
</div>
) : null}
{hasAgentic && crit ? (
<div className="mt-3 rounded-md border border-gray-100 bg-gray-50 p-2 text-xs text-gray-700">
<div className="mt-1 font-semibold text-gray-800">
{critDisagrees ? (
<span className="text-amber-700">
Critical review recommends a different answer: {String((crit as any)?.answer ?? '—')}
</span>
) : (
<span className="text-emerald-700">Critical review supports the screening answer</span>
)}
</div>
<div>Judgment confidence: {String((crit as any)?.confidence ?? '—')}</div>
</div>
) : null}
</div>
) : null}
</div>
Expand Down
Loading
Loading