Unify runtime state in SQLite and add rollout observability

This commit is contained in:
2569718930@qq.com
2026-03-20 23:00:07 +08:00
parent 6b76290cff
commit 43749fff7c
24 changed files with 1875 additions and 15 deletions
@@ -0,0 +1,53 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
TelegramAlertStateRepository,
)
def main():
parser = argparse.ArgumentParser(description='Export runtime state from SQLite back to legacy JSON files.')
parser.add_argument('--daily-records', default=os.path.join(PROJECT_ROOT, 'data', 'daily_records.json'))
parser.add_argument('--telegram-state', default=os.path.join(PROJECT_ROOT, 'data', 'telegram_alert_state.json'))
parser.add_argument('--snapshots', default=os.path.join(PROJECT_ROOT, 'data', 'probability_training_snapshots.jsonl'))
parser.add_argument('--open-meteo-cache', default=os.path.join(PROJECT_ROOT, 'data', 'open_meteo_cache.json'))
parser.add_argument('--open-meteo-max-age', type=int, default=int(os.getenv('OPEN_METEO_DISK_CACHE_MAX_AGE_SEC', '86400')))
args = parser.parse_args()
daily = DailyRecordRepository().load_all()
telegram = TelegramAlertStateRepository().load_state()
snapshots = ProbabilitySnapshotRepository().load_all_rows()
open_meteo = OpenMeteoCacheRepository().load_payload(args.open_meteo_max_age)
os.makedirs(os.path.dirname(os.path.abspath(args.daily_records)), exist_ok=True)
with open(args.daily_records, 'w', encoding='utf-8') as fh:
json.dump(daily, fh, ensure_ascii=False, indent=2)
with open(args.telegram_state, 'w', encoding='utf-8') as fh:
json.dump(telegram, fh, ensure_ascii=False, indent=2)
with open(args.snapshots, 'w', encoding='utf-8') as fh:
for row in snapshots:
fh.write(json.dumps(row, ensure_ascii=False) + '\n')
with open(args.open_meteo_cache, 'w', encoding='utf-8') as fh:
json.dump(open_meteo, fh, ensure_ascii=False)
print(json.dumps({
'daily_records_exported': sum(len(v) for v in daily.values()),
'telegram_state_exported': len((telegram.get('last_by_city') or {})) + len((telegram.get('by_signature') or {})),
'snapshots_exported': len(snapshots),
'open_meteo_cache_exported': sum(len((open_meteo.get(k) or {})) for k in ('forecast', 'ensemble', 'multi_model')),
}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+7
View File
@@ -14,6 +14,11 @@ from src.analysis.probability_calibration import ( # noqa: E402
fit_calibration,
)
from src.analysis.deb_algorithm import load_history # noqa: E402
from src.database.runtime_state import ( # noqa: E402
ProbabilitySnapshotRepository,
STATE_STORAGE_SQLITE,
get_state_storage_mode,
)
def _sf(value):
@@ -41,6 +46,8 @@ def _load_history_with_fallback(path):
def _load_snapshot_rows(path):
if get_state_storage_mode() == STATE_STORAGE_SQLITE:
return ProbabilitySnapshotRepository().load_all_rows()
rows = []
if not path or not os.path.exists(path):
return rows
+56
View File
@@ -0,0 +1,56 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.analysis.probability_rollout import build_rollout_report # noqa: E402
def main():
parser = argparse.ArgumentParser(description="Judge whether EMOS is ready for primary rollout.")
parser.add_argument(
"--evaluation-report",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"evaluation_report.json",
),
)
parser.add_argument(
"--shadow-report",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"shadow_report.json",
),
)
parser.add_argument(
"--output",
default=os.path.join(
PROJECT_ROOT,
"artifacts",
"probability_calibration",
"rollout_report.json",
),
)
args = parser.parse_args()
payload = build_rollout_report(args.evaluation_report, args.shadow_report)
output_dir = os.path.dirname(os.path.abspath(args.output))
if output_dir:
os.makedirs(output_dir, exist_ok=True)
with open(args.output, "w", encoding="utf-8") as fh:
json.dump(payload, fh, ensure_ascii=False, indent=2)
print(json.dumps(payload["decision"], ensure_ascii=False, indent=2))
print(f"saved rollout report to {args.output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
TelegramAlertStateRepository,
)
def _load_json(path, default):
if not path or not os.path.exists(path):
return default
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
return data
def _load_jsonl(path):
rows = []
if not path or not os.path.exists(path):
return rows
with open(path, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def main():
parser = argparse.ArgumentParser(description='Migrate runtime JSON state into SQLite.')
parser.add_argument('--daily-records', default=os.path.join(PROJECT_ROOT, 'data', 'daily_records.json'))
parser.add_argument('--telegram-state', default=os.path.join(PROJECT_ROOT, 'data', 'telegram_alert_state.json'))
parser.add_argument('--snapshots', default=os.path.join(PROJECT_ROOT, 'data', 'probability_training_snapshots.jsonl'))
parser.add_argument('--open-meteo-cache', default=os.path.join(PROJECT_ROOT, 'data', 'open_meteo_cache.json'))
parser.add_argument('--open-meteo-max-age', type=int, default=int(os.getenv('OPEN_METEO_DISK_CACHE_MAX_AGE_SEC', '86400')))
args = parser.parse_args()
daily = _load_json(args.daily_records, {})
telegram = _load_json(args.telegram_state, {'last_by_city': {}, 'by_signature': {}})
snapshots = _load_jsonl(args.snapshots)
open_meteo = _load_json(args.open_meteo_cache, {'forecast': {}, 'ensemble': {}, 'multi_model': {}, 'saved_at': 0})
daily_count = DailyRecordRepository().replace_all(daily if isinstance(daily, dict) else {})
telegram_count = TelegramAlertStateRepository().replace_from_state(telegram if isinstance(telegram, dict) else {})
snapshot_count = ProbabilitySnapshotRepository().replace_all(snapshots)
cache_count = OpenMeteoCacheRepository().replace_payload(open_meteo if isinstance(open_meteo, dict) else {}, args.open_meteo_max_age)
print(json.dumps({
'daily_records_imported': daily_count,
'telegram_state_imported': telegram_count,
'snapshots_imported': snapshot_count,
'open_meteo_cache_imported': cache_count,
}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+101
View File
@@ -0,0 +1,101 @@
import argparse
import json
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from src.database.runtime_state import ( # noqa: E402
DailyRecordRepository,
OpenMeteoCacheRepository,
ProbabilitySnapshotRepository,
TelegramAlertStateRepository,
)
def _load_json(path, default):
if not path or not os.path.exists(path):
return default
with open(path, 'r', encoding='utf-8') as fh:
return json.load(fh)
def _load_jsonl(path):
rows = []
if not path or not os.path.exists(path):
return rows
with open(path, 'r', encoding='utf-8') as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def _norm_json(obj):
return json.loads(json.dumps(obj, ensure_ascii=False, sort_keys=True))
def _norm_open_meteo_payload(payload):
payload = dict(payload or {})
payload.pop('saved_at', None)
return _norm_json(payload)
def main():
parser = argparse.ArgumentParser(description='Verify SQLite runtime state against legacy JSON files.')
parser.add_argument('--daily-records', default=os.path.join(PROJECT_ROOT, 'data', 'daily_records.json'))
parser.add_argument('--telegram-state', default=os.path.join(PROJECT_ROOT, 'data', 'telegram_alert_state.json'))
parser.add_argument('--snapshots', default=os.path.join(PROJECT_ROOT, 'data', 'probability_training_snapshots.jsonl'))
parser.add_argument('--open-meteo-cache', default=os.path.join(PROJECT_ROOT, 'data', 'open_meteo_cache.json'))
parser.add_argument('--open-meteo-max-age', type=int, default=int(os.getenv('OPEN_METEO_DISK_CACHE_MAX_AGE_SEC', '86400')))
args = parser.parse_args()
file_daily = _load_json(args.daily_records, {})
file_telegram = _load_json(args.telegram_state, {'last_by_city': {}, 'by_signature': {}})
file_snapshots = _load_jsonl(args.snapshots)
file_cache = _load_json(args.open_meteo_cache, {'forecast': {}, 'ensemble': {}, 'multi_model': {}, 'saved_at': 0})
db_daily = DailyRecordRepository().load_all()
db_telegram = TelegramAlertStateRepository().load_state()
db_snapshots = ProbabilitySnapshotRepository().load_all_rows()
db_cache = OpenMeteoCacheRepository().load_payload(args.open_meteo_max_age)
report = {
'daily_records': {
'file_cities': len(file_daily or {}),
'db_cities': len(db_daily or {}),
'equal': _norm_json(file_daily or {}) == _norm_json(db_daily or {}),
},
'telegram_state': {
'equal': _norm_json(file_telegram or {}) == _norm_json(db_telegram or {}),
'file_last_by_city': len((file_telegram or {}).get('last_by_city') or {}),
'db_last_by_city': len((db_telegram or {}).get('last_by_city') or {}),
},
'snapshots': {
'file_rows': len(file_snapshots),
'db_rows': len(db_snapshots),
'equal': _norm_json(file_snapshots) == _norm_json(db_snapshots),
},
'open_meteo_cache': {
'file_forecast': len((file_cache or {}).get('forecast') or {}),
'db_forecast': len((db_cache or {}).get('forecast') or {}),
'equal': _norm_open_meteo_payload(file_cache or {}) == _norm_open_meteo_payload(db_cache or {}),
},
}
report['ok'] = all(section.get('equal') for section in report.values() if isinstance(section, dict))
print(json.dumps(report, ensure_ascii=False, indent=2))
raise SystemExit(0 if report['ok'] else 1)
if __name__ == '__main__':
main()