File size: 11,722 Bytes
eec7490
 
35a94af
 
e0fd571
35a94af
 
 
 
 
eec7490
 
2d76892
 
 
 
 
 
 
6dc650c
 
 
 
 
 
 
 
 
 
 
 
 
1d5cfba
6dc650c
 
 
 
 
 
 
 
 
9097545
6dc650c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eec7490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1865b8a
 
 
 
eec7490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d5cfba
 
eec7490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d5cfba
 
 
1865b8a
 
1d5cfba
 
eec7490
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d5cfba
 
eec7490
 
 
 
 
 
 
 
 
 
 
 
 
17c4592
 
 
 
 
 
 
 
 
 
 
eec7490
 
f8ed5d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eec7490
 
9097545
 
 
 
eec7490
 
 
6dc650c
 
 
 
 
 
 
 
f8ed5d0
6dc650c
 
 
 
 
 
 
 
 
 
 
 
f8ed5d0
6dc650c
 
 
 
021c7a8
 
 
 
f8ed5d0
021c7a8
 
 
6dc650c
 
 
 
f8ed5d0
6dc650c
 
 
 
eec7490
 
 
 
 
 
 
 
 
 
 
 
 
 
1d5cfba
eec7490
 
 
 
6dc650c
 
 
 
 
 
 
2d76892
 
 
 
 
6dc650c
 
 
 
 
 
d82da85
1d5cfba
6dc650c
 
 
 
d82da85
6dc650c
 
021c7a8
 
 
 
 
 
 
 
d82da85
021c7a8
 
 
9097545
 
 
 
 
 
 
021c7a8
6dc650c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f842a06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
const BASE_URL: string = import.meta.env.VITE_API_URL ?? ''

if (!BASE_URL && import.meta.env.PROD) {
  console.warn(
    '[IIIF-Studio] VITE_API_URL non dΓ©fini en production. ' +
    'Les appels API utiliseront des chemins relatifs, ce qui peut Γ©chouer ' +
    'si le frontend n\'est pas servi par le mΓͺme domaine que le backend.'
  )
}

// ── Types ─────────────────────────────────────────────────────────────────────

export interface ProviderInfo {
  provider_type: string
  display_name: string
  available: boolean
  model_count: number
}

export interface ModelInfo {
  model_id: string
  display_name: string
  provider: string
  supports_vision: boolean
  input_token_limit: number | null
  output_token_limit: number | null
}

export interface IngestResponse {
  corpus_id: string
  manuscript_id: string
  pages_created: number
  pages_skipped: number
  page_ids: string[]
}

export interface CorpusRunResponse {
  corpus_id: string
  jobs_created: number
  job_ids: string[]
}

export type JobStatus = 'pending' | 'claimed' | 'running' | 'done' | 'failed'

export interface Job {
  id: string
  corpus_id: string
  page_id: string | null
  status: JobStatus
  started_at: string | null
  finished_at: string | null
  error_message: string | null
  created_at: string
}

export interface CreateCorpusInput {
  slug: string
  title: string
  profile_id: string
}

export interface Corpus {
  id: string
  slug: string
  title: string
  profile_id: string
  created_at: string
  updated_at: string
}

export interface Manuscript {
  id: string
  corpus_id: string
  title: string
  shelfmark: string | null
  date_label: string | null
  total_pages: number
}

export interface Page {
  id: string
  manuscript_id: string
  folio_label: string
  sequence: number
  image_master_path: string | null
  iiif_service_url: string | null
  canvas_width: number | null
  canvas_height: number | null
  manifest_url: string | null
  processing_status: string
  confidence_summary: number | null
}

export type RegionType =
  | 'text_block'
  | 'miniature'
  | 'decorated_initial'
  | 'margin'
  | 'rubric'
  | 'other'

export interface Region {
  id: string
  type: RegionType
  bbox: [number, number, number, number]
  confidence: number
  polygon?: number[][] | null
  parent_region_id?: string | null
}

export interface OCRResult {
  diplomatic_text: string
  blocks: Record<string, unknown>[]
  lines: Record<string, unknown>[]
  language: string
  confidence: number
  uncertain_segments: string[]
}

export interface Translation {
  fr: string
  en: string
}

export interface CommentaryClaim {
  claim: string
  evidence_region_ids: string[]
  certainty: 'high' | 'medium' | 'low' | 'speculative'
}

export interface Commentary {
  public: string
  scholarly: string
  claims: CommentaryClaim[]
}

export type EditorialStatus =
  | 'machine_draft'
  | 'needs_review'
  | 'reviewed'
  | 'validated'
  | 'published'

export interface EditorialInfo {
  status: EditorialStatus
  validated: boolean
  validated_by: string | null
  version: number
  notes: string[]
}

export interface ImageInfo {
  master: string
  derivative_web?: string | null
  thumbnail?: string | null
  iiif_service_url?: string | null
  manifest_url?: string | null
  width: number
  height: number
}

export interface PageMaster {
  schema_version: string
  page_id: string
  corpus_profile: string
  manuscript_id: string
  folio_label: string
  sequence: number
  image: ImageInfo
  layout: { regions: Region[] }
  ocr: OCRResult | null
  translation: Translation | null
  summary: { short: string; detailed: string } | null
  commentary: Commentary | null
  extensions: Record<string, unknown>
  processing: Record<string, unknown> | null
  editorial: EditorialInfo
}

export interface CorpusProfile {
  profile_id: string
  label: string
  language_hints: string[]
  script_type: string
  active_layers: string[]
  uncertainty_config: { flag_below: number; min_acceptable: number }
  export_config: { mets: boolean; alto: boolean; tei: boolean }
}

// ── Errors ────────────────────────────────────────────────────────────────────

export class ApiError extends Error {
  status: number
  constructor(status: number, message: string) {
    super(message)
    this.name = 'ApiError'
    this.status = status
  }
}

// ── Fetch helpers ─────────────────────────────────────────────────────────────

/**
 * Extract a human-readable error message from a FastAPI error response.
 * FastAPI may return { detail: "string" } or { detail: [{loc, msg, type}, ...] }
 * for validation errors (422). This function handles both cases.
 */
function extractDetail(payload: unknown, fallback: string): string {
  if (!payload || typeof payload !== 'object') return fallback
  const detail = (payload as Record<string, unknown>).detail
  if (typeof detail === 'string') return detail
  if (Array.isArray(detail)) {
    // FastAPI validation error: [{loc: [...], msg: "...", type: "..."}]
    const messages = detail
      .map((e: Record<string, unknown>) => {
        const loc = Array.isArray(e.loc) ? e.loc.join(' β†’ ') : ''
        return loc ? `${loc} : ${e.msg}` : String(e.msg ?? '')
      })
      .filter(Boolean)
    return messages.length > 0 ? messages.join(' ; ') : fallback
  }
  return fallback
}

async function get<T>(path: string): Promise<T> {
  const resp = await fetch(`${BASE_URL}${path}`)
  if (!resp.ok) {
    const payload = await resp.json().catch(() => null)
    throw new ApiError(resp.status, extractDetail(payload, `HTTP ${resp.status} β€” ${path}`))
  }
  return resp.json() as Promise<T>
}

async function post<T>(path: string, body?: unknown): Promise<T> {
  const resp = await fetch(`${BASE_URL}${path}`, {
    method: 'POST',
    headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
    body: body !== undefined ? JSON.stringify(body) : undefined,
  })
  if (!resp.ok) {
    const payload = await resp.json().catch(() => null)
    throw new Error(extractDetail(payload, `HTTP ${resp.status} β€” ${path}`))
  }
  return resp.json() as Promise<T>
}

async function put<T>(path: string, body?: unknown): Promise<T> {
  const resp = await fetch(`${BASE_URL}${path}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  })
  if (!resp.ok) {
    const payload = await resp.json().catch(() => null)
    throw new Error(extractDetail(payload, `HTTP ${resp.status} β€” ${path}`))
  }
  return resp.json() as Promise<T>
}

async function del(path: string): Promise<void> {
  const resp = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' })
  if (!resp.ok) {
    const payload = await resp.json().catch(() => null)
    throw new Error(extractDetail(payload, `HTTP ${resp.status} β€” ${path}`))
  }
}

async function postForm<T>(path: string, data: FormData): Promise<T> {
  const resp = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: data })
  if (!resp.ok) {
    const payload = await resp.json().catch(() => null)
    throw new Error(extractDetail(payload, `HTTP ${resp.status} β€” ${path}`))
  }
  return resp.json() as Promise<T>
}

// ── API functions ─────────────────────────────────────────────────────────────

export const fetchCorpora = (): Promise<Corpus[]> =>
  get('/api/v1/corpora')

export const fetchManuscripts = (corpusId: string): Promise<Manuscript[]> =>
  get(`/api/v1/corpora/${corpusId}/manuscripts`)

export const fetchPages = (manuscriptId: string): Promise<Page[]> =>
  get(`/api/v1/manuscripts/${manuscriptId}/pages`)

export const fetchMasterJson = (pageId: string): Promise<PageMaster> =>
  get(`/api/v1/pages/${pageId}/master-json`)

export const fetchManifest = (manuscriptId: string): Promise<Record<string, unknown>> =>
  get(`/api/v1/manuscripts/${manuscriptId}/iiif-manifest`)

export const fetchProfile = (profileId: string): Promise<CorpusProfile> =>
  get(`/api/v1/profiles/${profileId}`)

export const listProfiles = (): Promise<CorpusProfile[]> =>
  get('/api/v1/profiles')

export const createCorpus = (input: CreateCorpusInput): Promise<Corpus> =>
  post('/api/v1/corpora', input)

export const fetchProviders = (): Promise<ProviderInfo[]> =>
  get('/api/v1/providers')

export const fetchProviderModels = (providerType: string): Promise<ModelInfo[]> =>
  get(`/api/v1/providers/${providerType}/models`)

export const selectModel = (
  corpusId: string,
  modelId: string,
  displayName: string,
  providerType: string,
  supportsVision: boolean = true,
): Promise<CorpusModelConfig> =>
  put(`/api/v1/corpora/${corpusId}/model`, {
    model_id: modelId,
    display_name: displayName,
    provider_type: providerType,
    supports_vision: supportsVision,
  })

export const deleteCorpus = (id: string): Promise<void> =>
  del(`/api/v1/corpora/${id}`)

export interface CorpusModelConfig {
  corpus_id: string
  selected_model_id: string
  selected_model_display_name: string
  provider_type: string
  supports_vision: boolean
  updated_at: string
}

export const getCorpusModel = async (corpusId: string): Promise<CorpusModelConfig | null> => {
  try {
    return await get<CorpusModelConfig>(`/api/v1/corpora/${corpusId}/model`)
  } catch {
    return null
  }
}

export const ingestImages = (
  corpusId: string,
  urls: string[],
  folioLabels: string[],
): Promise<IngestResponse> =>
  post(`/api/v1/corpora/${corpusId}/ingest/iiif-images`, {
    urls,
    folio_labels: folioLabels,
  })

export const ingestManifest = (
  corpusId: string,
  manifestUrl: string,
): Promise<IngestResponse> =>
  post(`/api/v1/corpora/${corpusId}/ingest/iiif-manifest`, {
    manifest_url: manifestUrl,
  })

export const ingestFiles = (
  corpusId: string,
  files: File[],
): Promise<IngestResponse> => {
  const data = new FormData()
  for (const f of files) data.append('files', f)
  return postForm(`/api/v1/corpora/${corpusId}/ingest/files`, data)
}

export const runCorpus = (corpusId: string): Promise<CorpusRunResponse> =>
  post(`/api/v1/corpora/${corpusId}/run`)

export const getJob = (jobId: string): Promise<Job> =>
  get(`/api/v1/jobs/${jobId}`)

export const retryJob = (jobId: string): Promise<Job> =>
  post(`/api/v1/jobs/${jobId}/retry`)

export interface VersionInfo {
  version: number
  saved_at: string
  status: string
}

export interface CorrectionsInput {
  ocr_diplomatic_text?: string
  editorial_status?: string
  commentary_public?: string
  commentary_scholarly?: string
  region_validations?: Record<string, string>
  restore_to_version?: number
}

export interface SearchResult {
  page_id: string
  folio_label: string
  manuscript_id: string
  excerpt: string
  score: number
  corpus_profile: string
}

export const fetchPage = (pageId: string): Promise<Page> =>
  get(`/api/v1/pages/${pageId}`)

export const applyCorrections = (
  pageId: string,
  corrections: CorrectionsInput,
): Promise<PageMaster> =>
  post(`/api/v1/pages/${pageId}/corrections`, corrections)

export const getHistory = (pageId: string): Promise<VersionInfo[]> =>
  get(`/api/v1/pages/${pageId}/history`)

export const searchPages = (q: string): Promise<SearchResult[]> =>
  get(`/api/v1/search?q=${encodeURIComponent(q)}`)