+
+ {paramName}
+ {cleanDesc ? (
+
+ ) : null}
+
+
+
+ {saveStatus[paramName] === 'saved'
+ ? dict.extract.saved
+ : saveStatus[paramName] === 'error'
+ ? dict.common.error
+ : saveStatus[paramName] === 'saving'
? dict.extract.saving
: ''}
-
-
- {aiStatus[paramName] === 'queued'
- ? 'Queued'
- : aiStatus[paramName] === 'extracting'
- ? dict.extract.extractingFullText
- : aiStatus[paramName] === 'suggesting'
+
+
+ {aiStatus[paramName] === 'queued'
+ ? 'Queued'
+ : aiStatus[paramName] === 'extracting'
+ ? dict.extract.extractingFullText
+ : aiStatus[paramName] === 'suggesting'
? dict.extract.suggesting
: aiStatus[paramName] === 'suggested'
- ? 'Completed'
- : aiStatus[paramName] === 'error'
- ? dict.extract.aiError
- : ''}
-
-
+
+ {cleanDesc && descOpen[paramName] ? (
+
+ ) : null}
+ {options.length ? (
+
+ updateValue(paramName, e.target.value)
+ }
+ placeholder={dict.extract.enterValue}
+ className="mt-2 w-full rounded-md border px-2 py-1 text-sm"
+ />
+ )}
+
+ {aiPanels[paramName] ? (
+
+
+ setPanelOpen((prev) => ({
+ ...prev,
+ [paramName]: !Boolean(prev[paramName]),
+ }))
+ }
+ style={{ cursor: 'pointer' }}
+ className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2"
+ >
+
+ {typeof aiPanels[paramName]?.value ===
+ 'string' &&
+ aiPanels[paramName]?.value.trim().length >
+ 0 ? (
+ <>
+ {dict.extract.aiSuggested}{' '}
+
+ {aiPanels[paramName]?.value}
+
+ >
+ ) : (
+
+ {dict.extract.noAISuggestion}
-
- saveParam(paramName)}
- disabled={savingParam === paramName}
- className="rounded-md bg-emerald-600 px-2 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
- >
- {dict.common.save}
-
+ )}
+
+
+ {panelOpen[paramName]
+ ? dict.screening.minimize
+ : dict.screening.maximize}
- {cleanDesc && descOpen[paramName] ? (
-
{cleanDesc}
- ) : null}
-
updateValue(paramName, e.target.value)}
- placeholder={dict.extract.enterValue}
- className="mt-2 w-full rounded-md border px-2 py-1 text-sm"
- />
-
- {aiPanels[paramName] ? (
-
-
- setPanelOpen((prev) => ({
- ...prev,
- [paramName]: !Boolean(prev[paramName]),
- }))
- }
- style={{ cursor: 'pointer' }}
- className="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2"
- >
-
- {typeof aiPanels[paramName]?.value === 'string' && aiPanels[paramName]?.value.trim().length > 0 ? (
- <>
- {dict.extract.aiSuggested}{' '}
-
- {aiPanels[paramName]?.value}
-
- >
- ) : (
- {dict.extract.noAISuggestion}
- )}
-
-
- {panelOpen[paramName] ? dict.screening.minimize : dict.screening.maximize}
+ {panelOpen[paramName] ? (
+
+
+
+ {dict.screening.explanation}
+
+
+ {aiPanels[paramName]?.explanation ??
+ aiPanels[paramName]?.llm_raw ??
+ dict.screening.noExplanation}
- {panelOpen[paramName] ? (
-
-
-
{dict.screening.explanation}
-
- {aiPanels[paramName]?.explanation ??
- aiPanels[paramName]?.llm_raw ??
- dict.screening.noExplanation}
-
+ {Array.isArray(
+ aiPanels[paramName]?.evidence_sentences,
+ ) ? (
+
+
+ {dict.screening.evidence}
+
+
+ {aiPanels[
+ paramName
+ ].evidence_sentences.map(
+ (item: any, k: number) => {
+ const isCoord =
+ item && typeof item === 'object'
+ const label = isCoord
+ ? `${dict.screening.page} ${String(item.page ?? item.page_number ?? item.pageNum ?? '?')}${item.text ? `: ${String(item.text).slice(0, 80)}` : ''}`
+ : `${dict.screening.sentence} ${String(item)}`
+ const onClick = () => {
+ if (!viewerRef.current) return
+ if (isCoord) {
+ viewerRef.current.scrollToCoord(
+ item,
+ )
+ } else {
+ const idx = Number(item)
+ if (!Number.isNaN(idx)) {
+ viewerRef.current.scrollToSentenceIndex(
+ idx,
+ )
+ }
+ }
+ }
+ return (
+
+ {label}
+
+ )
+ },
+ )}
+
+
+ ) : null}
+
+ {Array.isArray(
+ aiPanels[paramName]?.evidence_tables,
+ ) &&
+ aiPanels[paramName].evidence_tables.length >
+ 0 ? (
+
+
Evidence tables:
+
+ {aiPanels[
+ paramName
+ ].evidence_tables.map(
+ (t: any, k: number) => {
+ const label = `Table T${String(t)}`
+ return (
+
+ scrollToArtifact(
+ 'table',
+ Number(t),
+ )
+ }
+ className="rounded border bg-gray-50 px-1.5 py-0.5 text-xs text-gray-700 hover:bg-gray-100"
+ title={label}
+ >
+ {label}
+
+ )
+ },
+ )}
+
+
+ ) : null}
+
+ {Array.isArray(
+ aiPanels[paramName]?.evidence_figures,
+ ) &&
+ aiPanels[paramName].evidence_figures
+ .length > 0 ? (
+
+
Evidence figures:
+
+ {aiPanels[
+ paramName
+ ].evidence_figures.map(
+ (f: any, k: number) => {
+ const label = `Figure F${String(f)}`
+ return (
+
+ scrollToArtifact(
+ 'figure',
+ Number(f),
+ )
+ }
+ className="rounded border bg-gray-50 px-1.5 py-0.5 text-xs text-gray-700 hover:bg-gray-100"
+ title={label}
+ >
+ {label}
+
+ )
+ },
+ )}
-{Array.isArray(aiPanels[paramName]?.evidence_sentences) ? (
-
-
{dict.screening.evidence}
-
- {aiPanels[paramName].evidence_sentences.map((item: any, k: number) => {
- const isCoord = item && typeof item === 'object'
- const label = isCoord
- ? `${dict.screening.page} ${String(item.page ?? item.page_number ?? item.pageNum ?? '?')}${item.text ? `: ${String(item.text).slice(0, 80)}` : ''}`
- : `${dict.screening.sentence} ${String(item)}`
- const onClick = () => {
- if (!viewerRef.current) return
- if (isCoord) {
- viewerRef.current.scrollToCoord(item)
- } else {
- const idx = Number(item)
- if (!Number.isNaN(idx)) {
- viewerRef.current.scrollToSentenceIndex(idx)
- }
- }
- }
- return (
-
- {label}
-
- )
- })}
-
-
-) : null}
-
-{Array.isArray(aiPanels[paramName]?.evidence_tables) && aiPanels[paramName].evidence_tables.length > 0 ? (
-
-
Evidence tables:
-
- {aiPanels[paramName].evidence_tables.map((t: any, k: number) => {
- const label = `Table T${String(t)}`
- return (
- scrollToArtifact('table', Number(t))}
- className="rounded border px-1.5 py-0.5 text-xs text-gray-700 bg-gray-50 hover:bg-gray-100"
- title={label}
- >
- {label}
-
- )
- })}
-
-
-) : null}
-
-{Array.isArray(aiPanels[paramName]?.evidence_figures) && aiPanels[paramName].evidence_figures.length > 0 ? (
-
-
Evidence figures:
-
- {aiPanels[paramName].evidence_figures.map((f: any, k: number) => {
- const label = `Figure F${String(f)}`
- return (
- scrollToArtifact('figure', Number(f))}
- className="rounded border px-1.5 py-0.5 text-xs text-gray-700 bg-gray-50 hover:bg-gray-100"
- title={label}
- >
- {label}
-
- )
- })}
-
-
-) : null}
) : null}
) : null}
- )
- })}
-
-
- ))}
+ ) : null}
+
+ )
+ })}
+
+
{dict.extract.noParameters}
)}
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 63d6f0c7..bc612c30 100644
--- a/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx
+++ b/frontend/app/[lang]/can-sr/l2-screen/view/page.tsx
@@ -640,11 +640,14 @@ export default function CanSrL2ScreenViewPage() {
// A Full Text AI panel must only exist when there is a persisted fulltext
// screening run. Never display a shared/legacy L1 llm_* value as L2 output.
if (fulltextRun && isCurrentFulltextRun) {
- const llmMatchesFulltext = llmParsed && typeof llmParsed === 'object' &&
- String((llmParsed as any).selected ?? '') === String(fulltextRun.answer ?? '') &&
- ((llmParsed as any).pipeline === 'fulltext' || !(llmParsed as any).pipeline)
+ // 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)
newAiPanels[idx] = {
- ...(llmMatchesFulltext ? llmParsed : {}),
+ ...(persistedFulltext ? llmParsed : {}),
selected: fulltextRun.answer ?? '',
confidence: fulltextRun.confidence,
explanation: fulltextRun.rationale ?? '',
@@ -896,17 +899,23 @@ export default function CanSrL2ScreenViewPage() {
srId={srId || ''}
citationId={citationId ?? ''}
conversionId={null}
- fileName={"Fulltext"}
+ fileName={String(
+ citation?.title ||
+ citation?.citation ||
+ citation?.article_title ||
+ 'Full text'
+ )}
coords={fulltextCoords || []}
pages={fulltextPages || []}
aiPanels={panelsKeyed.panels}
panelOpen={panelsKeyed.open}
+ artifacts={{ tables: parsedTables, figures: parsedFigures }}
fulltext={fulltextStr || ''}
defaultFitToWidth={true}
ref={viewerRef}
/>
)
- }, [citation, loadingCitation, srId, citationId, fulltextCoords, fulltextPages, fulltextStr, panelsKeyed, dict, error])
+ }, [citation, loadingCitation, srId, citationId, fulltextCoords, fulltextPages, fulltextStr, panelsKeyed, parsedTables, parsedFigures, dict, error])
if (!srId || !citationId) {
// guard - redirect already handled in effect but keep safe render
@@ -1050,6 +1059,18 @@ export default function CanSrL2ScreenViewPage() {
const displayExplanationRaw = (scrRationale || aiExpl || '').trim()
const displayExplanation =
extractXmlTag(displayExplanationRaw, 'rationale') || displayExplanationRaw
+ const screeningGuardrails = parseObject((scr as any)?.guardrails)
+ const missingFields = Array.isArray(aiData?.missing_fields)
+ ? aiData.missing_fields.map((field: unknown) => String(field))
+ : Array.isArray(screeningGuardrails?.missing_fields)
+ ? screeningGuardrails.missing_fields.map((field: unknown) => String(field))
+ : []
+ const missingLabels = missingFields.map((field: string) => {
+ if (field === 'answer') return 'selection'
+ if (field === 'confidence') return 'confidence'
+ if (field === 'rationale') return 'rationale'
+ return field
+ })
return (
+ {missingLabels.length > 0 ? (
+
+ The AI response was incomplete after one automatic repair. Missing:{' '}
+ {missingLabels.join(', ')}. Review the available fields and rerun this criterion.
+
+ ) : null}
{dict.screening.confidence}{' '}
- {Number.isFinite(displayConfidence)
+ {missingFields.includes('confidence')
+ ? 'Not returned by AI'
+ : Number.isFinite(displayConfidence)
? String(displayConfidence)
: String(aiData.confidence ?? '')}
{dict.screening.explanation}
- {displayExplanation || dict.screening.noExplanation}
+ {missingFields.includes('rationale')
+ ? 'Rationale was not returned by the AI after repair.'
+ : displayExplanation || dict.screening.noExplanation}
diff --git a/frontend/app/[lang]/can-sr/setup/page.tsx b/frontend/app/[lang]/can-sr/setup/page.tsx
index 43839a58..2c9875b5 100644
--- a/frontend/app/[lang]/can-sr/setup/page.tsx
+++ b/frontend/app/[lang]/can-sr/setup/page.tsx
@@ -1,20 +1,11 @@
'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
+import { useSearchParams } from 'next/navigation'
import GCHeader, { SRHeader } from '@/components/can-sr/headers'
import { authenticatedFetch, getAuthToken, getTokenType } from '@/lib/auth'
-import { SAMPLE_YAML } from '@/components/can-sr/setup/sample-yaml'
import ManageUsersPopup from '@/components/can-sr/setup/manage-users-popup'
-import {
- AlertDialog,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from '@/components/ui/alert-dialog'
+import CriteriaEditor from '@/components/can-sr/setup/criteria-editor'
import { Settings } from 'lucide-react'
import { useDictionary } from '../../DictionaryProvider'
@@ -27,7 +18,6 @@ function getAuthHeaders(): Record
{
// CSV preview removed - client-side preview disabled
export default function CanSrSetupPage() {
- const router = useRouter()
const searchParams = useSearchParams()
const srId = searchParams?.get('sr_id')
const dict = useDictionary()
@@ -38,28 +28,14 @@ export default function CanSrSetupPage() {
const [uploadResult, setUploadResult] = useState(null)
const [uploadError, setUploadError] = useState(null)
- const [yamlText, setYamlText] = useState('')
- const [yamlLoading, setYamlLoading] = useState(false)
- const [yamlSaving, setYamlSaving] = useState(false)
- const [yamlSaveMessage, setYamlSaveMessage] = useState(null)
- const [confirmOverwriteOpen, setConfirmOverwriteOpen] = useState(false)
- const [overwriteConfirmMessage, setOverwriteConfirmMessage] = useState(
- 'This SR already has screening data. Updating criteria may invalidate existing screening results.',
- )
-
+ const [, setSrLoading] = useState(true)
+ const [, setSrError] = useState(null)
+ const [sr, setSr] = useState(null)
const fileInputRef = useRef(null)
- const yamlInputRef = useRef(null)
- const yamlSaveMessageTimeoutRef = useRef(null)
-
- // auth headers and SR state for Manage Users popup (mirrors sr/page.tsx)
const token = getAuthToken()
const tokenType = getTokenType()
const authHeaders: Record | undefined = token ? { Authorization: `${tokenType} ${token}` } : undefined
- const [sr, setSr] = useState(null)
- const [, setSrLoading] = useState(true)
- const [, setSrError] = useState(null)
-
const loadSr = useCallback(async () => {
if (!srId) return null
@@ -91,73 +67,6 @@ export default function CanSrSetupPage() {
void loadSr()
}, [loadSr, srId])
- const clearYamlSaveMessageTimeout = useCallback(() => {
- if (yamlSaveMessageTimeoutRef.current !== null) {
- window.clearTimeout(yamlSaveMessageTimeoutRef.current)
- yamlSaveMessageTimeoutRef.current = null
- }
- }, [])
-
- const showYamlSaveMessage = useCallback(
- (message: string | null, autoClear = false) => {
- clearYamlSaveMessageTimeout()
- setYamlSaveMessage(message)
-
- if (message && autoClear) {
- yamlSaveMessageTimeoutRef.current = window.setTimeout(() => {
- setYamlSaveMessage(null)
- yamlSaveMessageTimeoutRef.current = null
- }, 3500)
- }
- },
- [clearYamlSaveMessageTimeout],
- )
-
- useEffect(() => {
- return () => {
- clearYamlSaveMessageTimeout()
- }
- }, [clearYamlSaveMessageTimeout])
-
- const normalizeYaml = useCallback((value: string | null | undefined) => {
- return (value || '').replace(/\r\n/g, '\n').trim()
- }, [])
-
- const getLastSavedYaml = useCallback(async () => {
- if (!srId) return SAMPLE_YAML
-
- const res = await authenticatedFetch(
- `/api/can-sr/reviews/create?sr_id=${encodeURIComponent(srId)}`,
- )
- const data = await res.json().catch(() => ({}))
- const criteria_yaml = data.criteria_yaml
-
- if (!criteria_yaml) {
- return SAMPLE_YAML
- }
-
- return criteria_yaml
- }, [srId])
-
- useEffect(() => {
- if (!srId) {
- router.replace('/can-sr')
- return
- }
- // try load project example YAML file (bundled in app)
- const loadLastSavedYaml = async () => {
- setYamlLoading(true)
- try {
- const criteria_yaml = await getLastSavedYaml()
- setYamlText(criteria_yaml || '')
- } finally {
- setYamlLoading(false)
- }
- }
-
- loadLastSavedYaml()
- }, [getLastSavedYaml, router, srId])
-
// read selected file
const handleFileSelected = async (f: File | null) => {
setUploadResult(null)
@@ -165,32 +74,7 @@ export default function CanSrSetupPage() {
setFile(f)
}
- // read uploaded YAML file and populate editor
- const handleYamlSelected = async (f: File | null) => {
- if (!f) return
- setYamlLoading(true)
- showYamlSaveMessage(null)
- try {
- const text = await f.text()
- setYamlText(text)
- // reset the file input so selecting the same file again will trigger change
- if (yamlInputRef.current) {
- try {
- ;(yamlInputRef.current as HTMLInputElement).value = ''
- } catch {
- // ignore
- }
- }
- } catch {
- showYamlSaveMessage('Failed to read YAML file')
- } finally {
- setYamlLoading(false)
- }
- }
-
- const onChooseFile = () => {
- fileInputRef.current?.click()
- }
+ const onChooseFile = () => fileInputRef.current?.click()
const handleUpload = async () => {
setUploadResult(null)
@@ -235,109 +119,6 @@ export default function CanSrSetupPage() {
const hasExistingScreeningData = Boolean((sr?.screening_db || {}).table_name)
- const persistYaml = useCallback(async (forceUpdate = false) => {
- if (!srId) {
- showYamlSaveMessage('Missing review id')
- return false
- }
-
- setYamlSaving(true)
- showYamlSaveMessage(null)
- const submittedYaml = yamlText || ''
- const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
- const timeoutId = window.setTimeout(() => {
- controller?.abort()
- }, 15000)
-
- try {
- const body = new URLSearchParams()
- body.set('criteria_yaml', submittedYaml)
- if (forceUpdate) {
- body.set('force', 'true')
- }
-
- const headers = getAuthHeaders()
- const res = await fetch(`/api/can-sr/reviews/edit?sr_id=${encodeURIComponent(srId)}`, {
- method: 'PUT',
- headers: {
- ...headers,
- 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
- },
- body: body.toString(),
- signal: controller?.signal,
- })
- if (res.status === 409 && !forceUpdate) {
- const data = await res.json().catch(() => ({}))
- setOverwriteConfirmMessage(
- data?.detail ||
- 'This SR already has screening data. Updating criteria may invalidate existing screening results.',
- )
- setConfirmOverwriteOpen(true)
- return false
- }
-
- if (!res.ok) {
- const data = await res.json().catch(() => ({}))
- showYamlSaveMessage(
- (data && (data.error || data.detail)) || `Save failed (${res.status})`,
- )
- return false
- }
-
- void loadSr()
- showYamlSaveMessage('Saved successfully', true)
- return true
- } catch (err: any) {
- const refreshedSr = await loadSr().catch(() => null)
- if (normalizeYaml(refreshedSr?.criteria_yaml) === normalizeYaml(submittedYaml)) {
- showYamlSaveMessage('Saved successfully', true)
- return true
- }
-
- if (err?.name === 'AbortError') {
- showYamlSaveMessage('Save timed out while waiting for the response. Please try again.')
- return false
- }
-
- showYamlSaveMessage(err?.message || 'Network error while saving')
- return false
- } finally {
- window.clearTimeout(timeoutId)
- setYamlSaving(false)
- }
- }, [loadSr, normalizeYaml, showYamlSaveMessage, srId, yamlText])
-
- const saveYaml = useCallback(() => {
- if (yamlSaving) {
- return
- }
-
- if (!hasExistingScreeningData) {
- void persistYaml(false)
- return
- }
-
- setOverwriteConfirmMessage(
- 'This SR already has screening data. Updating criteria may invalidate existing screening results. Do you want to overwrite the existing configuration?',
- )
- setConfirmOverwriteOpen(true)
- }, [hasExistingScreeningData, persistYaml, yamlSaving])
-
- const confirmOverwriteAndSave = useCallback(async () => {
- setConfirmOverwriteOpen(false)
- await persistYaml(true)
- }, [persistYaml])
-
- const reloadLastSave = async () => {
- setYamlLoading(true)
- try {
- const criteria_yaml = await getLastSavedYaml()
- setYamlText(criteria_yaml)
- } finally {
- setYamlLoading(false)
- }
- }
-
return (
@@ -411,129 +192,14 @@ export default function CanSrSetupPage() {
- {/* YAML editor */}
-
-
-
-
-
{dict.setup.criteriaConfigDesc}
-
-
-
- handleYamlSelected(e.target.files ? e.target.files[0] : null)}
- className="hidden"
- />
-
-
- {dict.setup.reloadLastSave}
-
-
- yamlInputRef.current?.click()}
- className="rounded-md border border-emerald-500 bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-100"
- >
- {dict.setup.uploadYAML}
-
-
-
-
-
-
-
- {/*
-
- router.push(`/can-sr/l1-screen?sr_id=${encodeURIComponent(srId || '')}`)}
- className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
- >
- Continue to Title & Abstract Screening
-
-
-
*/}
+ }
+ />
-
-
-
- Overwrite existing screening configuration?
-
- {overwriteConfirmMessage}
-
-
-
- {
- showYamlSaveMessage('Update canceled', true)
- }}
- >
- Cancel
-
- {
- void confirmOverwriteAndSave()
- }}
- disabled={yamlSaving}
- className={`rounded-md px-3 py-2 text-sm font-medium text-white ${yamlSaving ? 'bg-emerald-300' : 'bg-emerald-600 hover:bg-emerald-700'}`}
- >
- {yamlSaving ? dict.setup.saving : 'Overwrite and save'}
-
-
-
-
setManageOpen(false)}
diff --git a/frontend/app/api/can-sr/citations/full-text/route.ts b/frontend/app/api/can-sr/citations/full-text/route.ts
index 1230a499..19399dc0 100644
--- a/frontend/app/api/can-sr/citations/full-text/route.ts
+++ b/frontend/app/api/can-sr/citations/full-text/route.ts
@@ -16,9 +16,8 @@ import { BACKEND_URL } from '@/lib/config'
* -> stream the stored PDF associated with the citation (calls backend citation endpoint to resolve document_id/storage_path,
* then proxies to backend download endpoint or streams the storage URL)
*
- * - GET /api/can-sr/citations/full-text?document_id=
- * -> stream the document directly from backend download endpoint:
- * GET {BACKEND_URL}/api/files/documents/{document_id}/download
+ * - GET with sr_id, citation_id, and document_id streams a citation-scoped
+ * attachment after backend membership and linkage checks.
*
* Authentication: forwards Authorization header for all backend calls (required).
*/
@@ -56,6 +55,7 @@ export async function GET(request: NextRequest) {
const srId = params.get('sr_id')
const citationId = params.get('citation_id')
const documentIdParam = params.get('document_id')
+ const action = params.get('action')
const authHeader = request.headers.get('authorization')
if (!authHeader) {
@@ -65,6 +65,15 @@ export async function GET(request: NextRequest) {
)
}
+ if (action === 'documents' && srId && citationId) {
+ const res = await fetch(
+ `${BACKEND_URL}/api/cite/${encodeURIComponent(srId)}/citations/${encodeURIComponent(citationId)}/fulltext-documents`,
+ { headers: { Authorization: authHeader }, cache: 'no-store' },
+ )
+ const data = await res.json().catch(() => ({}))
+ return NextResponse.json(data, { status: res.status })
+ }
+
let fileFetchUrl: string | null = null
const fetchOptions: Record = {
method: 'GET',
@@ -73,11 +82,15 @@ export async function GET(request: NextRequest) {
},
}
- if (documentIdParam) {
- // Prefer direct document_id -> backend files download route
- fileFetchUrl = `${BACKEND_URL}/api/files/documents/${encodeURIComponent(
- documentIdParam,
- )}/download`
+ if (documentIdParam && srId && citationId) {
+ // Resolve inside the authorized SR/citation scope. This permits review
+ // collaborators to view attachments uploaded by another member.
+ fileFetchUrl = `${BACKEND_URL}/api/cite/${encodeURIComponent(srId)}/citations/${encodeURIComponent(citationId)}/fulltext-documents/${encodeURIComponent(documentIdParam)}/download`
+ } else if (documentIdParam) {
+ return NextResponse.json(
+ { error: 'sr_id and citation_id are required with document_id' },
+ { status: 400 },
+ )
} else if (srId && citationId) {
// Fetch the citation row to locate document_id or storage_path
const citationUrl = `${BACKEND_URL}/api/cite/${encodeURIComponent(
@@ -241,8 +254,8 @@ export async function POST(request: NextRequest) {
try {
const params = request.nextUrl.searchParams
const srId = params.get('sr_id')
+ const action = params.get('action')
const citationId = params.get('citation_id')
- const action = params.get('action') // optional; if 'extract' will call backend extract endpoint
if (!srId || !citationId) {
return NextResponse.json(
@@ -252,6 +265,23 @@ export async function POST(request: NextRequest) {
}
const authHeader = request.headers.get('authorization')
+ if (!authHeader) {
+ return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 })
+ }
+
+ if (action === 'activate') {
+ const payload = await request.json()
+ const res = await fetch(
+ `${BACKEND_URL}/api/cite/${encodeURIComponent(srId)}/citations/${encodeURIComponent(citationId)}/fulltext-documents/activate`,
+ {
+ method: 'POST',
+ headers: { Authorization: authHeader, 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ },
+ )
+ const data = await res.json().catch(() => ({}))
+ return NextResponse.json(data, { status: res.status })
+ }
// If action=extract, call backend extract endpoint which will download PDF from storage and run Grobid
if (action === 'extract') {
@@ -259,13 +289,6 @@ export async function POST(request: NextRequest) {
srId,
)}/citations/${encodeURIComponent(citationId)}/extract-fulltext`
- if (!authHeader) {
- return NextResponse.json(
- { error: 'Authorization header is required' },
- { status: 401 },
- )
- }
-
const res = await fetch(url, {
method: 'POST',
headers: {
@@ -297,16 +320,10 @@ export async function POST(request: NextRequest) {
const backendForm = new FormData()
backendForm.append('file', file)
+ const mode = params.get('mode') || 'replace'
const url = `${BACKEND_URL}/api/cite/${encodeURIComponent(
srId,
- )}/citations/${encodeURIComponent(citationId)}/upload-fulltext`
-
- if (!authHeader) {
- return NextResponse.json(
- { error: 'Authorization header is required' },
- { status: 401 },
- )
- }
+ )}/citations/${encodeURIComponent(citationId)}/upload-fulltext?mode=${encodeURIComponent(mode)}`
const res = await fetch(url, {
method: 'POST',
@@ -333,3 +350,21 @@ export async function POST(request: NextRequest) {
)
}
}
+
+export async function DELETE(request: NextRequest) {
+ const params = request.nextUrl.searchParams
+ const srId = params.get('sr_id')
+ const citationId = params.get('citation_id')
+ const documentId = params.get('document_id')
+ const authHeader = request.headers.get('authorization')
+ if (!authHeader) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 })
+ if (!srId || !citationId || !documentId) {
+ return NextResponse.json({ error: 'sr_id, citation_id and document_id are required' }, { status: 400 })
+ }
+ const res = await fetch(
+ `${BACKEND_URL}/api/cite/${encodeURIComponent(srId)}/citations/${encodeURIComponent(citationId)}/fulltext-documents/${encodeURIComponent(documentId)}`,
+ { method: 'DELETE', headers: { Authorization: authHeader } },
+ )
+ const data = await res.json().catch(() => ({}))
+ return NextResponse.json(data, { status: res.status })
+}
diff --git a/frontend/app/api/can-sr/reviews/citation-fields/route.ts b/frontend/app/api/can-sr/reviews/citation-fields/route.ts
new file mode 100644
index 00000000..7bf416fe
--- /dev/null
+++ b/frontend/app/api/can-sr/reviews/citation-fields/route.ts
@@ -0,0 +1,13 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { BACKEND_URL } from '@/lib/config'
+
+export async function GET(request: NextRequest) {
+ const srId = request.nextUrl.searchParams.get('sr_id')
+ const authorization = request.headers.get('authorization')
+ if (!srId) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 })
+ if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 })
+ const response = await fetch(`${BACKEND_URL}/api/sr/${encodeURIComponent(srId)}/citation-fields`, {
+ headers: { Authorization: authorization },
+ })
+ return NextResponse.json(await response.json().catch(() => ({})), { status: response.status })
+}
diff --git a/frontend/app/api/can-sr/reviews/criteria-config/route.ts b/frontend/app/api/can-sr/reviews/criteria-config/route.ts
new file mode 100644
index 00000000..98215af5
--- /dev/null
+++ b/frontend/app/api/can-sr/reviews/criteria-config/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { BACKEND_URL } from '@/lib/config'
+
+function target(request: NextRequest, suffix = '') {
+ const srId = request.nextUrl.searchParams.get('sr_id')
+ if (!srId) return null
+ return `${BACKEND_URL}/api/sr/${encodeURIComponent(srId)}/criteria-config${suffix}`
+}
+
+async function auth(request: NextRequest) {
+ return request.headers.get('authorization')
+}
+
+export async function GET(request: NextRequest) {
+ const downloadYaml = request.nextUrl.searchParams.get('download') === 'yaml'
+ const url = target(request, downloadYaml ? '/export-yaml' : '')
+ const authorization = await auth(request)
+ if (!url) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 })
+ if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 })
+ const response = await fetch(url, { headers: { Authorization: authorization } })
+ if (downloadYaml) {
+ return new NextResponse(await response.text(), {
+ status: response.status,
+ headers: { 'Content-Type': response.headers.get('content-type') || 'text/yaml; charset=utf-8' },
+ })
+ }
+ return NextResponse.json(await response.json().catch(() => ({})), { status: response.status })
+}
+
+export async function PUT(request: NextRequest) {
+ const url = target(request)
+ const authorization = await auth(request)
+ if (!url) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 })
+ if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 })
+ const response = await fetch(url, {
+ method: 'PUT',
+ headers: { Authorization: authorization, 'Content-Type': 'application/json' },
+ body: JSON.stringify(await request.json()),
+ })
+ return NextResponse.json(await response.json().catch(() => ({})), { status: response.status })
+}
+
+export async function POST(request: NextRequest) {
+ const operation = request.nextUrl.searchParams.get('operation')
+ const suffix = operation === 'import-yaml' ? '/import-yaml' : '/validate'
+ const url = target(request, suffix)
+ const authorization = await auth(request)
+ if (!url) return NextResponse.json({ error: 'sr_id query parameter is required' }, { status: 400 })
+ if (!authorization) return NextResponse.json({ error: 'Authorization header is required' }, { status: 401 })
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { Authorization: authorization, 'Content-Type': 'application/json' },
+ body: JSON.stringify(await request.json()),
+ })
+ return NextResponse.json(await response.json().catch(() => ({})), { status: response.status })
+}
diff --git a/frontend/components/can-sr/CitationListPage.tsx b/frontend/components/can-sr/CitationListPage.tsx
index 7b190263..1eaa2ead 100644
--- a/frontend/components/can-sr/CitationListPage.tsx
+++ b/frontend/components/can-sr/CitationListPage.tsx
@@ -86,7 +86,9 @@ export default function CitationsListPage({
// Phase 1 single-threshold is deprecated; kept for backward compatibility.
const [threshold, setThreshold] = useState(0.9)
- const [filterMode, setFilterMode] = useState<'needs' | 'validated' | 'unvalidated' | 'not_screened' | 'all'>('all')
+ const [filterMode, setFilterMode] = useState<
+ 'needs' | 'validated' | 'unvalidated' | 'not_screened' | 'all'
+ >('all')
const [filterHydrated, setFilterHydrated] = useState(false)
const filterParam = searchParams?.get('filter')
@@ -97,8 +99,18 @@ export default function CitationsListPage({
}, [srId, screeningStep])
const normalizeFilter = useCallback(
- (v: any): 'needs' | 'validated' | 'unvalidated' | 'not_screened' | 'all' | null => {
- const s = String(v || '').toLowerCase().trim()
+ (
+ v: any,
+ ):
+ | 'needs'
+ | 'validated'
+ | 'unvalidated'
+ | 'not_screened'
+ | 'all'
+ | null => {
+ const s = String(v || '')
+ .toLowerCase()
+ .trim()
if (s === 'needs') return 'needs'
if (s === 'validated') return 'validated'
if (s === 'unvalidated') return 'unvalidated'
@@ -128,7 +140,8 @@ export default function CitationsListPage({
try {
const stored = window.localStorage.getItem(filterStorageKey)
const fromStore = normalizeFilter(stored)
- if (fromStore) setFilterMode((prev) => (prev === fromStore ? prev : fromStore))
+ if (fromStore)
+ setFilterMode((prev) => (prev === fromStore ? prev : fromStore))
} catch {
// ignore
}
@@ -152,14 +165,26 @@ export default function CitationsListPage({
}
}, [srId, screeningStep, filterMode, filterStorageKey])
// page-local stats no longer shown (SR-wide progress bar is in metrics panel)
- const [_pageStats, setPageStats] = useState(undefined)
+ const [_pageStats, setPageStats] = useState<
+ ScreeningMetricsStats | undefined
+ >(undefined)
// Phase 2 metrics (SR-wide)
- const [srMetricsSummary, setSrMetricsSummary] = useState(undefined)
- const [srCriterionMetrics, setSrCriterionMetrics] = useState(undefined)
- const [srCalibration, setSrCalibration] = useState(undefined)
- const [srLiveHistogram, setSrLiveHistogram] = useState(undefined)
- const [_srThresholds, setSrThresholds] = useState | null>(null)
+ const [srMetricsSummary, setSrMetricsSummary] = useState<
+ ScreeningMetricsSummary | undefined
+ >(undefined)
+ const [srCriterionMetrics, setSrCriterionMetrics] = useState<
+ ScreeningCriterionMetrics[] | undefined
+ >(undefined)
+ const [srCalibration, setSrCalibration] = useState<
+ CalibrationCriterion[] | undefined
+ >(undefined)
+ const [srLiveHistogram, setSrLiveHistogram] = useState<
+ LiveConfidenceHistogramCriterion[] | undefined
+ >(undefined)
+ const [_srThresholds, setSrThresholds] = useState | null>(
+ null,
+ )
// Backend warnings (e.g., legacy data needs run-all)
const [srWarnings, setSrWarnings] = useState(null)
@@ -167,8 +192,10 @@ export default function CitationsListPage({
const legacyWarning = useMemo(() => {
const ws = Array.isArray(srWarnings) ? srWarnings : []
return (
- ws.find((w) => String(w?.code || '').toUpperCase() === 'LEGACY_DATA_NEEDS_RUN_ALL') ||
- null
+ ws.find(
+ (w) =>
+ String(w?.code || '').toUpperCase() === 'LEGACY_DATA_NEEDS_RUN_ALL',
+ ) || null
)
}, [srWarnings])
@@ -180,7 +207,10 @@ export default function CitationsListPage({
const [metricsDrawerOpen, setMetricsDrawerOpen] = useState(false)
// Draft editing: user can adjust thresholds locally, then click Save.
- const [draftThresholds, setDraftThresholds] = useState | null>(null)
+ const [draftThresholds, setDraftThresholds] = useState | null>(null)
const [thresholdsDirty, setThresholdsDirty] = useState(false)
const [savingThresholds, setSavingThresholds] = useState(false)
@@ -213,14 +243,21 @@ export default function CitationsListPage({
let alive = true
let wasActive = false
const refreshJobs = async () => {
- const res = await fetch('/api/can-sr/jobs/active', { headers: getAuthHeaders() })
+ const res = await fetch('/api/can-sr/jobs/active', {
+ headers: getAuthHeaders(),
+ })
const data = await res.json().catch(() => ({}))
if (!alive || !res.ok) return
const matching = (Array.isArray(data?.jobs) ? data.jobs : []).find(
- (job: any) => String(job?.sr_id) === String(srId) && job?.pipeline_key === 'pdf_linkage',
+ (job: any) =>
+ String(job?.sr_id) === String(srId) &&
+ job?.pipeline_key === 'pdf_linkage',
)
const active = Boolean(
- matching && ['queued', 'running', 'paused'].includes(String(matching.status).toLowerCase()),
+ matching &&
+ ['queued', 'running', 'paused'].includes(
+ String(matching.status).toLowerCase(),
+ ),
)
if (wasActive && !active) setCitationRefreshKey((value) => value + 1)
wasActive = active
@@ -280,7 +317,9 @@ export default function CitationsListPage({
setError(errMsg)
setCitationIds([])
} else {
- setCitationIds(Array.isArray(data?.citation_ids) ? data.citation_ids : [])
+ setCitationIds(
+ Array.isArray(data?.citation_ids) ? data.citation_ids : [],
+ )
}
} else {
// extract view still uses legacy list endpoint (server eligibility differs)
@@ -362,8 +401,12 @@ export default function CitationsListPage({
)
const tJson = await tRes.json().catch(() => ({}))
const thresholds = (tRes.ok ? tJson?.screening_thresholds : null) || {}
- setSrThresholds(typeof thresholds === 'object' && thresholds ? thresholds : {})
- setDraftThresholds(typeof thresholds === 'object' && thresholds ? thresholds : {})
+ setSrThresholds(
+ typeof thresholds === 'object' && thresholds ? thresholds : {},
+ )
+ setDraftThresholds(
+ typeof thresholds === 'object' && thresholds ? thresholds : {},
+ )
setThresholdsDirty(false)
// 2) metrics
@@ -408,7 +451,9 @@ export default function CitationsListPage({
)
const hJson = await hRes.json().catch(() => ({}))
if (hRes.ok && Array.isArray(hJson?.criteria)) {
- setSrLiveHistogram(hJson.criteria as LiveConfidenceHistogramCriterion[])
+ setSrLiveHistogram(
+ hJson.criteria as LiveConfidenceHistogramCriterion[],
+ )
} else {
setSrLiveHistogram(undefined)
}
@@ -429,7 +474,10 @@ export default function CitationsListPage({
if (!srId) return
try {
setSavingThresholds(true)
- const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' }
+ const headers = {
+ ...getAuthHeaders(),
+ 'Content-Type': 'application/json',
+ }
const res = await fetch(
`/api/can-sr/reviews/thresholds?sr_id=${encodeURIComponent(srId)}`,
{
@@ -479,7 +527,9 @@ export default function CitationsListPage({
)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
- throw new Error(data?.detail || data?.error || `Status failed (${res.status})`)
+ throw new Error(
+ data?.detail || data?.error || `Status failed (${res.status})`,
+ )
}
return data
}
@@ -509,7 +559,6 @@ export default function CitationsListPage({
}
}, [runAllJobId, runAllStorageKey, clearRunAll])
-
const hasActiveRunAll = useMemo(() => {
const st = String(runAllJob?.status || '').toLowerCase()
return !!runAllJobId && ['queued', 'running', 'paused'].includes(st)
@@ -524,9 +573,13 @@ export default function CitationsListPage({
setRunAllStarting(true)
setError(null)
try {
- const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' }
+ const headers = {
+ ...getAuthHeaders(),
+ 'Content-Type': 'application/json',
+ }
const sendExplicitIds = screeningStep === 'l1'
- const res = await fetch(`/api/can-sr/jobs/run-all/start?sr_id=${encodeURIComponent(srId)}`,
+ const res = await fetch(
+ `/api/can-sr/jobs/run-all/start?sr_id=${encodeURIComponent(srId)}`,
{
method: 'POST',
headers,
@@ -538,13 +591,15 @@ export default function CitationsListPage({
// For l1 we explicitly send the filtered list IDs (entire list, not just page).
// For l2/extract we let the backend compute eligible IDs so it can enforce
// the PDF/fulltext requirement.
- citation_ids: sendExplicitIds ? (citationIds || []) : undefined,
+ citation_ids: sendExplicitIds ? citationIds || [] : undefined,
}),
},
)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
- throw new Error(data?.detail || data?.error || `Start failed (${res.status})`)
+ throw new Error(
+ data?.detail || data?.error || `Start failed (${res.status})`,
+ )
}
// Notify floating panel to refresh once (it stops polling when empty/paused).
@@ -560,9 +615,13 @@ export default function CitationsListPage({
// If server says a job already exists, attach UI to that job and warn.
const alreadyRunning = Boolean(data?.already_running || data?.existing)
if (alreadyRunning) {
- toast.error(dict?.screening?.onlyOneJobAtATime || 'Only one job can be running at a time', {
- duration: 5000,
- })
+ toast.error(
+ dict?.screening?.onlyOneJobAtATime ||
+ 'Only one job can be running at a time',
+ {
+ duration: 5000,
+ },
+ )
}
setRunAllJobId(jid)
@@ -594,11 +653,19 @@ export default function CitationsListPage({
setPdfLinkStarting(true)
setError(null)
try {
- const res = await fetch(`/api/can-sr/jobs/pdf-linkage/start?sr_id=${encodeURIComponent(srId)}`, {
- method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: '{}',
- })
+ const res = await fetch(
+ `/api/can-sr/jobs/pdf-linkage/start?sr_id=${encodeURIComponent(srId)}`,
+ {
+ method: 'POST',
+ headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
+ body: '{}',
+ },
+ )
const data = await res.json().catch(() => ({}))
- if (!res.ok) throw new Error(data?.detail || data?.error || `Start failed (${res.status})`)
+ if (!res.ok)
+ throw new Error(
+ data?.detail || data?.error || `Start failed (${res.status})`,
+ )
setPdfLinkageActive(true)
window.dispatchEvent(new Event('jobs:changed'))
toast.success('PDF linkage started')
@@ -626,7 +693,10 @@ export default function CitationsListPage({
title={displayMap[screeningStep]}
backHref={`/can-sr/sr?sr_id=${encodeURIComponent(srId || '')}`}
right={
-
+
}
/>
@@ -639,11 +709,14 @@ export default function CitationsListPage({
Legacy screening data detected
- {String(legacyWarning?.message ||
- 'This SR has legacy llm_* outputs but no agentic runs. Please run Run-all to regenerate results.')}
+ {String(
+ legacyWarning?.message ||
+ 'This SR has legacy llm_* outputs but no agentic runs. Please run Run-all to regenerate results.',
+ )}
- Tip: when legacy data is detected, Run-all will automatically force overwrite to generate real agent runs.
+ Tip: when legacy data is detected, Run-all will automatically
+ force overwrite to generate real agent runs.
) : null}
@@ -661,10 +734,13 @@ export default function CitationsListPage({
step={screeningStep}
/>
-