Spaces:
Sleeping
Sleeping
Replace conflict monitor with FinRepair formula audit
Browse files- .gitignore +6 -0
- Dockerfile +0 -20
- README.md +33 -8
- app.py +426 -0
- app/__init__.pyc +0 -0
- app/collectors/__init__.pyc +0 -0
- app/collectors/base.pyc +0 -0
- app/collectors/rss.pyc +0 -0
- app/collectors/webpage.pyc +0 -0
- app/config.pyc +0 -0
- app/core/__init__.pyc +0 -0
- app/db.pyc +0 -0
- app/main.pyc +0 -0
- app/models.pyc +0 -0
- app/pipeline.pyc +0 -0
- app/schemas.pyc +0 -0
- app/services/__init__.pyc +0 -0
- app/services/airports.pyc +0 -0
- app/services/classification.pyc +0 -0
- app/services/clustering.pyc +0 -0
- app/services/factcheck.pyc +0 -0
- app/services/meconf.pyc +0 -0
- app/services/nlp.pyc +0 -0
- app/services/scoring.pyc +0 -0
- app/services/source_loader.pyc +0 -0
- app/static/app.js +0 -551
- app/static/styles.css +0 -521
- app/templates/index.html +0 -163
- audit_core.py +384 -0
- requirements.txt +2 -13
- sources.yaml +0 -307
- styles.css +204 -0
- tests/test_audit_core.py +126 -0
.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:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
| 8 |
pinned: false
|
|
|
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("&", "&")
|
| 83 |
-
.replaceAll("<", "<")
|
| 84 |
-
.replaceAll(">", ">")
|
| 85 |
-
.replaceAll('"', """)
|
| 86 |
-
.replaceAll("'", "'");
|
| 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 |
-
|
| 2 |
-
|
| 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")
|