File size: 9,305 Bytes
914024f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""Runtime-mutable configuration — RSS feeds and operational thresholds exposed via /config/*.

In-memory only (module-level state): the app runs on HuggingFace Spaces where
restarts are frequent, so no persistence layer is introduced for this. Every
default below is the exact value that was previously hardcoded as a module
constant elsewhere in the pipeline — see PARAMETER_SPECS for the original
file:line each one came from.

NOT included here (intentionally out of scope): SEVERITY_WEIGHTS and the
0.85/0.15 coverage-bonus coefficients (pipeline/scoring/tcs.py) — the TCS
formula itself stays hardcoded and non-editable from the UI.
"""

from __future__ import annotations

from dataclasses import dataclass
from threading import Lock
from urllib.parse import urlparse

# RSS feeds
# Moved here (previously module constants in rss_verifier.py) so the effective
# feed list can be mutated at runtime without rss_verifier importing back into
# a config module that itself needs the feed list.

DEFAULT_FEEDS: list[str] = [
    "http://feeds.bbci.co.uk/news/politics/rss.xml",
    "https://feeds.npr.org/1014/rss.xml",
    "https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml",
    "https://thehill.com/homenews/feed/",
    "https://rss.politico.com/politics-news.xml",
    "https://www.theguardian.com/politics/rss",
    "https://www.theguardian.com/us-news/rss",
    "https://feeds.skynews.com/feeds/rss/politics.xml",
]

FEED_NAMES: dict[str, str] = {
    "http://feeds.bbci.co.uk/news/politics/rss.xml": "BBC Politics",
    "https://feeds.npr.org/1014/rss.xml": "NPR Politics",
    "https://rss.nytimes.com/services/xml/rss/nyt/Politics.xml": "NYT Politics",
    "https://thehill.com/homenews/feed/": "The Hill",
    "https://rss.politico.com/politics-news.xml": "Politico",
    "https://www.theguardian.com/politics/rss": "The Guardian Politics",
    "https://www.theguardian.com/us-news/rss": "The Guardian US",
    "https://feeds.skynews.com/feeds/rss/politics.xml": "Sky News Politics",
}

_rss_lock = Lock()
_predefined_enabled: dict[str, bool] = {url: True for url in DEFAULT_FEEDS}
_custom_feeds: list[str] = []


def feed_name(url: str) -> str:
    return FEED_NAMES.get(url, url.split("/")[2] if "/" in url else url)


def get_rss_feeds() -> dict:
    """Returns {"predefined": [{"url","name","enabled"}, ...8], "custom": [urls]}."""
    with _rss_lock:
        return {
            "predefined": [
                {"url": url, "name": feed_name(url), "enabled": _predefined_enabled[url]}
                for url in DEFAULT_FEEDS
            ],
            "custom": list(_custom_feeds),
        }


def get_effective_feed_urls() -> list[str]:
    """(enabled predefined) union (custom) — the list RSSVerifier actually queries."""
    with _rss_lock:
        enabled = [url for url in DEFAULT_FEEDS if _predefined_enabled.get(url, True)]
        return enabled + list(_custom_feeds)


def set_predefined_enabled(flags: dict[str, bool]) -> None:
    """flags must map exactly the 8 predefined URLs to booleans."""
    if set(flags.keys()) != set(DEFAULT_FEEDS):
        raise ValueError("Must specify the enabled flag for exactly the 8 predefined feed URLs.")
    with _rss_lock:
        for url, enabled in flags.items():
            _predefined_enabled[url] = bool(enabled)


def add_custom_feed(url: str) -> None:
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https") or not parsed.netloc:
        raise ValueError(f"Invalid feed URL: '{url}'. Must be a well-formed http(s) URL.")
    with _rss_lock:
        _custom_feeds.append(url)


def remove_custom_feed(index: int) -> None:
    with _rss_lock:
        if index < 0 or index >= len(_custom_feeds):
            raise IndexError(f"Custom feed index {index} out of range (0..{len(_custom_feeds) - 1}).")
        _custom_feeds.pop(index)


def reset_rss_feeds() -> None:
    with _rss_lock:
        for url in DEFAULT_FEEDS:
            _predefined_enabled[url] = True
        _custom_feeds.clear()


# Operational parameters (Table 5.4 minus severity weights / Wikidata cache TTL / batch size)

@dataclass(frozen=True)
class ParameterSpec:
    key: str
    label: str
    group: str  # "classification" | "internal" | "external"
    unit: str  # "score" | "days" | "years"
    default: float
    min: float
    max: float
    step: float


PARAMETER_SPECS: dict[str, ParameterSpec] = {
    "fake_threshold": ParameterSpec(
        "fake_threshold", "Classification Threshold FAKE/TRUE (θ)", "classification", "score",
        default=0.75, min=0.0, max=1.0, step=0.05,
    ),
    "tcs_very_consistent": ParameterSpec(
        "tcs_very_consistent", 'TCS Threshold "Highly Consistent"', "classification", "score",
        default=0.80, min=0.0, max=1.0, step=0.05,
    ),
    "tcs_moderate": ParameterSpec(
        "tcs_moderate", 'TCS Threshold "Moderate"', "classification", "score",
        default=0.50, min=0.0, max=1.0, step=0.05,
    ),
    "tcs_suspicious": ParameterSpec(
        "tcs_suspicious", 'TCS Threshold "Suspicious"', "classification", "score",
        default=0.20, min=0.0, max=1.0, step=0.05,
    ),
    "inverted_interval_tolerance_days": ParameterSpec(
        "inverted_interval_tolerance_days", "Inverted Interval Tolerance (V3)", "internal", "days",
        default=30, min=0, max=90, step=10,
    ),
    "max_plausible_tenure_years": ParameterSpec(
        "max_plausible_tenure_years", "Maximum Plausible Duration (V3)", "internal", "years",
        default=50, min=0, max=100, step=10,
    ),
    "absurd_duration_years": ParameterSpec(
        "absurd_duration_years", "Duration Excluded from Analysis (V3)", "internal", "years",
        default=80, min=0, max=150, step=10,
    ),
    "election_to_office_buffer_days": ParameterSpec(
        "election_to_office_buffer_days", "Election-to-Office Buffer (V5)", "internal", "days",
        default=180, min=0, max=365, step=10,
    ),
    "action_before_office_buffer_days": ParameterSpec(
        "action_before_office_buffer_days", "Action-Before-Office Buffer (V8)", "internal", "days",
        default=90, min=0, max=180, step=10,
    ),
    "text_similarity_threshold_v4": ParameterSpec(
        "text_similarity_threshold_v4", "Text Similarity Threshold (V4)", "internal", "score",
        default=0.85, min=0.0, max=1.0, step=0.05,
    ),
    "min_fact_confidence": ParameterSpec(
        "min_fact_confidence", "Minimum Fact Confidence Threshold (C2)", "internal", "score",
        default=0.30, min=0.0, max=1.0, step=0.05,
    ),
    "external_date_tolerance_days": ParameterSpec(
        "external_date_tolerance_days", "External Date Tolerance (L1, L3)", "external", "days",
        default=200, min=0, max=400, step=10,
    ),
    "canonical_event_tolerance_days": ParameterSpec(
        "canonical_event_tolerance_days", "Canonical Event Tolerance (L1)", "external", "days",
        default=100, min=0, max=200, step=10,
    ),
    "canonical_event_similarity_threshold": ParameterSpec(
        "canonical_event_similarity_threshold", "Canonical Event Lexical Similarity Threshold (L1)", "external", "score",
        default=0.75, min=0.0, max=1.0, step=0.05,
    ),
    "cross_article_similarity_threshold": ParameterSpec(
        "cross_article_similarity_threshold", "Cross-Article Similarity Threshold (L2)", "external", "score",
        default=0.80, min=0.0, max=1.0, step=0.05,
    ),
}

_param_lock = Lock()
_param_values: dict[str, float] = {key: spec.default for key, spec in PARAMETER_SPECS.items()}


def get_value(key: str) -> float:
    """Read a parameter's current value — call at point of use, not at import time."""
    return _param_values[key]


def get_parameters() -> list[dict]:
    with _param_lock:
        return [
            {
                "key": key,
                "label": spec.label,
                "group": spec.group,
                "unit": spec.unit,
                "value": _param_values[key],
                "default": spec.default,
                "min": spec.min,
                "max": spec.max,
                "step": spec.step,
            }
            for key, spec in PARAMETER_SPECS.items()
        ]


def _validate(key: str, value: float) -> None:
    if key not in PARAMETER_SPECS:
        raise ValueError(f"Unknown parameter: '{key}'.")
    spec = PARAMETER_SPECS[key]
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        raise ValueError(f"'{key}' must be numeric (got {type(value).__name__}).")
    if not (spec.min <= value <= spec.max):
        raise ValueError(f"'{key}' must be between {spec.min} and {spec.max} (got {value}).")
    steps = (value - spec.min) / spec.step
    if abs(steps - round(steps)) > 1e-9:
        raise ValueError(
            f"'{key}' must land on the increment grid: {spec.min} + n*{spec.step} (got {value})."
        )


def set_parameters(updates: dict[str, float]) -> None:
    """Validates every update against [min,max] and the increment grid before applying any of them."""
    for key, value in updates.items():
        _validate(key, value)
    with _param_lock:
        _param_values.update(updates)


def reset_parameters() -> None:
    with _param_lock:
        _param_values.clear()
        _param_values.update({key: spec.default for key, spec in PARAMETER_SPECS.items()})