BoomQ commited on
Commit
213976d
·
verified ·
1 Parent(s): e0db2c8

Replace conflict monitor with FinRepair formula audit

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .env
5
+ local_annotations/
6
+ local_bundle/
Dockerfile DELETED
@@ -1,20 +0,0 @@
1
- FROM python:3.12-slim
2
-
3
- WORKDIR /app
4
-
5
- COPY requirements.txt ./requirements.txt
6
- RUN pip install --no-cache-dir -r requirements.txt
7
-
8
- COPY app ./app
9
- COPY sources.yaml ./sources.yaml
10
-
11
- ENV PYTHONPATH=/app
12
- ENV SOURCES_FILE=/app/sources.yaml
13
- ENV DATABASE_URL=sqlite:////app/data/dev.db
14
- ENV ALLOWED_ORIGINS=*
15
- ENV PORT=7860
16
-
17
- RUN mkdir -p /app/data
18
-
19
- EXPOSE 7860
20
- CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,13 +1,38 @@
1
  ---
2
- title: Conflict Monitor Demo
3
- emoji: 📡
4
- colorFrom: blue
5
- colorTo: red
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: false
 
 
9
  ---
10
 
11
- # Conflict Monitor Demo
12
 
13
- Public demo web app.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FinRepair Formula Audit
3
+ colorFrom: gray
4
+ colorTo: blue
5
+ sdk: gradio
6
+ sdk_version: 6.20.0
7
+ python_version: 3.12
8
+ app_file: app.py
9
  pinned: false
10
+ hf_oauth: true
11
+ hf_oauth_expiration_minutes: 43200
12
  ---
13
 
14
+ # FinRepair Formula Audit
15
 
16
+ Private, identity-aware human audit interface for the frozen Seed60 formula graph
17
+ sample. The Space contains application code only. Cases, images, and per-annotator
18
+ records live in the private `BoomQ/FinRepair-Formula-Audit` dataset repository.
19
+
20
+ ## Space configuration
21
+
22
+ - Secret: `HF_TOKEN`, with read/write access to the private dataset repository.
23
+ - Variable: `FINREPAIR_DATASET_REPO=BoomQ/FinRepair-Formula-Audit`.
24
+ - Optional variable: `ANNOTATOR_ALLOWLIST=user-a,user-b`.
25
+ - Optional variable: `FINREPAIR_DATASET_REVISION=<commit>` to pin a data revision.
26
+
27
+ Every data-loading, navigation, and saving callback checks the Hugging Face OAuth
28
+ profile independently. Annotation files are stored as
29
+ `annotations/<annotator>.json` and never bundled into the public Space repository.
30
+
31
+ ## Local development
32
+
33
+ ```bash
34
+ FINREPAIR_BUNDLE_DIR=/path/to/seed60_formula_audit_v1 \
35
+ FINREPAIR_DEV_USER=local-reviewer \
36
+ FINREPAIR_ANNOTATION_CACHE=./local_annotations \
37
+ python app.py
38
+ ```
app.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio application for the FinRepair Seed60 formula audit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ os.environ.setdefault("GRADIO_TEMP_DIR", f"/tmp/finrepair-gradio-{os.getuid()}")
12
+
13
+ import gradio as gr
14
+
15
+ from audit_core import (
16
+ CONFIDENCE_LEVELS,
17
+ DECISIONS,
18
+ ISSUE_TYPES,
19
+ AnnotationStore,
20
+ AnnotationValidationError,
21
+ AuthorizationError,
22
+ CaseBundle,
23
+ StorageError,
24
+ authorize,
25
+ build_annotation,
26
+ resolve_bundle_root,
27
+ )
28
+
29
+
30
+ BUNDLE = CaseBundle.load(
31
+ resolve_bundle_root(),
32
+ verify_images=os.getenv("FINREPAIR_VERIFY_IMAGES", "1") != "0",
33
+ )
34
+ STORE = AnnotationStore.from_environment()
35
+ CSS_PATH = Path(__file__).with_name("styles.css")
36
+ IMAGE_EXPORT_DIR = Path(os.environ["GRADIO_TEMP_DIR"]) / "selected-images"
37
+ IMAGE_EXPORT_DIR.mkdir(parents=True, exist_ok=True)
38
+
39
+
40
+ def export_image(case: dict[str, Any]) -> str:
41
+ source = Path(BUNDLE.image_path(case))
42
+ destination = IMAGE_EXPORT_DIR / f"{case['image_sha256']}.png"
43
+ if not destination.is_file():
44
+ shutil.copy2(source, destination)
45
+ return str(destination)
46
+
47
+
48
+ def proposal_rows(case: dict[str, Any]) -> list[list[Any]]:
49
+ proposal = case["proposal"]
50
+ rows = [["Parent", proposal["parent"]["row_id"], proposal["parent"]["label"], "-"]]
51
+ rows.extend(
52
+ ["Child", child["row_id"], child["label"], child["weight"]]
53
+ for child in proposal["children"]
54
+ )
55
+ return rows
56
+
57
+
58
+ def extracted_rows(case: dict[str, Any]) -> list[list[str]]:
59
+ parent_id = case["proposal"]["parent"]["row_id"]
60
+ child_ids = {child["row_id"] for child in case["proposal"]["children"]}
61
+ result = []
62
+ for row in case["rows"]:
63
+ role = "Parent" if row["row_id"] == parent_id else "Child" if row["row_id"] in child_ids else ""
64
+ result.append([row["row_id"], row["text"], role])
65
+ return result
66
+
67
+
68
+ def row_choices(case: dict[str, Any]) -> list[tuple[str, str]]:
69
+ return [(f"{row['row_id']} | {row['text']}", row["row_id"]) for row in case["rows"]]
70
+
71
+
72
+ def proposed_children(case: dict[str, Any]) -> list[list[Any]]:
73
+ return [[child["row_id"], child["weight"]] for child in case["proposal"]["children"]]
74
+
75
+
76
+ def progress_html(username: str | None, document: dict[str, Any] | None) -> str:
77
+ completed = len((document or {}).get("annotations", {}))
78
+ total = len(BUNDLE.cases)
79
+ percent = (100 * completed / total) if total else 0
80
+ user_text = html.escape(username) if username else "Not signed in"
81
+ return (
82
+ '<div class="progress-shell">'
83
+ f'<div class="progress-copy"><strong>{completed} / {total}</strong> reviewed'
84
+ f'<span>{percent:.1f}%</span><span class="annotator-name">{user_text}</span></div>'
85
+ f'<progress max="{total}" value="{completed}"></progress></div>'
86
+ )
87
+
88
+
89
+ def restore_values(case: dict[str, Any], annotation: dict[str, Any] | None) -> tuple[Any, ...]:
90
+ if not annotation:
91
+ return (
92
+ None,
93
+ [],
94
+ None,
95
+ case["proposal"]["parent"]["row_id"],
96
+ proposed_children(case),
97
+ "",
98
+ "",
99
+ )
100
+ correction = annotation.get("correction") or {}
101
+ corrected_children = correction.get("children")
102
+ child_table = (
103
+ [[child["row_id"], child["weight"]] for child in corrected_children]
104
+ if corrected_children
105
+ else proposed_children(case)
106
+ )
107
+ return (
108
+ annotation.get("decision"),
109
+ annotation.get("issues") or [],
110
+ annotation.get("confidence"),
111
+ correction.get("parent_row_id") or case["proposal"]["parent"]["row_id"],
112
+ child_table,
113
+ annotation.get("equivalence_explanation") or "",
114
+ annotation.get("notes") or "",
115
+ )
116
+
117
+
118
+ def case_view(
119
+ index: int,
120
+ username: str,
121
+ document: dict[str, Any],
122
+ status: str = "Ready",
123
+ ) -> tuple[Any, ...]:
124
+ index = max(0, min(int(index), len(BUNDLE.cases) - 1))
125
+ case = BUNDLE.cases[index]
126
+ annotation = document["annotations"].get(case["audit_id"])
127
+ decision, issues, confidence, parent, children, equivalent, notes = restore_values(
128
+ case, annotation
129
+ )
130
+ reviewed = "Reviewed" if annotation else "Not reviewed"
131
+ case_title = (
132
+ f"### Case {index + 1} of {len(BUNDLE.cases)}"
133
+ f"\n`{case['audit_id']}` | {case['relation_size']} relation terms | {reviewed}"
134
+ )
135
+ return (
136
+ index,
137
+ progress_html(username, document),
138
+ f"**{status}**",
139
+ case_title,
140
+ export_image(case),
141
+ proposal_rows(case),
142
+ extracted_rows(case),
143
+ decision,
144
+ issues,
145
+ confidence,
146
+ gr.update(choices=row_choices(case), value=parent),
147
+ children,
148
+ equivalent,
149
+ notes,
150
+ )
151
+
152
+
153
+ def locked_view(message: str) -> tuple[Any, ...]:
154
+ return (
155
+ 0,
156
+ progress_html(None, None),
157
+ f"**{message}**",
158
+ "### Sign in required",
159
+ None,
160
+ [],
161
+ [],
162
+ None,
163
+ [],
164
+ None,
165
+ gr.update(choices=[], value=None),
166
+ [["", ""]],
167
+ "",
168
+ "",
169
+ )
170
+
171
+
172
+ def load_initial(profile: gr.OAuthProfile | None) -> tuple[Any, ...]:
173
+ try:
174
+ username = authorize(profile)
175
+ document = STORE.load(username)
176
+ except (AuthorizationError, StorageError) as exc:
177
+ return locked_view(str(exc))
178
+ annotations = document["annotations"]
179
+ index = next(
180
+ (i for i, case in enumerate(BUNDLE.cases) if case["audit_id"] not in annotations),
181
+ 0,
182
+ )
183
+ return case_view(index, username, document, "Progress loaded")
184
+
185
+
186
+ def navigate(index: int, direction: str, profile: gr.OAuthProfile | None) -> tuple[Any, ...]:
187
+ try:
188
+ username = authorize(profile)
189
+ document = STORE.load(username)
190
+ except (AuthorizationError, StorageError) as exc:
191
+ return locked_view(str(exc))
192
+ current = int(index or 0)
193
+ if direction == "previous":
194
+ target = max(0, current - 1)
195
+ elif direction == "next":
196
+ target = min(len(BUNDLE.cases) - 1, current + 1)
197
+ else:
198
+ annotations = document["annotations"]
199
+ scan = list(range(current + 1, len(BUNDLE.cases))) + list(range(0, current + 1))
200
+ target = next(
201
+ (i for i in scan if BUNDLE.cases[i]["audit_id"] not in annotations),
202
+ current,
203
+ )
204
+ return case_view(target, username, document)
205
+
206
+
207
+ def go_previous(index: int, profile: gr.OAuthProfile | None) -> tuple[Any, ...]:
208
+ return navigate(index, "previous", profile)
209
+
210
+
211
+ def go_next(index: int, profile: gr.OAuthProfile | None) -> tuple[Any, ...]:
212
+ return navigate(index, "next", profile)
213
+
214
+
215
+ def go_next_unreviewed(index: int, profile: gr.OAuthProfile | None) -> tuple[Any, ...]:
216
+ return navigate(index, "unreviewed", profile)
217
+
218
+
219
+ def save_and_next(
220
+ index: int,
221
+ decision: str | None,
222
+ issues: list[str] | None,
223
+ confidence: str | None,
224
+ corrected_parent: str | None,
225
+ corrected_children: Any,
226
+ equivalence_explanation: str,
227
+ notes: str,
228
+ profile: gr.OAuthProfile | None,
229
+ ) -> tuple[Any, ...]:
230
+ try:
231
+ username = authorize(profile)
232
+ document = STORE.load(username)
233
+ current = max(0, min(int(index or 0), len(BUNDLE.cases) - 1))
234
+ case = BUNDLE.cases[current]
235
+ annotation = build_annotation(
236
+ case=case,
237
+ username=username,
238
+ decision=decision,
239
+ issues=issues,
240
+ confidence=confidence,
241
+ corrected_parent=corrected_parent,
242
+ corrected_children=corrected_children,
243
+ equivalence_explanation=equivalence_explanation,
244
+ notes=notes,
245
+ )
246
+ document["annotations"][case["audit_id"]] = annotation
247
+ synced = STORE.save(username, document)
248
+ except (AuthorizationError, AnnotationValidationError, StorageError) as exc:
249
+ raise gr.Error(str(exc)) from exc
250
+
251
+ annotations = document["annotations"]
252
+ scan = list(range(current + 1, len(BUNDLE.cases))) + list(range(0, current + 1))
253
+ target = next(
254
+ (i for i in scan if BUNDLE.cases[i]["audit_id"] not in annotations),
255
+ current,
256
+ )
257
+ status = "Saved and synced" if synced else "Saved to local development storage"
258
+ return case_view(target, username, document, status)
259
+
260
+
261
+ def build_app() -> gr.Blocks:
262
+ output_components: list[gr.Component] = []
263
+ with gr.Blocks(
264
+ title="FinRepair Formula Audit",
265
+ fill_width=True,
266
+ ) as demo:
267
+ case_index = gr.State(0)
268
+
269
+ with gr.Row(elem_id="topbar"):
270
+ gr.Markdown("# FinRepair Formula Audit", elem_id="product-title")
271
+ auth_button = gr.LoginButton(size="sm", elem_id="auth-button")
272
+
273
+ progress = gr.HTML(progress_html(None, None), elem_id="audit-progress")
274
+ status = gr.Markdown("**Waiting for sign-in**", elem_id="save-status")
275
+ case_title = gr.Markdown("### Sign in required", elem_id="case-title")
276
+
277
+ image = gr.Image(
278
+ label="Financial statement",
279
+ type="filepath",
280
+ interactive=False,
281
+ buttons=["fullscreen"],
282
+ height="min(58vh, 680px)",
283
+ elem_id="statement-image",
284
+ )
285
+
286
+ with gr.Row(equal_height=False, elem_id="workbench"):
287
+ with gr.Column(scale=6, min_width=420):
288
+ candidate = gr.Dataframe(
289
+ headers=["Role", "Row", "Label", "Weight"],
290
+ datatype=["str", "str", "str", "str"],
291
+ type="array",
292
+ interactive=False,
293
+ wrap=True,
294
+ max_height=340,
295
+ column_widths=[90, 90, "60%", 90],
296
+ label="Proposed relation",
297
+ buttons=["fullscreen", "copy"],
298
+ )
299
+ all_rows = gr.Dataframe(
300
+ headers=["Row", "Extracted label", "Candidate role"],
301
+ datatype=["str", "str", "str"],
302
+ type="array",
303
+ interactive=False,
304
+ wrap=True,
305
+ max_height=420,
306
+ column_widths=[90, "70%", 120],
307
+ show_search="search",
308
+ label="All statement rows",
309
+ buttons=["fullscreen", "copy"],
310
+ )
311
+
312
+ with gr.Column(scale=4, min_width=340, elem_id="annotation-panel"):
313
+ decision = gr.Radio(
314
+ choices=list(DECISIONS),
315
+ label="Decision",
316
+ )
317
+ confidence = gr.Radio(
318
+ choices=list(CONFIDENCE_LEVELS),
319
+ label="Confidence",
320
+ )
321
+ issues = gr.CheckboxGroup(
322
+ choices=list(ISSUE_TYPES),
323
+ label="Issues",
324
+ )
325
+ corrected_parent = gr.Dropdown(
326
+ choices=[],
327
+ label="Corrected parent",
328
+ filterable=True,
329
+ )
330
+ corrected_children = gr.Dataframe(
331
+ headers=["Row ID", "Weight"],
332
+ datatype=["str", "number"],
333
+ type="array",
334
+ interactive=True,
335
+ row_count=2,
336
+ column_widths=["65%", "35%"],
337
+ label="Corrected children",
338
+ )
339
+ equivalence = gr.Textbox(
340
+ label="Equivalence explanation",
341
+ lines=2,
342
+ max_lines=4,
343
+ )
344
+ notes = gr.Textbox(label="Notes", lines=3, max_lines=6)
345
+
346
+ with gr.Row(elem_id="navigation"):
347
+ previous_button = gr.Button("Previous", variant="secondary")
348
+ next_button = gr.Button("Next", variant="secondary")
349
+ unreviewed_button = gr.Button("Next unreviewed", variant="secondary")
350
+ save_button = gr.Button("Save and next", variant="primary")
351
+
352
+ output_components = [
353
+ case_index,
354
+ progress,
355
+ status,
356
+ case_title,
357
+ image,
358
+ candidate,
359
+ all_rows,
360
+ decision,
361
+ issues,
362
+ confidence,
363
+ corrected_parent,
364
+ corrected_children,
365
+ equivalence,
366
+ notes,
367
+ ]
368
+
369
+ demo.load(
370
+ load_initial,
371
+ inputs=None,
372
+ outputs=output_components,
373
+ show_progress="minimal",
374
+ api_visibility="private",
375
+ )
376
+ previous_button.click(
377
+ go_previous,
378
+ inputs=[case_index],
379
+ outputs=output_components,
380
+ show_progress="minimal",
381
+ api_visibility="private",
382
+ )
383
+ next_button.click(
384
+ go_next,
385
+ inputs=[case_index],
386
+ outputs=output_components,
387
+ show_progress="minimal",
388
+ api_visibility="private",
389
+ )
390
+ unreviewed_button.click(
391
+ go_next_unreviewed,
392
+ inputs=[case_index],
393
+ outputs=output_components,
394
+ show_progress="minimal",
395
+ api_visibility="private",
396
+ )
397
+ save_button.click(
398
+ save_and_next,
399
+ inputs=[
400
+ case_index,
401
+ decision,
402
+ issues,
403
+ confidence,
404
+ corrected_parent,
405
+ corrected_children,
406
+ equivalence,
407
+ notes,
408
+ ],
409
+ outputs=output_components,
410
+ show_progress="full",
411
+ api_visibility="private",
412
+ )
413
+ return demo
414
+
415
+
416
+ demo = build_app()
417
+
418
+
419
+ if __name__ == "__main__":
420
+ demo.launch(
421
+ server_name="0.0.0.0",
422
+ server_port=int(os.getenv("PORT", "7860")),
423
+ css_paths=[str(CSS_PATH)],
424
+ footer_links=[],
425
+ show_error=True,
426
+ )
app/__init__.pyc DELETED
Binary file (118 Bytes)
 
app/collectors/__init__.pyc DELETED
Binary file (129 Bytes)
 
app/collectors/base.pyc DELETED
Binary file (966 Bytes)
 
app/collectors/rss.pyc DELETED
Binary file (2.73 kB)
 
app/collectors/webpage.pyc DELETED
Binary file (3.88 kB)
 
app/config.pyc DELETED
Binary file (943 Bytes)
 
app/core/__init__.pyc DELETED
Binary file (123 Bytes)
 
app/db.pyc DELETED
Binary file (923 Bytes)
 
app/main.pyc DELETED
Binary file (19.6 kB)
 
app/models.pyc DELETED
Binary file (6.94 kB)
 
app/pipeline.pyc DELETED
Binary file (11.7 kB)
 
app/schemas.pyc DELETED
Binary file (4.65 kB)
 
app/services/__init__.pyc DELETED
Binary file (127 Bytes)
 
app/services/airports.pyc DELETED
Binary file (10.4 kB)
 
app/services/classification.pyc DELETED
Binary file (4.24 kB)
 
app/services/clustering.pyc DELETED
Binary file (2.52 kB)
 
app/services/factcheck.pyc DELETED
Binary file (13.8 kB)
 
app/services/meconf.pyc DELETED
Binary file (9.35 kB)
 
app/services/nlp.pyc DELETED
Binary file (1.51 kB)
 
app/services/scoring.pyc DELETED
Binary file (2.38 kB)
 
app/services/source_loader.pyc DELETED
Binary file (1.15 kB)
 
app/static/app.js DELETED
@@ -1,551 +0,0 @@
1
- const eventList = document.getElementById("eventList");
2
- const detail = document.getElementById("detail");
3
- const liveEventPanel = document.getElementById("liveEventPanel");
4
- const liveDetailPanel = document.getElementById("liveDetailPanel");
5
- const backToEventsBtn = document.getElementById("backToEventsBtn");
6
- const criticalList = document.getElementById("criticalList");
7
- const airportBoardBody = document.getElementById("airportBoardBody");
8
- const countryFilter = document.getElementById("countryFilter");
9
- const languageFilter = document.getElementById("languageFilter");
10
- const flightCountryFilter = document.getElementById("flightCountryFilter");
11
- const flightShowUnknown = document.getElementById("flightShowUnknown");
12
- const factcheckClaim = document.getElementById("factcheckClaim");
13
- const factcheckOptionalLangs = document.getElementById("factcheckOptionalLangs");
14
- const factcheckMaxEvidence = document.getElementById("factcheckMaxEvidence");
15
- const factcheckRunBtn = document.getElementById("factcheckRunBtn");
16
- const factcheckResult = document.getElementById("factcheckResult");
17
- const switchButtons = document.querySelectorAll(".switch-btn");
18
- let activeViewId = "liveView";
19
- let factcheckRecord = null;
20
- let factcheckSelectedLang = "all";
21
-
22
- const MIDDLE_EAST_COUNTRIES = [
23
- "Bahrain",
24
- "Egypt",
25
- "Iran",
26
- "Iraq",
27
- "Israel",
28
- "Jordan",
29
- "Kuwait",
30
- "Lebanon",
31
- "Oman",
32
- "Palestine",
33
- "Qatar",
34
- "Saudi Arabia",
35
- "Syria",
36
- "Turkey",
37
- "UAE",
38
- "Yemen",
39
- ];
40
-
41
- const DEFAULT_LANGUAGE_OPTIONS = ["en", "ar", "fa", "he", "tr", "ckb", "ku", "ru"];
42
- const LANGUAGE_LABELS = {
43
- en: "English",
44
- ar: "Arabic",
45
- fa: "Persian",
46
- he: "Hebrew",
47
- tr: "Turkish",
48
- ckb: "Kurdish (Sorani)",
49
- ku: "Kurdish (Kurmanji)",
50
- ru: "Russian",
51
- fr: "French",
52
- it: "Italian",
53
- da: "Danish",
54
- };
55
-
56
- const CATEGORY_LABELS = {
57
- airport_flights: "airport/flights",
58
- us_base_activity: "US base activity",
59
- bombardment: "bombardment",
60
- general: "general",
61
- };
62
-
63
- async function api(path, options = {}) {
64
- const res = await fetch(path, options);
65
- if (!res.ok) {
66
- const text = await res.text();
67
- throw new Error(text || `Request failed: ${res.status}`);
68
- }
69
- return res.json();
70
- }
71
-
72
- function toPlainText(input) {
73
- if (!input) return "";
74
- const node = document.createElement("div");
75
- node.innerHTML = input;
76
- return (node.textContent || node.innerText || "").trim();
77
- }
78
-
79
- function escapeHtml(input) {
80
- if (!input) return "";
81
- return String(input)
82
- .replaceAll("&", "&amp;")
83
- .replaceAll("<", "&lt;")
84
- .replaceAll(">", "&gt;")
85
- .replaceAll('"', "&quot;")
86
- .replaceAll("'", "&#39;");
87
- }
88
-
89
- function normalizeLang(value) {
90
- if (!value) return "unknown";
91
- return String(value).trim().toLowerCase() || "unknown";
92
- }
93
-
94
- function languageLabel(code) {
95
- const normalized = normalizeLang(code);
96
- if (normalized === "unknown") return "Unknown";
97
- return LANGUAGE_LABELS[normalized] || normalized.toUpperCase();
98
- }
99
-
100
- function statusTag(status) {
101
- return `<span class="tag ${status}">${status}</span>`;
102
- }
103
-
104
- function categoryTag(category) {
105
- const label = CATEGORY_LABELS[category] || category;
106
- return `<span class="tag category">${label}</span>`;
107
- }
108
-
109
- function signalTag(signal) {
110
- return `<span class="tag signal-${signal}">${signal}</span>`;
111
- }
112
-
113
- function flightTag(status) {
114
- return `<span class="tag flight-${status}">${status}</span>`;
115
- }
116
-
117
- function sourceTag(sourceType) {
118
- const value = sourceType || "unknown";
119
- return `<span class="tag source-${value}">${value}</span>`;
120
- }
121
-
122
- function trustTag(level) {
123
- const value = level || "low";
124
- return `<span class="tag trust-${value}">${value}</span>`;
125
- }
126
-
127
- function showEventListPanel() {
128
- liveEventPanel.classList.remove("hidden");
129
- liveDetailPanel.classList.add("hidden");
130
- }
131
-
132
- function showEventDetailPanel() {
133
- liveEventPanel.classList.add("hidden");
134
- liveDetailPanel.classList.remove("hidden");
135
- }
136
-
137
- function eventLine(event) {
138
- return `
139
- <li data-id="${event.id}">
140
- <div><strong>${event.title}</strong></div>
141
- <small>${sourceTag(event.source_type)} ${trustTag(event.trust_level)}</small>
142
- </li>
143
- `;
144
- }
145
-
146
- function criticalCard(item) {
147
- if (!item.event_id) {
148
- return `
149
- <div class="critical-item">
150
- <h3>${item.label}</h3>
151
- <div>${signalTag(item.signal)}</div>
152
- <p>No recent event for current filter.</p>
153
- </div>
154
- `;
155
- }
156
-
157
- return `
158
- <div class="critical-item">
159
- <h3>${item.label}</h3>
160
- <div>${signalTag(item.signal)} ${statusTag(item.status || "unverified")}</div>
161
- <p><strong>${item.title}</strong></p>
162
- <p>${toPlainText(item.summary)}</p>
163
- <small>${item.country || "-"} | ${new Date(item.last_seen_at).toLocaleString()} | conf=${(item.confidence ?? 0).toFixed(2)}</small>
164
- </div>
165
- `;
166
- }
167
-
168
- function setCountryOptions(countries) {
169
- const list = countries && countries.length ? countries : MIDDLE_EAST_COUNTRIES;
170
- const merged = ["", ...list];
171
- const html = merged
172
- .map((country) => {
173
- const label = country || "All";
174
- const value = country;
175
- return `<option value="${value}">${label}</option>`;
176
- })
177
- .join("");
178
-
179
- countryFilter.innerHTML = html;
180
- flightCountryFilter.innerHTML = html;
181
- }
182
-
183
- function setLanguageOptions(languages) {
184
- const list = languages && languages.length ? languages : DEFAULT_LANGUAGE_OPTIONS;
185
- const merged = ["", ...list];
186
- const html = merged
187
- .map((lang) => {
188
- const label = lang ? languageLabel(lang) : "All Languages";
189
- const value = lang;
190
- return `<option value="${value}">${label}</option>`;
191
- })
192
- .join("");
193
- languageFilter.innerHTML = html;
194
- }
195
-
196
- async function loadMeta() {
197
- try {
198
- const data = await api("/api/meta");
199
- setCountryOptions(data.countries || []);
200
- setLanguageOptions(data.languages || []);
201
- } catch (err) {
202
- console.error(err);
203
- setCountryOptions([]);
204
- setLanguageOptions([]);
205
- }
206
- }
207
-
208
- async function loadCritical() {
209
- const country = countryFilter.value;
210
- const params = new URLSearchParams();
211
- if (country) params.set("country", country);
212
-
213
- const data = await api(`/api/critical?${params.toString()}`);
214
- criticalList.innerHTML = data.map(criticalCard).join("");
215
- }
216
-
217
- function airportRow(item) {
218
- const updated = item.last_seen_at ? new Date(item.last_seen_at).toLocaleString() : "-";
219
- const links = Array.isArray(item.source_links) ? item.source_links : [];
220
- let source = "-";
221
-
222
- if (links.length > 0) {
223
- source = links
224
- .map((entry) => {
225
- if (entry.url) {
226
- return `<a href="${entry.url}" target="_blank" rel="noreferrer">${entry.label}</a>`;
227
- }
228
- return `<span>${entry.label}</span>`;
229
- })
230
- .join("<br/>");
231
- } else if (item.source_url) {
232
- source = `<a href="${item.source_url}" target="_blank" rel="noreferrer">${item.source_note || "source"}</a>`;
233
- } else if (item.source_note) {
234
- source = item.source_note;
235
- }
236
-
237
- return `
238
- <tr>
239
- <td>${item.country}</td>
240
- <td>${item.city}</td>
241
- <td>${item.name}</td>
242
- <td>${item.iata}</td>
243
- <td>${flightTag(item.status)}</td>
244
- <td>${updated}</td>
245
- <td>${source}</td>
246
- </tr>
247
- `;
248
- }
249
-
250
- async function loadAirportBoard() {
251
- const selected = flightCountryFilter.value || countryFilter.value;
252
- const showUnknown = flightShowUnknown.checked;
253
- const params = new URLSearchParams();
254
- if (selected) params.set("country", selected);
255
- const data = await api(`/api/airports/status?${params.toString()}`);
256
- const visible = showUnknown
257
- ? data
258
- : data.filter((row) => row.status !== "unknown");
259
- airportBoardBody.innerHTML = visible.map(airportRow).join("");
260
-
261
- if (visible.length === 0) {
262
- airportBoardBody.innerHTML = `
263
- <tr>
264
- <td colspan="7">${showUnknown ? "No airport status rows for current filters." : "No non-unknown airport rows for current filters."}</td>
265
- </tr>
266
- `;
267
- }
268
- }
269
-
270
- async function loadEvents() {
271
- const status = document.getElementById("statusFilter").value;
272
- const category = document.getElementById("categoryFilter").value;
273
- const country = countryFilter.value;
274
- const language = languageFilter.value;
275
-
276
- const params = new URLSearchParams();
277
- if (status) params.set("status", status);
278
- if (category) params.set("category", category);
279
- if (country) params.set("country", country);
280
- if (language) params.set("language", language);
281
- params.set("limit", "100");
282
-
283
- const data = await api(`/api/events?${params.toString()}`);
284
- eventList.innerHTML = data.map(eventLine).join("");
285
- showEventListPanel();
286
- detail.innerHTML = "<p>Select an event to inspect supporting sources.</p>";
287
-
288
- for (const li of eventList.querySelectorAll("li")) {
289
- li.addEventListener("click", () => {
290
- showEventDetailPanel();
291
- loadEventDetail(li.dataset.id);
292
- });
293
- }
294
- }
295
-
296
- function renderFactcheckResult(record) {
297
- factcheckRecord = record;
298
- const claimStruct = record.claim_struct || {};
299
- const result = record.result || {};
300
- const signals = result.signals || {};
301
- const languages = Array.isArray(record.languages) ? record.languages : [];
302
- const evidence = Array.isArray(record.evidence) ? record.evidence : [];
303
- const langCounts = {};
304
- for (const row of evidence) {
305
- const lang = normalizeLang(row.lang);
306
- langCounts[lang] = (langCounts[lang] || 0) + 1;
307
- }
308
-
309
- const availableLangs = Object.keys(langCounts).sort();
310
- if (factcheckSelectedLang !== "all" && !availableLangs.includes(factcheckSelectedLang)) {
311
- factcheckSelectedLang = "all";
312
- }
313
-
314
- const filteredEvidence =
315
- factcheckSelectedLang === "all"
316
- ? evidence
317
- : evidence.filter((row) => normalizeLang(row.lang) === factcheckSelectedLang);
318
-
319
- const languageTags = languages
320
- .map((lang) => `<span class="tag category">${escapeHtml(languageLabel(lang))}</span>`)
321
- .join("");
322
- const languageCards = [
323
- `<button type="button" class="lang-card ${factcheckSelectedLang === "all" ? "active" : ""}" data-lang="all">All (${evidence.length})</button>`,
324
- ...availableLangs.map((lang) => {
325
- const count = langCounts[lang];
326
- const active = factcheckSelectedLang === lang ? "active" : "";
327
- return `<button type="button" class="lang-card ${active}" data-lang="${escapeHtml(lang)}">${escapeHtml(languageLabel(lang))} (${count})</button>`;
328
- }),
329
- ].join("");
330
-
331
- const claimStructHtml = `
332
- <div class="factcheck-grid">
333
- <div><strong>actor</strong><div>${escapeHtml(claimStruct.actor || "-")}</div></div>
334
- <div><strong>location</strong><div>${escapeHtml(claimStruct.location || "-")}</div></div>
335
- <div><strong>time</strong><div>${escapeHtml(claimStruct.time || "-")}</div></div>
336
- <div><strong>action</strong><div>${escapeHtml(claimStruct.action || "-")}</div></div>
337
- <div><strong>object</strong><div>${escapeHtml(claimStruct.object || "-")}</div></div>
338
- <div><strong>casualty</strong><div>${escapeHtml(claimStruct.casualty || "-")}</div></div>
339
- </div>
340
- `;
341
-
342
- const evidenceRows = filteredEvidence
343
- .map((row) => {
344
- const when = row.published_at ? new Date(row.published_at).toLocaleString() : "-";
345
- const title = escapeHtml(row.title || "-");
346
- const snippet = escapeHtml(row.snippet || "-");
347
- const lang = escapeHtml(languageLabel(row.lang || "unknown"));
348
- const sourceType = escapeHtml(row.source_type || "news");
349
- const stance = escapeHtml(row.stance || "unclear");
350
- const url = escapeHtml(row.url || "#");
351
- return `
352
- <tr>
353
- <td>${lang}</td>
354
- <td>${sourceType}</td>
355
- <td>${stance}</td>
356
- <td>${title}<div class="muted">${snippet}</div></td>
357
- <td>${when}</td>
358
- <td><a href="${url}" target="_blank" rel="noreferrer">source</a></td>
359
- </tr>
360
- `;
361
- })
362
- .join("");
363
-
364
- const evidenceTable = evidenceRows
365
- ? `
366
- <div class="table-wrap">
367
- <table>
368
- <thead>
369
- <tr>
370
- <th>Lang</th>
371
- <th>Source</th>
372
- <th>Stance</th>
373
- <th>Evidence</th>
374
- <th>Published</th>
375
- <th>Link</th>
376
- </tr>
377
- </thead>
378
- <tbody>${evidenceRows}</tbody>
379
- </table>
380
- </div>
381
- `
382
- : "<p>No matching evidence found for the selected language in current local dataset.</p>";
383
-
384
- factcheckResult.innerHTML = `
385
- <div class="factcheck-summary">
386
- <p><strong>Claim ID:</strong> ${escapeHtml(record.claim_id || "-")}</p>
387
- <p><strong>Claim:</strong> ${escapeHtml(record.claim_text || "-")}</p>
388
- <p><strong>Languages:</strong> ${languageTags || "-"}</p>
389
- ${claimStructHtml}
390
- <p>
391
- <strong>Credibility:</strong> ${(result.credibility_score ?? 0).toFixed(3)}
392
- ${statusTag(result.verification_level || "unverified")}
393
- </p>
394
- <p><strong>Signals:</strong>
395
- source_diversity=${signals.source_diversity ?? 0},
396
- language_diversity=${signals.language_diversity ?? 0},
397
- time_geo_consistency=${signals.time_geo_consistency ?? 0},
398
- contradiction_count=${signals.contradiction_count ?? 0}
399
- </p>
400
- <p>${escapeHtml(result.notes || "")}</p>
401
- </div>
402
- <h3>Evidence by Original Language</h3>
403
- <div class="lang-cards">${languageCards}</div>
404
- <h3>Evidence</h3>
405
- ${evidenceTable}
406
- `;
407
-
408
- for (const btn of factcheckResult.querySelectorAll(".lang-card")) {
409
- btn.addEventListener("click", () => {
410
- factcheckSelectedLang = btn.dataset.lang || "all";
411
- renderFactcheckResult(factcheckRecord);
412
- });
413
- }
414
- }
415
-
416
- async function runFactcheck() {
417
- const claimText = (factcheckClaim.value || "").trim();
418
- if (claimText.length < 5) {
419
- factcheckResult.innerHTML = "<p>Please enter a claim with at least 5 characters.</p>";
420
- return;
421
- }
422
-
423
- const payload = {
424
- claim_text: claimText,
425
- include_optional_languages: factcheckOptionalLangs.checked,
426
- max_evidence: Number(factcheckMaxEvidence.value || 8),
427
- };
428
-
429
- factcheckRunBtn.disabled = true;
430
- factcheckRunBtn.textContent = "Running...";
431
- try {
432
- factcheckSelectedLang = "all";
433
- const data = await api("/api/factcheck", {
434
- method: "POST",
435
- headers: { "Content-Type": "application/json" },
436
- body: JSON.stringify(payload),
437
- });
438
- renderFactcheckResult(data);
439
- } catch (err) {
440
- factcheckResult.innerHTML = `<p>Error: ${escapeHtml(err.message)}</p>`;
441
- } finally {
442
- factcheckRunBtn.disabled = false;
443
- factcheckRunBtn.textContent = "Run Fact Check";
444
- }
445
- }
446
-
447
- async function loadEventDetail(id) {
448
- const data = await api(`/api/events/${id}`);
449
- const evidenceHtml = data.evidence
450
- .map(
451
- (e) => `
452
- <div class="evidence-item">
453
- <div>${statusTag(e.stance === "supports" ? "confirmed" : "conflicting")} <strong>${e.source_name}</strong> (${e.source_priority})</div>
454
- <div>${new Date(e.published_at).toLocaleString()} | score=${(e.final_score ?? 0).toFixed(2)}</div>
455
- <div>${e.title}</div>
456
- <a href="${e.source_url}" target="_blank" rel="noreferrer">source link</a>
457
- </div>
458
- `
459
- )
460
- .join("");
461
-
462
- detail.innerHTML = `
463
- <h3>${data.title}</h3>
464
- <p>${statusTag(data.status)} ${categoryTag(data.category)} ${toPlainText(data.summary)}</p>
465
- <p><small>${data.country || "-"} | ${data.actor || "-"} | confidence=${data.confidence.toFixed(2)}</small></p>
466
- ${evidenceHtml || "<p>No evidence yet.</p>"}
467
- `;
468
- }
469
-
470
- async function bindActions() {
471
- document.getElementById("refreshBtn").addEventListener("click", refreshActiveView);
472
- countryFilter.addEventListener("change", refreshActiveView);
473
- document.getElementById("statusFilter").addEventListener("change", () => {
474
- if (activeViewId === "liveView") loadEvents();
475
- });
476
- document.getElementById("categoryFilter").addEventListener("change", () => {
477
- if (activeViewId === "liveView") loadEvents();
478
- });
479
- languageFilter.addEventListener("change", () => {
480
- if (activeViewId === "liveView") loadEvents();
481
- });
482
- flightCountryFilter.addEventListener("change", () => {
483
- if (activeViewId === "flightView") loadAirportBoard();
484
- });
485
- flightShowUnknown.addEventListener("change", () => {
486
- if (activeViewId === "flightView") loadAirportBoard();
487
- });
488
- backToEventsBtn.addEventListener("click", showEventListPanel);
489
- factcheckRunBtn.addEventListener("click", runFactcheck);
490
-
491
- document.getElementById("bootstrapBtn").addEventListener("click", async () => {
492
- await api("/api/bootstrap", { method: "POST" });
493
- await refreshActiveView();
494
- });
495
-
496
- document.getElementById("ingestBtn").addEventListener("click", async () => {
497
- await api("/api/ingest/run", { method: "POST" });
498
- await refreshActiveView();
499
- });
500
-
501
- for (const btn of switchButtons) {
502
- btn.addEventListener("click", () => activateView(btn.dataset.view));
503
- }
504
-
505
- await loadMeta();
506
- flightShowUnknown.checked = false;
507
- flightCountryFilter.value = countryFilter.value;
508
- await activateView(activeViewId);
509
- }
510
-
511
- function activateButton(viewId) {
512
- for (const button of switchButtons) {
513
- button.classList.toggle("active", button.dataset.view === viewId);
514
- }
515
- }
516
-
517
- function activateSection(viewId) {
518
- for (const view of document.querySelectorAll(".view")) {
519
- view.classList.toggle("active", view.id === viewId);
520
- }
521
- }
522
-
523
- async function activateView(viewId) {
524
- activeViewId = viewId;
525
- activateButton(viewId);
526
- activateSection(viewId);
527
- await refreshActiveView();
528
- }
529
-
530
- async function refreshActiveView() {
531
- if (activeViewId === "flightView") {
532
- await loadAirportBoard();
533
- return;
534
- }
535
-
536
- if (activeViewId === "signalView") {
537
- await loadCritical();
538
- return;
539
- }
540
-
541
- if (activeViewId === "factcheckView") {
542
- return;
543
- }
544
-
545
- await loadEvents();
546
- }
547
-
548
- bindActions().catch((err) => {
549
- console.error(err);
550
- detail.innerHTML = `<p>Error: ${err.message}</p>`;
551
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/static/styles.css DELETED
@@ -1,521 +0,0 @@
1
- :root {
2
- --bg: #fcf8ef;
3
- --ink: #1f2937;
4
- --primary: #0f766e;
5
- --accent: #b45309;
6
- --panel: rgba(255, 255, 255, 0.82);
7
- --border: rgba(15, 118, 110, 0.25);
8
- }
9
-
10
- * {
11
- box-sizing: border-box;
12
- }
13
-
14
- body {
15
- margin: 0;
16
- font-family: "IBM Plex Sans", "Helvetica Neue", sans-serif;
17
- color: var(--ink);
18
- background: radial-gradient(circle at 10% 10%, #fef3c7 0%, var(--bg) 55%);
19
- min-height: 100vh;
20
- position: relative;
21
- }
22
-
23
- .bg-shape {
24
- position: fixed;
25
- z-index: -1;
26
- border-radius: 999px;
27
- filter: blur(40px);
28
- opacity: 0.5;
29
- }
30
-
31
- .bg-a {
32
- width: 260px;
33
- height: 260px;
34
- background: #14b8a6;
35
- top: 40px;
36
- right: 10%;
37
- }
38
-
39
- .bg-b {
40
- width: 320px;
41
- height: 320px;
42
- background: #f59e0b;
43
- bottom: 40px;
44
- left: 5%;
45
- }
46
-
47
- .hero,
48
- main {
49
- width: min(1100px, 92vw);
50
- margin: 0 auto;
51
- }
52
-
53
- .hero {
54
- padding: 32px 0 18px;
55
- }
56
-
57
- .hero h1 {
58
- margin: 0;
59
- font-size: clamp(1.8rem, 4vw, 2.8rem);
60
- }
61
-
62
- .actions {
63
- display: flex;
64
- gap: 10px;
65
- margin-top: 14px;
66
- }
67
-
68
- .top-actions {
69
- align-items: end;
70
- flex-wrap: wrap;
71
- }
72
-
73
- .top-actions label {
74
- display: flex;
75
- flex-direction: column;
76
- gap: 6px;
77
- font-size: 0.88rem;
78
- }
79
-
80
- .top-actions select {
81
- border: 1px solid #d1d5db;
82
- border-radius: 8px;
83
- padding: 7px;
84
- min-width: 160px;
85
- }
86
-
87
- .view-switch {
88
- margin-top: 14px;
89
- display: flex;
90
- gap: 8px;
91
- flex-wrap: wrap;
92
- }
93
-
94
- .switch-btn {
95
- border: 1px solid var(--border);
96
- border-radius: 999px;
97
- padding: 7px 12px;
98
- background: rgba(255, 255, 255, 0.85);
99
- }
100
-
101
- .switch-btn.active {
102
- background: var(--primary);
103
- color: #fff;
104
- border-color: var(--primary);
105
- }
106
-
107
- button {
108
- border: 1px solid var(--border);
109
- background: white;
110
- color: var(--ink);
111
- border-radius: 10px;
112
- padding: 8px 12px;
113
- cursor: pointer;
114
- }
115
-
116
- button:hover {
117
- border-color: var(--primary);
118
- }
119
-
120
- .filters {
121
- display: grid;
122
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
123
- gap: 10px;
124
- background: var(--panel);
125
- border: 1px solid var(--border);
126
- border-radius: 12px;
127
- padding: 12px;
128
- }
129
-
130
- .view {
131
- display: none;
132
- }
133
-
134
- .view.active {
135
- display: block;
136
- }
137
-
138
- .critical {
139
- background: var(--panel);
140
- border: 1px solid var(--border);
141
- border-radius: 12px;
142
- padding: 12px;
143
- margin-bottom: 12px;
144
- }
145
-
146
- .flight-board {
147
- background: var(--panel);
148
- border: 1px solid var(--border);
149
- border-radius: 12px;
150
- padding: 12px;
151
- margin-bottom: 12px;
152
- }
153
-
154
- .factcheck {
155
- background: var(--panel);
156
- border: 1px solid var(--border);
157
- border-radius: 12px;
158
- padding: 12px;
159
- margin-bottom: 12px;
160
- }
161
-
162
- .critical .note {
163
- margin: 6px 0 10px;
164
- font-size: 0.9rem;
165
- }
166
-
167
- .flight-board .note {
168
- margin: 6px 0 10px;
169
- font-size: 0.9rem;
170
- }
171
-
172
- .factcheck .note {
173
- margin: 6px 0 10px;
174
- font-size: 0.9rem;
175
- }
176
-
177
- .flight-controls {
178
- display: flex;
179
- gap: 12px;
180
- align-items: end;
181
- flex-wrap: wrap;
182
- margin-bottom: 10px;
183
- }
184
-
185
- .flight-controls label {
186
- display: flex;
187
- flex-direction: column;
188
- gap: 6px;
189
- font-size: 0.88rem;
190
- }
191
-
192
- .flight-controls select {
193
- border: 1px solid #d1d5db;
194
- border-radius: 8px;
195
- padding: 7px;
196
- min-width: 180px;
197
- }
198
-
199
- .flight-controls .check {
200
- flex-direction: row;
201
- align-items: center;
202
- gap: 6px;
203
- margin-bottom: 2px;
204
- }
205
-
206
- .factcheck-form label {
207
- display: flex;
208
- flex-direction: column;
209
- gap: 6px;
210
- font-size: 0.88rem;
211
- }
212
-
213
- .factcheck-form textarea,
214
- .factcheck-controls select {
215
- border: 1px solid #d1d5db;
216
- border-radius: 8px;
217
- padding: 9px;
218
- width: 100%;
219
- }
220
-
221
- .factcheck-controls {
222
- margin-top: 10px;
223
- display: flex;
224
- gap: 12px;
225
- flex-wrap: wrap;
226
- align-items: end;
227
- }
228
-
229
- .factcheck-controls .check {
230
- flex-direction: row;
231
- align-items: center;
232
- gap: 6px;
233
- margin-bottom: 4px;
234
- }
235
-
236
- .factcheck-result {
237
- margin-top: 14px;
238
- border-top: 1px solid #e5e7eb;
239
- padding-top: 12px;
240
- }
241
-
242
- .factcheck-grid {
243
- margin: 10px 0;
244
- display: grid;
245
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
246
- gap: 8px;
247
- }
248
-
249
- .factcheck-grid > div {
250
- border: 1px solid #e5e7eb;
251
- border-radius: 8px;
252
- background: rgba(255, 255, 255, 0.7);
253
- padding: 8px;
254
- }
255
-
256
- .lang-cards {
257
- display: flex;
258
- flex-wrap: wrap;
259
- gap: 8px;
260
- margin: 8px 0 10px;
261
- }
262
-
263
- .lang-card {
264
- border: 1px solid #cbd5e1;
265
- background: #ffffff;
266
- color: #1f2937;
267
- border-radius: 999px;
268
- padding: 6px 10px;
269
- font-size: 0.82rem;
270
- cursor: pointer;
271
- }
272
-
273
- .lang-card:hover {
274
- border-color: var(--primary);
275
- }
276
-
277
- .lang-card.active {
278
- background: var(--primary);
279
- color: #fff;
280
- border-color: var(--primary);
281
- }
282
-
283
- .muted {
284
- color: #64748b;
285
- font-size: 0.84rem;
286
- margin-top: 4px;
287
- }
288
-
289
- .table-wrap {
290
- overflow: auto;
291
- }
292
-
293
- table {
294
- width: 100%;
295
- border-collapse: collapse;
296
- }
297
-
298
- th,
299
- td {
300
- text-align: left;
301
- border-bottom: 1px solid #e5e7eb;
302
- padding: 8px;
303
- vertical-align: top;
304
- font-size: 0.9rem;
305
- }
306
-
307
- th {
308
- font-size: 0.78rem;
309
- letter-spacing: 0.03em;
310
- text-transform: uppercase;
311
- color: #334155;
312
- }
313
-
314
- .critical-list {
315
- display: grid;
316
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
317
- gap: 10px;
318
- }
319
-
320
- .critical-item {
321
- border: 1px solid #e5e7eb;
322
- border-radius: 10px;
323
- padding: 10px;
324
- background: rgba(255, 255, 255, 0.75);
325
- }
326
-
327
- .critical-item h3 {
328
- margin: 0 0 6px;
329
- font-size: 1rem;
330
- }
331
-
332
- .critical-item p {
333
- margin: 6px 0;
334
- }
335
-
336
- .filters label {
337
- display: flex;
338
- flex-direction: column;
339
- gap: 6px;
340
- font-size: 0.88rem;
341
- }
342
-
343
- .filters input,
344
- .filters select {
345
- border: 1px solid #d1d5db;
346
- border-radius: 8px;
347
- padding: 7px;
348
- }
349
-
350
- .live-stage {
351
- margin: 16px 0 30px;
352
- }
353
-
354
- .live-stage article {
355
- background: var(--panel);
356
- border: 1px solid var(--border);
357
- border-radius: 12px;
358
- padding: 12px;
359
- backdrop-filter: blur(2px);
360
- }
361
-
362
- .detail-head {
363
- margin-bottom: 10px;
364
- }
365
-
366
- .hidden {
367
- display: none;
368
- }
369
-
370
- .grid {
371
- display: grid;
372
- grid-template-columns: 1fr;
373
- gap: 16px;
374
- margin: 16px 0 30px;
375
- }
376
-
377
- @media (min-width: 980px) {
378
- .grid {
379
- grid-template-columns: 1fr 1fr;
380
- }
381
- }
382
-
383
- article {
384
- background: var(--panel);
385
- border: 1px solid var(--border);
386
- border-radius: 12px;
387
- padding: 12px;
388
- backdrop-filter: blur(2px);
389
- }
390
-
391
- .event-list {
392
- list-style: none;
393
- margin: 0;
394
- padding: 0;
395
- display: flex;
396
- flex-direction: column;
397
- gap: 9px;
398
- }
399
-
400
- .event-list li {
401
- border: 1px solid #e5e7eb;
402
- border-radius: 10px;
403
- padding: 10px;
404
- cursor: pointer;
405
- }
406
-
407
- .event-list li:hover {
408
- border-color: var(--primary);
409
- }
410
-
411
- .tag {
412
- display: inline-block;
413
- font-size: 0.75rem;
414
- padding: 2px 8px;
415
- border-radius: 999px;
416
- margin-right: 4px;
417
- }
418
-
419
- .tag.confirmed {
420
- background: #d1fae5;
421
- }
422
-
423
- .tag.unverified {
424
- background: #fee2e2;
425
- }
426
-
427
- .tag.conflicting {
428
- background: #fef3c7;
429
- }
430
-
431
- .tag.official {
432
- background: #d1fae5;
433
- }
434
-
435
- .tag.cross_verified {
436
- background: #dbeafe;
437
- }
438
-
439
- .tag.partial {
440
- background: #fef3c7;
441
- }
442
-
443
- .tag.category {
444
- background: #dbeafe;
445
- }
446
-
447
- .tag.signal-open {
448
- background: #d1fae5;
449
- }
450
-
451
- .tag.signal-disrupted,
452
- .tag.signal-under_attack,
453
- .tag.signal-active {
454
- background: #fee2e2;
455
- }
456
-
457
- .tag.signal-monitoring,
458
- .tag.signal-heightened,
459
- .tag.signal-de_escalating {
460
- background: #fef3c7;
461
- }
462
-
463
- .tag.flight-open {
464
- background: #d1fae5;
465
- }
466
-
467
- .tag.flight-limited {
468
- background: #fef3c7;
469
- }
470
-
471
- .tag.flight-closed {
472
- background: #fee2e2;
473
- }
474
-
475
- .tag.flight-unknown {
476
- background: #e5e7eb;
477
- }
478
-
479
- .tag.source-official {
480
- background: #dcfce7;
481
- }
482
-
483
- .tag.source-news {
484
- background: #dbeafe;
485
- }
486
-
487
- .tag.source-social {
488
- background: #fef3c7;
489
- }
490
-
491
- .tag.source-notam {
492
- background: #fae8ff;
493
- }
494
-
495
- .tag.source-unknown {
496
- background: #e5e7eb;
497
- }
498
-
499
- .tag.trust-high {
500
- background: #22c55e;
501
- color: #fff;
502
- }
503
-
504
- .tag.trust-medium {
505
- background: #f59e0b;
506
- color: #fff;
507
- }
508
-
509
- .tag.trust-low {
510
- background: #ef4444;
511
- color: #fff;
512
- }
513
-
514
- .detail .evidence-item {
515
- border-top: 1px solid #e5e7eb;
516
- padding: 8px 0;
517
- }
518
-
519
- .detail a {
520
- color: #0f766e;
521
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/index.html DELETED
@@ -1,163 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Conflict Monitor v0.1</title>
7
- <link rel="stylesheet" href="/static/styles.css" />
8
- </head>
9
- <body>
10
- <div class="bg-shape bg-a"></div>
11
- <div class="bg-shape bg-b"></div>
12
-
13
- <header class="hero">
14
- <h1>Conflict Monitor v0.1</h1>
15
- <p>Live conflict intelligence with focused operational views.</p>
16
- <div class="actions top-actions">
17
- <label>
18
- Country
19
- <select id="countryFilter"></select>
20
- </label>
21
- <button id="refreshBtn">Refresh</button>
22
- <button id="bootstrapBtn">Load Sources</button>
23
- <button id="ingestBtn">Run Ingestion</button>
24
- </div>
25
- <div class="view-switch" role="tablist" aria-label="Main views">
26
- <button class="switch-btn active" data-view="liveView" type="button">Live Feed</button>
27
- <button class="switch-btn" data-view="flightView" type="button">Flight Board</button>
28
- <button class="switch-btn" data-view="signalView" type="button">Safety Signals</button>
29
- <button class="switch-btn" data-view="factcheckView" type="button">Fact Check</button>
30
- </div>
31
- </header>
32
-
33
- <main>
34
- <section class="view active" id="liveView">
35
- <section class="filters">
36
- <label>
37
- Status
38
- <select id="statusFilter">
39
- <option value="">All</option>
40
- <option value="confirmed">confirmed</option>
41
- <option value="unverified">unverified</option>
42
- <option value="conflicting">conflicting</option>
43
- </select>
44
- </label>
45
- <label>
46
- Focus
47
- <select id="categoryFilter">
48
- <option value="">All</option>
49
- <option value="airport_flights">airport / flights</option>
50
- <option value="us_base_activity">US base activity</option>
51
- <option value="bombardment">bombardment</option>
52
- <option value="general">general</option>
53
- </select>
54
- </label>
55
- <label>
56
- Language
57
- <select id="languageFilter">
58
- <option value="">All</option>
59
- </select>
60
- </label>
61
- </section>
62
-
63
- <section class="live-stage">
64
- <article id="liveEventPanel">
65
- <h2>Events</h2>
66
- <ul id="eventList" class="event-list"></ul>
67
- </article>
68
-
69
- <article id="liveDetailPanel" class="hidden">
70
- <div class="detail-head">
71
- <button id="backToEventsBtn" type="button">Back to Events</button>
72
- </div>
73
- <h2>Evidence Chain</h2>
74
- <div id="detail" class="detail">
75
- <p>Select an event to inspect supporting sources.</p>
76
- </div>
77
- </article>
78
- </section>
79
- </section>
80
-
81
- <section class="view" id="flightView">
82
- <section class="flight-board" id="flightBoard">
83
- <h2>Middle East Airport Flight Status</h2>
84
- <p class="note">Single board view for airport operations: open / limited / closed / unknown.</p>
85
- <div class="flight-controls">
86
- <label>
87
- Flight Country
88
- <select id="flightCountryFilter"></select>
89
- </label>
90
- <label class="check">
91
- <input id="flightShowUnknown" type="checkbox" />
92
- Show Unknown
93
- </label>
94
- </div>
95
- <div class="table-wrap">
96
- <table>
97
- <thead>
98
- <tr>
99
- <th>Country</th>
100
- <th>City</th>
101
- <th>Airport</th>
102
- <th>IATA</th>
103
- <th>Status</th>
104
- <th>Last Update</th>
105
- <th>Source</th>
106
- </tr>
107
- </thead>
108
- <tbody id="airportBoardBody"></tbody>
109
- </table>
110
- </div>
111
- </section>
112
- </section>
113
-
114
- <section class="view" id="signalView">
115
- <section class="critical" id="criticalPanel">
116
- <h2>Critical Safety Signals</h2>
117
- <p class="note">
118
- Use this as a monitoring aid. For immediate safety decisions, confirm with official
119
- airline, embassy, and civil-defense channels.
120
- </p>
121
- <div id="criticalList" class="critical-list"></div>
122
- </section>
123
- </section>
124
-
125
- <section class="view" id="factcheckView">
126
- <section class="factcheck" id="factcheckPanel">
127
- <h2>Cross-Lingual Fact Check</h2>
128
- <div class="factcheck-form">
129
- <label>
130
- Claim
131
- <textarea
132
- id="factcheckClaim"
133
- rows="4"
134
- placeholder="Example: Flights at Beirut airport were suspended after overnight strikes."
135
- ></textarea>
136
- </label>
137
- <div class="factcheck-controls">
138
- <label class="check">
139
- <input id="factcheckOptionalLangs" type="checkbox" />
140
- Include optional languages (ckb, ku, ru)
141
- </label>
142
- <label>
143
- Max Evidence
144
- <select id="factcheckMaxEvidence">
145
- <option value="5">5</option>
146
- <option value="8" selected>8</option>
147
- <option value="12">12</option>
148
- <option value="20">20</option>
149
- </select>
150
- </label>
151
- <button id="factcheckRunBtn" type="button">Run Fact Check</button>
152
- </div>
153
- </div>
154
- <div id="factcheckResult" class="factcheck-result">
155
- <p>Enter a claim and run the pipeline to generate a structured fact-check record.</p>
156
- </div>
157
- </section>
158
- </section>
159
- </main>
160
-
161
- <script src="/static/app.js"></script>
162
- </body>
163
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
audit_core.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core data, validation, authorization, and persistence for the audit Space."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Iterable
15
+
16
+ from huggingface_hub import HfApi, hf_hub_download, snapshot_download
17
+ from huggingface_hub.errors import EntryNotFoundError, HfHubHTTPError
18
+
19
+
20
+ ANNOTATION_SCHEMA_VERSION = "1.0"
21
+ DECISIONS = ("Exact", "Equivalent", "Incorrect", "Unclear")
22
+ CONFIDENCE_LEVELS = ("High", "Medium", "Low")
23
+ ISSUE_TYPES = (
24
+ "Wrong parent",
25
+ "Missing child",
26
+ "Extra child",
27
+ "Wrong sign or weight",
28
+ "Ambiguous decomposition",
29
+ "Table crop or row text issue",
30
+ "Other",
31
+ )
32
+
33
+
34
+ class AuthorizationError(ValueError):
35
+ """Raised when an annotation callback has no authorized identity."""
36
+
37
+
38
+ class AnnotationValidationError(ValueError):
39
+ """Raised when a submitted annotation is incomplete or malformed."""
40
+
41
+
42
+ class StorageError(RuntimeError):
43
+ """Raised when durable annotation persistence fails."""
44
+
45
+
46
+ def utc_now() -> str:
47
+ return datetime.now(timezone.utc).isoformat()
48
+
49
+
50
+ def sha256_file(path: Path) -> str:
51
+ digest = hashlib.sha256()
52
+ with path.open("rb") as handle:
53
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
54
+ digest.update(chunk)
55
+ return digest.hexdigest()
56
+
57
+
58
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
59
+ records: list[dict[str, Any]] = []
60
+ with path.open("r", encoding="utf-8") as handle:
61
+ for line_number, line in enumerate(handle, 1):
62
+ if not line.strip():
63
+ continue
64
+ try:
65
+ records.append(json.loads(line))
66
+ except json.JSONDecodeError as exc:
67
+ raise ValueError(f"Invalid JSON at {path}:{line_number}") from exc
68
+ return records
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class CaseBundle:
73
+ root: Path
74
+ cases: tuple[dict[str, Any], ...]
75
+ by_id: dict[str, dict[str, Any]]
76
+
77
+ @classmethod
78
+ def load(cls, root: str | Path, verify_images: bool = True) -> "CaseBundle":
79
+ root_path = Path(root).expanduser().resolve()
80
+ case_path = root_path / "cases.jsonl"
81
+ lock_path = root_path / "LOCK.json"
82
+ if not case_path.is_file() or not lock_path.is_file():
83
+ raise ValueError(f"Incomplete audit bundle: {root_path}")
84
+
85
+ lock = json.loads(lock_path.read_text(encoding="utf-8"))
86
+ expected_cases_hash = lock.get("artifacts", {}).get("cases.jsonl")
87
+ if not expected_cases_hash or sha256_file(case_path) != expected_cases_hash:
88
+ raise ValueError("The frozen cases.jsonl hash does not match LOCK.json")
89
+
90
+ cases = sorted(_read_jsonl(case_path), key=lambda row: int(row["order"]))
91
+ expected_count = int(lock.get("image_count", 0))
92
+ if not cases or len(cases) != expected_count:
93
+ raise ValueError(
94
+ f"Bundle case count mismatch: found {len(cases)}, expected {expected_count}"
95
+ )
96
+
97
+ by_id: dict[str, dict[str, Any]] = {}
98
+ orders: set[int] = set()
99
+ for case in cases:
100
+ audit_id = str(case.get("audit_id", ""))
101
+ order = int(case.get("order", 0))
102
+ if not audit_id or audit_id in by_id or order in orders:
103
+ raise ValueError(f"Duplicate or empty case identity: {audit_id!r}/{order}")
104
+ rows = {str(row["row_id"]) for row in case.get("rows", [])}
105
+ parent = case.get("proposal", {}).get("parent", {}).get("row_id")
106
+ children = case.get("proposal", {}).get("children", [])
107
+ if parent not in rows or not children:
108
+ raise ValueError(f"Malformed proposal in {audit_id}")
109
+ if any(child.get("row_id") not in rows for child in children):
110
+ raise ValueError(f"Proposal references an absent row in {audit_id}")
111
+ image_path = root_path / str(case.get("image_file", ""))
112
+ if not image_path.is_file():
113
+ raise ValueError(f"Missing image for {audit_id}: {image_path}")
114
+ if verify_images and sha256_file(image_path) != case.get("image_sha256"):
115
+ raise ValueError(f"Image hash mismatch for {audit_id}")
116
+ by_id[audit_id] = case
117
+ orders.add(order)
118
+
119
+ if orders != set(range(1, len(cases) + 1)):
120
+ raise ValueError("Case order must be contiguous and one-indexed")
121
+ return cls(root=root_path, cases=tuple(cases), by_id=by_id)
122
+
123
+ def image_path(self, case: dict[str, Any]) -> str:
124
+ return str((self.root / str(case["image_file"])).resolve())
125
+
126
+
127
+ def resolve_bundle_root() -> Path:
128
+ local_root = os.getenv("FINREPAIR_BUNDLE_DIR", "").strip()
129
+ if local_root:
130
+ return Path(local_root).expanduser().resolve()
131
+
132
+ repo_id = os.getenv("FINREPAIR_DATASET_REPO", "").strip()
133
+ token = os.getenv("HF_TOKEN", "").strip()
134
+ if not repo_id or not token:
135
+ raise RuntimeError(
136
+ "Set FINREPAIR_BUNDLE_DIR locally, or configure both "
137
+ "FINREPAIR_DATASET_REPO and HF_TOKEN in the Space."
138
+ )
139
+ revision = os.getenv("FINREPAIR_DATASET_REVISION", "").strip() or None
140
+ path = snapshot_download(
141
+ repo_id=repo_id,
142
+ repo_type="dataset",
143
+ revision=revision,
144
+ token=token,
145
+ allow_patterns=("cases.jsonl", "LOCK.json", "summary.json", "images/*.png"),
146
+ )
147
+ return Path(path).resolve()
148
+
149
+
150
+ def profile_username(profile: Any | None) -> str | None:
151
+ if profile is not None:
152
+ for field in ("preferred_username", "username", "name"):
153
+ value = profile.get(field) if isinstance(profile, dict) else getattr(profile, field, None)
154
+ if isinstance(value, str) and value.strip():
155
+ return value.strip()
156
+ dev_user = os.getenv("FINREPAIR_DEV_USER", "").strip()
157
+ return dev_user or None
158
+
159
+
160
+ def authorize(profile: Any | None) -> str:
161
+ username = profile_username(profile)
162
+ if not username:
163
+ raise AuthorizationError("Sign in with Hugging Face to access audit cases.")
164
+ allowlist = {
165
+ item.strip().casefold()
166
+ for item in os.getenv("ANNOTATOR_ALLOWLIST", "").split(",")
167
+ if item.strip()
168
+ }
169
+ if allowlist and username.casefold() not in allowlist:
170
+ raise AuthorizationError(f"Hugging Face user {username!r} is not an authorized annotator.")
171
+ return username
172
+
173
+
174
+ def safe_user_key(username: str) -> str:
175
+ slug = re.sub(r"[^a-z0-9._-]+", "-", username.casefold()).strip("-._") or "annotator"
176
+ suffix = hashlib.sha256(username.casefold().encode("utf-8")).hexdigest()[:10]
177
+ return f"{slug[:60]}-{suffix}"
178
+
179
+
180
+ def _table_rows(value: Any) -> Iterable[list[Any]]:
181
+ if value is None:
182
+ return []
183
+ if hasattr(value, "values") and hasattr(value.values, "tolist"):
184
+ return value.values.tolist()
185
+ if isinstance(value, dict):
186
+ columns = list(value.values())
187
+ return [list(row) for row in zip(*columns)] if columns else []
188
+ if isinstance(value, (list, tuple)):
189
+ return value
190
+ raise AnnotationValidationError("Corrected children must be a two-column table.")
191
+
192
+
193
+ def normalize_children(value: Any, valid_row_ids: set[str]) -> list[dict[str, int | float | str]]:
194
+ children: list[dict[str, int | float | str]] = []
195
+ seen: set[str] = set()
196
+ for raw in _table_rows(value):
197
+ row = list(raw) if isinstance(raw, (list, tuple)) else [raw]
198
+ row_id = str(row[0]).strip() if row and row[0] is not None else ""
199
+ raw_weight = row[1] if len(row) > 1 else None
200
+ if not row_id and (raw_weight is None or str(raw_weight).strip() == ""):
201
+ continue
202
+ if row_id not in valid_row_ids:
203
+ raise AnnotationValidationError(f"Unknown corrected child row: {row_id or '<blank>'}")
204
+ if row_id in seen:
205
+ raise AnnotationValidationError(f"Corrected child row is duplicated: {row_id}")
206
+ try:
207
+ weight = float(raw_weight)
208
+ except (TypeError, ValueError) as exc:
209
+ raise AnnotationValidationError(f"Weight for {row_id} must be numeric.") from exc
210
+ if not math.isfinite(weight) or weight == 0:
211
+ raise AnnotationValidationError(f"Weight for {row_id} must be finite and non-zero.")
212
+ normalized_weight: int | float = int(weight) if weight.is_integer() else weight
213
+ children.append({"row_id": row_id, "weight": normalized_weight})
214
+ seen.add(row_id)
215
+ return children
216
+
217
+
218
+ def build_annotation(
219
+ case: dict[str, Any],
220
+ username: str,
221
+ decision: str | None,
222
+ issues: list[str] | None,
223
+ confidence: str | None,
224
+ corrected_parent: str | None,
225
+ corrected_children: Any,
226
+ equivalence_explanation: str | None,
227
+ notes: str | None,
228
+ ) -> dict[str, Any]:
229
+ if decision not in DECISIONS:
230
+ raise AnnotationValidationError("Select a decision before saving.")
231
+ if confidence not in CONFIDENCE_LEVELS:
232
+ raise AnnotationValidationError("Select a confidence level before saving.")
233
+ unknown_issues = set(issues or []) - set(ISSUE_TYPES)
234
+ if unknown_issues:
235
+ raise AnnotationValidationError(f"Unknown issue types: {sorted(unknown_issues)}")
236
+
237
+ row_ids = {str(row["row_id"]) for row in case["rows"]}
238
+ parent = str(corrected_parent or "").strip()
239
+ children = normalize_children(corrected_children, row_ids)
240
+ explanation = str(equivalence_explanation or "").strip()
241
+ note_text = str(notes or "").strip()
242
+
243
+ if decision == "Equivalent" and not explanation:
244
+ raise AnnotationValidationError("Explain why the proposed relation is equivalent.")
245
+ if decision == "Incorrect":
246
+ if parent not in row_ids:
247
+ raise AnnotationValidationError("Select a valid corrected parent row.")
248
+ if len(children) < 2:
249
+ raise AnnotationValidationError("An incorrect relation needs at least two corrected children.")
250
+ if parent in {str(child["row_id"]) for child in children}:
251
+ raise AnnotationValidationError("The corrected parent cannot also be a child.")
252
+ if decision == "Unclear" and not note_text:
253
+ raise AnnotationValidationError("Describe why this case is unclear.")
254
+
255
+ correction = None
256
+ if decision == "Incorrect":
257
+ correction = {"parent_row_id": parent, "children": children}
258
+ return {
259
+ "annotation_schema_version": ANNOTATION_SCHEMA_VERSION,
260
+ "audit_id": case["audit_id"],
261
+ "proposal_hash": case["proposal_hash"],
262
+ "annotator": username,
263
+ "annotated_at_utc": utc_now(),
264
+ "decision": decision,
265
+ "confidence": confidence,
266
+ "issues": list(issues or []),
267
+ "correction": correction,
268
+ "equivalence_explanation": explanation or None,
269
+ "notes": note_text or None,
270
+ }
271
+
272
+
273
+ class AnnotationStore:
274
+ """Per-user JSON persistence with an atomic local cache and optional HF sync."""
275
+
276
+ def __init__(
277
+ self,
278
+ local_root: str | Path,
279
+ repo_id: str | None = None,
280
+ token: str | None = None,
281
+ ) -> None:
282
+ self.local_root = Path(local_root).expanduser().resolve()
283
+ self.local_root.mkdir(parents=True, exist_ok=True)
284
+ self.repo_id = (repo_id or "").strip() or None
285
+ self.token = (token or "").strip() or None
286
+ self._locks: dict[str, threading.Lock] = {}
287
+ self._locks_guard = threading.Lock()
288
+
289
+ @classmethod
290
+ def from_environment(cls) -> "AnnotationStore":
291
+ return cls(
292
+ local_root=os.getenv("FINREPAIR_ANNOTATION_CACHE", "/tmp/finrepair_annotations"),
293
+ repo_id=os.getenv("FINREPAIR_ANNOTATION_REPO")
294
+ or os.getenv("FINREPAIR_DATASET_REPO"),
295
+ token=os.getenv("HF_TOKEN"),
296
+ )
297
+
298
+ def _lock_for(self, username: str) -> threading.Lock:
299
+ key = safe_user_key(username)
300
+ with self._locks_guard:
301
+ return self._locks.setdefault(key, threading.Lock())
302
+
303
+ def _relative_path(self, username: str) -> str:
304
+ return f"annotations/{safe_user_key(username)}.json"
305
+
306
+ def _local_path(self, username: str) -> Path:
307
+ return self.local_root / f"{safe_user_key(username)}.json"
308
+
309
+ @staticmethod
310
+ def empty_document(username: str) -> dict[str, Any]:
311
+ return {
312
+ "annotation_schema_version": ANNOTATION_SCHEMA_VERSION,
313
+ "annotator": username,
314
+ "updated_at_utc": None,
315
+ "annotations": {},
316
+ }
317
+
318
+ def load(self, username: str) -> dict[str, Any]:
319
+ with self._lock_for(username):
320
+ local_path = self._local_path(username)
321
+ if self.repo_id and self.token:
322
+ try:
323
+ remote_path = hf_hub_download(
324
+ repo_id=self.repo_id,
325
+ repo_type="dataset",
326
+ filename=self._relative_path(username),
327
+ token=self.token,
328
+ force_download=True,
329
+ )
330
+ document = json.loads(Path(remote_path).read_text(encoding="utf-8"))
331
+ self._write_local(local_path, document)
332
+ return self._validate_document(document, username)
333
+ except EntryNotFoundError:
334
+ pass
335
+ except (HfHubHTTPError, OSError, json.JSONDecodeError) as exc:
336
+ raise StorageError(f"Could not load the remote annotation record: {exc}") from exc
337
+ if local_path.is_file():
338
+ try:
339
+ document = json.loads(local_path.read_text(encoding="utf-8"))
340
+ except (OSError, json.JSONDecodeError) as exc:
341
+ raise StorageError(f"Could not load the local annotation record: {exc}") from exc
342
+ return self._validate_document(document, username)
343
+ return self.empty_document(username)
344
+
345
+ def save(self, username: str, document: dict[str, Any]) -> bool:
346
+ with self._lock_for(username):
347
+ document = self._validate_document(document, username)
348
+ document["updated_at_utc"] = utc_now()
349
+ local_path = self._local_path(username)
350
+ self._write_local(local_path, document)
351
+ if not self.repo_id or not self.token:
352
+ return False
353
+ try:
354
+ HfApi(token=self.token).upload_file(
355
+ path_or_fileobj=str(local_path),
356
+ path_in_repo=self._relative_path(username),
357
+ repo_id=self.repo_id,
358
+ repo_type="dataset",
359
+ commit_message=f"Save audit progress for {safe_user_key(username)}",
360
+ )
361
+ except (HfHubHTTPError, OSError) as exc:
362
+ raise StorageError(
363
+ "The annotation was cached locally but could not be synced to the private dataset."
364
+ ) from exc
365
+ return True
366
+
367
+ @staticmethod
368
+ def _validate_document(document: dict[str, Any], username: str) -> dict[str, Any]:
369
+ if not isinstance(document, dict) or not isinstance(document.get("annotations"), dict):
370
+ raise StorageError("Annotation document has an invalid structure.")
371
+ owner = document.get("annotator")
372
+ if owner != username:
373
+ raise StorageError(f"Annotation owner mismatch: expected {username!r}, found {owner!r}")
374
+ return document
375
+
376
+ @staticmethod
377
+ def _write_local(path: Path, document: dict[str, Any]) -> None:
378
+ path.parent.mkdir(parents=True, exist_ok=True)
379
+ temporary = path.with_suffix(path.suffix + ".tmp")
380
+ temporary.write_text(
381
+ json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n",
382
+ encoding="utf-8",
383
+ )
384
+ temporary.replace(path)
requirements.txt CHANGED
@@ -1,13 +1,2 @@
1
- fastapi==0.116.1
2
- uvicorn[standard]==0.35.0
3
- sqlalchemy==2.0.43
4
- psycopg2-binary==2.9.10
5
- pydantic==2.11.7
6
- pydantic-settings==2.10.1
7
- feedparser==6.0.11
8
- httpx==0.28.1
9
- beautifulsoup4==4.13.4
10
- python-dateutil==2.9.0.post0
11
- langdetect==1.0.9
12
- PyYAML==6.0.2
13
- jinja2==3.1.6
 
1
+ gradio[oauth]==6.20.0
2
+ huggingface_hub==1.23.0
 
 
 
 
 
 
 
 
 
 
 
sources.yaml DELETED
@@ -1,307 +0,0 @@
1
- sources:
2
- - name: US State Department Press
3
- kind: rss
4
- base_url: https://www.state.gov/
5
- feed_url: https://www.state.gov/press-releases/feed/
6
- country: United States
7
- actor: US State Department
8
- priority: official
9
- active: true
10
-
11
- - name: The White House Briefing Room
12
- kind: rss
13
- base_url: https://www.whitehouse.gov/
14
- feed_url: https://www.whitehouse.gov/briefing-room/feed/
15
- country: United States
16
- actor: White House
17
- priority: official
18
- active: true
19
-
20
- - name: US DoD Releases
21
- kind: rss
22
- base_url: https://www.defense.gov/
23
- feed_url: https://www.defense.gov/DesktopModules/ArticleCS/RSS.ashx?ContentType=1&Site=945&max=20
24
- country: United States
25
- actor: US Department of Defense
26
- priority: official
27
- active: true
28
-
29
- - name: UN News Middle East
30
- kind: rss
31
- base_url: https://news.un.org/
32
- feed_url: https://news.un.org/feed/subscribe/en/news/region/middle-east/feed/rss.xml
33
- country: International
34
- actor: United Nations
35
- priority: official
36
- active: true
37
-
38
- - name: UN Security Council Meetings
39
- kind: rss
40
- base_url: https://press.un.org/
41
- feed_url: https://press.un.org/en/content/security-council/rss.xml
42
- country: International
43
- actor: UN Security Council
44
- priority: official
45
- active: true
46
-
47
- - name: UK Foreign Office
48
- kind: rss
49
- base_url: https://www.gov.uk/government/organisations/foreign-commonwealth-development-office
50
- feed_url: https://www.gov.uk/government/organisations/foreign-commonwealth-development-office.atom
51
- country: United Kingdom
52
- actor: FCDO
53
- priority: official
54
- active: true
55
-
56
- - name: French Foreign Ministry
57
- kind: rss
58
- base_url: https://www.diplomatie.gouv.fr/
59
- feed_url: https://www.diplomatie.gouv.fr/en/spip.php?page=backend
60
- country: France
61
- actor: French Foreign Ministry
62
- priority: official
63
- active: true
64
-
65
- - name: Reuters World
66
- kind: rss
67
- base_url: https://www.reuters.com/
68
- feed_url: https://feeds.reuters.com/Reuters/worldNews
69
- country: International
70
- actor: Reuters
71
- priority: media
72
- active: true
73
-
74
- - name: Reuters Middle East
75
- kind: rss
76
- base_url: https://www.reuters.com/world/middle-east/
77
- feed_url: https://feeds.reuters.com/reuters/world/middleeast
78
- country: International
79
- actor: Reuters
80
- priority: media
81
- active: true
82
-
83
- - name: AP News World
84
- kind: rss
85
- base_url: https://apnews.com/
86
- feed_url: https://apnews.com/hub/apf-world-news?output=1
87
- country: International
88
- actor: Associated Press
89
- priority: media
90
- active: true
91
-
92
- - name: Al Jazeera Middle East
93
- kind: rss
94
- base_url: https://www.aljazeera.com/
95
- feed_url: https://www.aljazeera.com/xml/rss/all.xml
96
- country: Qatar
97
- actor: Al Jazeera
98
- priority: media
99
- active: true
100
-
101
- - name: BBC World
102
- kind: rss
103
- base_url: https://www.bbc.com/news
104
- feed_url: http://feeds.bbci.co.uk/news/world/rss.xml
105
- country: United Kingdom
106
- actor: BBC
107
- priority: media
108
- active: true
109
-
110
- - name: BBC Arabic
111
- kind: rss
112
- base_url: https://www.bbc.com/arabic
113
- feed_url: https://feeds.bbci.co.uk/arabic/rss.xml
114
- country: International
115
- actor: BBC Arabic
116
- priority: media
117
- active: true
118
-
119
- - name: BBC Persian
120
- kind: rss
121
- base_url: https://www.bbc.com/persian
122
- feed_url: https://feeds.bbci.co.uk/persian/rss.xml
123
- country: Iran
124
- actor: BBC Persian
125
- priority: media
126
- active: true
127
-
128
- - name: BBC Turkish
129
- kind: rss
130
- base_url: https://www.bbc.com/turkce
131
- feed_url: https://feeds.bbci.co.uk/turkce/rss.xml
132
- country: Turkey
133
- actor: BBC Turkish
134
- priority: media
135
- active: true
136
-
137
- - name: Al Jazeera Arabic
138
- kind: rss
139
- base_url: https://www.aljazeera.net/
140
- feed_url: https://www.aljazeera.net/aljazeerarss/ar
141
- country: Qatar
142
- actor: Al Jazeera Arabic
143
- priority: media
144
- active: true
145
-
146
- - name: Anadolu Turkish
147
- kind: rss
148
- base_url: https://www.aa.com.tr/tr
149
- feed_url: https://www.aa.com.tr/tr/rss/default?cat=guncel
150
- country: Turkey
151
- actor: Anadolu Agency
152
- priority: media
153
- active: true
154
-
155
- - name: Anadolu Arabic
156
- kind: rss
157
- base_url: https://www.aa.com.tr/ar
158
- feed_url: https://www.aa.com.tr/ar/rss/default?cat=featured
159
- country: International
160
- actor: Anadolu Agency Arabic
161
- priority: media
162
- active: true
163
-
164
- - name: Ynet Hebrew
165
- kind: rss
166
- base_url: https://www.ynet.co.il/
167
- feed_url: https://www.ynet.co.il/Integration/StoryRss2.xml
168
- country: Israel
169
- actor: Ynet
170
- priority: media
171
- active: true
172
-
173
- - name: CNN World
174
- kind: rss
175
- base_url: https://edition.cnn.com/world
176
- feed_url: http://rss.cnn.com/rss/edition_world.rss
177
- country: United States
178
- actor: CNN
179
- priority: media
180
- active: true
181
-
182
- - name: NYTimes World
183
- kind: rss
184
- base_url: https://www.nytimes.com/section/world
185
- feed_url: https://rss.nytimes.com/services/xml/rss/nyt/World.xml
186
- country: United States
187
- actor: New York Times
188
- priority: media
189
- active: true
190
-
191
- - name: Guardian World
192
- kind: rss
193
- base_url: https://www.theguardian.com/world
194
- feed_url: https://www.theguardian.com/world/rss
195
- country: United Kingdom
196
- actor: The Guardian
197
- priority: media
198
- active: true
199
-
200
- - name: Times of Israel
201
- kind: rss
202
- base_url: https://www.timesofisrael.com/
203
- feed_url: https://www.timesofisrael.com/feed/
204
- country: Israel
205
- actor: Times of Israel
206
- priority: media
207
- active: true
208
-
209
- - name: Haaretz
210
- kind: rss
211
- base_url: https://www.haaretz.com/
212
- feed_url: https://www.haaretz.com/cmlink/1.628752
213
- country: Israel
214
- actor: Haaretz
215
- priority: media
216
- active: true
217
-
218
- - name: Jerusalem Post
219
- kind: rss
220
- base_url: https://www.jpost.com/
221
- feed_url: https://www.jpost.com/rss/rssfeedsheadlines.aspx
222
- country: Israel
223
- actor: Jerusalem Post
224
- priority: media
225
- active: true
226
-
227
- - name: Arab News
228
- kind: rss
229
- base_url: https://www.arabnews.com/
230
- feed_url: https://www.arabnews.com/rss.xml
231
- country: Saudi Arabia
232
- actor: Arab News
233
- priority: media
234
- active: true
235
-
236
- - name: Gulf News
237
- kind: rss
238
- base_url: https://gulfnews.com/
239
- feed_url: https://gulfnews.com/rss
240
- country: UAE
241
- actor: Gulf News
242
- priority: media
243
- active: true
244
-
245
- - name: Israeli PMO Announcements
246
- kind: webpage
247
- base_url: https://www.gov.il/en/departments/prime_ministers_office/news
248
- country: Israel
249
- actor: Prime Minister's Office
250
- priority: official
251
- active: true
252
-
253
- - name: UAE MoFA Announcements
254
- kind: webpage
255
- base_url: https://www.mofa.gov.ae/en/mediahub/news
256
- country: UAE
257
- actor: Ministry of Foreign Affairs
258
- priority: official
259
- active: true
260
-
261
- - name: Saudi Foreign Ministry Announcements
262
- kind: webpage
263
- base_url: https://www.mofa.gov.sa/en/mediacenter/news
264
- country: Saudi Arabia
265
- actor: Ministry of Foreign Affairs
266
- priority: official
267
- active: true
268
-
269
- - name: Iranian Foreign Ministry News
270
- kind: webpage
271
- base_url: https://en.mfa.ir/portal/news
272
- country: Iran
273
- actor: Ministry of Foreign Affairs
274
- priority: official
275
- active: true
276
-
277
- - name: Dubai Airports Alerts
278
- kind: webpage
279
- base_url: https://www.dubaiairports.ae/alert
280
- country: UAE
281
- actor: Dubai Airports
282
- priority: official
283
- active: true
284
-
285
- - name: Hamad International Airport
286
- kind: webpage
287
- base_url: https://dohahamadairport.com/
288
- country: Qatar
289
- actor: Hamad International Airport
290
- priority: official
291
- active: true
292
-
293
- - name: Israel Airports Authority
294
- kind: webpage
295
- base_url: https://www.iaa.gov.il/en/
296
- country: Israel
297
- actor: Israel Airports Authority
298
- priority: official
299
- active: true
300
-
301
- - name: Kuwait International Airport
302
- kind: webpage
303
- base_url: https://www.kuwaitairport.gov.kw/en/
304
- country: Kuwait
305
- actor: Directorate General of Civil Aviation
306
- priority: official
307
- active: true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
styles.css ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --ink: #17202a;
3
+ --muted: #5f6b76;
4
+ --line: #d7dde3;
5
+ --surface: #ffffff;
6
+ --surface-soft: #f5f7f8;
7
+ --primary: #185f8d;
8
+ --primary-dark: #11496d;
9
+ --success: #27825b;
10
+ --warning: #a25e15;
11
+ }
12
+
13
+ .gradio-container {
14
+ background: var(--surface-soft) !important;
15
+ color: var(--ink) !important;
16
+ max-width: min(1680px, 100vw) !important;
17
+ margin: 0 auto !important;
18
+ padding: 0 !important;
19
+ width: 100vw !important;
20
+ }
21
+
22
+ #topbar {
23
+ align-items: center;
24
+ border-bottom: 1px solid var(--line);
25
+ margin-bottom: 12px;
26
+ padding-bottom: 10px;
27
+ }
28
+
29
+ #product-title {
30
+ flex: 1 1 auto;
31
+ }
32
+
33
+ #product-title h1 {
34
+ color: var(--ink);
35
+ font-size: 24px;
36
+ line-height: 1.25;
37
+ margin: 0;
38
+ }
39
+
40
+ #auth-button {
41
+ flex: 0 0 auto;
42
+ min-width: 150px;
43
+ }
44
+
45
+ .progress-shell {
46
+ background: var(--surface);
47
+ border: 1px solid var(--line);
48
+ border-radius: 6px;
49
+ padding: 10px 12px;
50
+ }
51
+
52
+ .progress-copy {
53
+ align-items: baseline;
54
+ color: var(--muted);
55
+ display: flex;
56
+ font-size: 13px;
57
+ gap: 10px;
58
+ justify-content: flex-start;
59
+ margin-bottom: 7px;
60
+ }
61
+
62
+ .progress-copy strong {
63
+ color: var(--ink);
64
+ }
65
+
66
+ .annotator-name {
67
+ margin-left: auto;
68
+ }
69
+
70
+ progress {
71
+ accent-color: var(--success);
72
+ display: block;
73
+ height: 8px;
74
+ width: 100%;
75
+ }
76
+
77
+ #save-status {
78
+ color: var(--success);
79
+ font-size: 13px;
80
+ min-height: 22px;
81
+ padding: 5px 2px 0;
82
+ }
83
+
84
+ #case-title h3 {
85
+ font-size: 17px;
86
+ margin: 3px 0 8px;
87
+ }
88
+
89
+ #case-title p {
90
+ color: var(--muted);
91
+ font-size: 12px;
92
+ }
93
+
94
+ #statement-image {
95
+ background: var(--surface);
96
+ border-color: var(--line);
97
+ border-radius: 6px;
98
+ }
99
+
100
+ #statement-image img {
101
+ object-fit: contain !important;
102
+ }
103
+
104
+ #workbench {
105
+ align-items: flex-start;
106
+ gap: 16px;
107
+ margin-top: 14px;
108
+ }
109
+
110
+ #annotation-panel {
111
+ border-left: 1px solid var(--line);
112
+ padding-left: 16px;
113
+ }
114
+
115
+ #navigation {
116
+ background: var(--surface-soft);
117
+ border-top: 1px solid var(--line);
118
+ gap: 8px;
119
+ margin-top: 16px;
120
+ padding-top: 12px;
121
+ }
122
+
123
+ #navigation button {
124
+ border-radius: 5px !important;
125
+ min-height: 42px;
126
+ }
127
+
128
+ #navigation button.primary {
129
+ background: var(--primary) !important;
130
+ border-color: var(--primary) !important;
131
+ }
132
+
133
+ #navigation button.primary:hover {
134
+ background: var(--primary-dark) !important;
135
+ }
136
+
137
+ @media (max-width: 760px) {
138
+ #topbar,
139
+ #workbench,
140
+ #workbench > .column,
141
+ #navigation {
142
+ max-width: 100% !important;
143
+ min-width: 0 !important;
144
+ width: 100% !important;
145
+ }
146
+
147
+ #workbench .block {
148
+ max-width: 100% !important;
149
+ min-width: 0 !important;
150
+ }
151
+
152
+ #workbench {
153
+ flex-direction: column !important;
154
+ }
155
+
156
+ #workbench > .column {
157
+ flex: 1 1 auto !important;
158
+ }
159
+
160
+ #product-title h1 {
161
+ font-size: 20px;
162
+ }
163
+
164
+ #topbar {
165
+ align-items: stretch;
166
+ gap: 8px;
167
+ }
168
+
169
+ #auth-button {
170
+ min-width: 100%;
171
+ }
172
+
173
+ .progress-copy {
174
+ flex-wrap: wrap;
175
+ }
176
+
177
+ .annotator-name {
178
+ flex-basis: 100%;
179
+ margin-left: 0;
180
+ overflow-wrap: anywhere;
181
+ }
182
+
183
+ #statement-image {
184
+ height: 220px !important;
185
+ min-height: 220px;
186
+ }
187
+
188
+ #annotation-panel {
189
+ border-left: 0;
190
+ border-top: 1px solid var(--line);
191
+ padding-left: 0;
192
+ padding-top: 14px;
193
+ }
194
+
195
+ #navigation {
196
+ display: grid !important;
197
+ grid-template-columns: 1fr 1fr;
198
+ }
199
+
200
+ #navigation > div {
201
+ min-width: 0 !important;
202
+ width: 100% !important;
203
+ }
204
+ }
tests/test_audit_core.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from audit_core import (
10
+ AnnotationStore,
11
+ AnnotationValidationError,
12
+ AuthorizationError,
13
+ CaseBundle,
14
+ authorize,
15
+ build_annotation,
16
+ normalize_children,
17
+ safe_user_key,
18
+ )
19
+
20
+
21
+ def make_case() -> dict:
22
+ return {
23
+ "audit_id": "audit_test",
24
+ "image_file": "images/audit_test.png",
25
+ "image_sha256": hashlib.sha256(b"image").hexdigest(),
26
+ "order": 1,
27
+ "proposal": {
28
+ "parent": {"row_id": "r3", "label": "Profit"},
29
+ "children": [
30
+ {"row_id": "r1", "label": "Revenue", "weight": 1},
31
+ {"row_id": "r2", "label": "Cost", "weight": -1},
32
+ ],
33
+ },
34
+ "proposal_hash": "proposal-hash",
35
+ "relation_size": 3,
36
+ "rows": [
37
+ {"row_id": "r1", "text": "Revenue"},
38
+ {"row_id": "r2", "text": "Cost"},
39
+ {"row_id": "r3", "text": "Profit"},
40
+ ],
41
+ "schema_version": "1.0",
42
+ }
43
+
44
+
45
+ def make_bundle(tmp_path: Path) -> Path:
46
+ root = tmp_path / "bundle"
47
+ (root / "images").mkdir(parents=True)
48
+ case = make_case()
49
+ (root / case["image_file"]).write_bytes(b"image")
50
+ case_bytes = (json.dumps(case) + "\n").encode()
51
+ (root / "cases.jsonl").write_bytes(case_bytes)
52
+ lock = {
53
+ "artifacts": {"cases.jsonl": hashlib.sha256(case_bytes).hexdigest()},
54
+ "image_count": 1,
55
+ }
56
+ (root / "LOCK.json").write_text(json.dumps(lock), encoding="utf-8")
57
+ return root
58
+
59
+
60
+ def test_case_bundle_validates_locked_cases_and_images(tmp_path: Path) -> None:
61
+ bundle = CaseBundle.load(make_bundle(tmp_path))
62
+ assert len(bundle.cases) == 1
63
+ assert bundle.cases[0]["audit_id"] == "audit_test"
64
+ assert bundle.image_path(bundle.cases[0]).endswith("images/audit_test.png")
65
+
66
+
67
+ def test_normalize_children_rejects_duplicates_and_zero_weight() -> None:
68
+ assert normalize_children([["r1", "1"], ["r2", -1]], {"r1", "r2"}) == [
69
+ {"row_id": "r1", "weight": 1},
70
+ {"row_id": "r2", "weight": -1},
71
+ ]
72
+ with pytest.raises(AnnotationValidationError, match="duplicated"):
73
+ normalize_children([["r1", 1], ["r1", -1]], {"r1"})
74
+ with pytest.raises(AnnotationValidationError, match="non-zero"):
75
+ normalize_children([["r1", 0]], {"r1"})
76
+
77
+
78
+ def test_decision_specific_validation() -> None:
79
+ case = make_case()
80
+ exact = build_annotation(
81
+ case, "alice", "Exact", [], "High", "r3", [["r1", 1], ["r2", -1]], "", ""
82
+ )
83
+ assert exact["correction"] is None
84
+
85
+ with pytest.raises(AnnotationValidationError, match="equivalent"):
86
+ build_annotation(
87
+ case, "alice", "Equivalent", [], "Medium", "r3", [], "", ""
88
+ )
89
+ with pytest.raises(AnnotationValidationError, match="at least two"):
90
+ build_annotation(
91
+ case, "alice", "Incorrect", ["Missing child"], "Low", "r3", [["r1", 1]], "", ""
92
+ )
93
+ corrected = build_annotation(
94
+ case,
95
+ "alice",
96
+ "Incorrect",
97
+ ["Wrong sign or weight"],
98
+ "High",
99
+ "r3",
100
+ [["r1", 1], ["r2", -1]],
101
+ "",
102
+ "checked",
103
+ )
104
+ assert corrected["correction"]["children"][1]["weight"] == -1
105
+
106
+
107
+ def test_authorization_requires_identity_and_honors_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
108
+ monkeypatch.delenv("FINREPAIR_DEV_USER", raising=False)
109
+ monkeypatch.delenv("ANNOTATOR_ALLOWLIST", raising=False)
110
+ with pytest.raises(AuthorizationError, match="Sign in"):
111
+ authorize(None)
112
+ assert authorize({"preferred_username": "Alice"}) == "Alice"
113
+ monkeypatch.setenv("ANNOTATOR_ALLOWLIST", "bob, carol")
114
+ with pytest.raises(AuthorizationError, match="not an authorized"):
115
+ authorize({"preferred_username": "Alice"})
116
+ assert authorize({"preferred_username": "BOB"}) == "BOB"
117
+
118
+
119
+ def test_local_store_round_trip_and_user_isolation(tmp_path: Path) -> None:
120
+ store = AnnotationStore(tmp_path)
121
+ alice = store.empty_document("alice")
122
+ alice["annotations"]["audit_test"] = {"decision": "Exact"}
123
+ assert store.save("alice", alice) is False
124
+ assert store.load("alice")["annotations"]["audit_test"]["decision"] == "Exact"
125
+ assert store.load("bob")["annotations"] == {}
126
+ assert safe_user_key("Alice") != safe_user_key("Bob")