alloc-context 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. alloc_context-0.1.0.dist-info/METADATA +154 -0
  2. alloc_context-0.1.0.dist-info/RECORD +85 -0
  3. alloc_context-0.1.0.dist-info/WHEEL +5 -0
  4. alloc_context-0.1.0.dist-info/entry_points.txt +4 -0
  5. alloc_context-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. alloc_context-0.1.0.dist-info/top_level.txt +1 -0
  7. alloccontext/__init__.py +3 -0
  8. alloccontext/__main__.py +149 -0
  9. alloccontext/config.py +415 -0
  10. alloccontext/horizon.py +30 -0
  11. alloccontext/ingest/__init__.py +1 -0
  12. alloccontext/ingest/cf_benchmarks.py +38 -0
  13. alloccontext/ingest/cf_history.py +65 -0
  14. alloccontext/ingest/coinbase_client.py +234 -0
  15. alloccontext/ingest/coinbase_portfolio.py +53 -0
  16. alloccontext/ingest/coingecko.py +148 -0
  17. alloccontext/ingest/coinmarketcap.py +135 -0
  18. alloccontext/ingest/env_keys.py +12 -0
  19. alloccontext/ingest/etf_flows.py +282 -0
  20. alloccontext/ingest/exchange/__init__.py +4 -0
  21. alloccontext/ingest/exchange/coinbase_adapter.py +64 -0
  22. alloccontext/ingest/exchange/kraken_adapter.py +66 -0
  23. alloccontext/ingest/exchange/live.py +95 -0
  24. alloccontext/ingest/exchange/portfolio.py +8 -0
  25. alloccontext/ingest/exchange/registry.py +27 -0
  26. alloccontext/ingest/exchange/types.py +5 -0
  27. alloccontext/ingest/exchange_http.py +28 -0
  28. alloccontext/ingest/fear_greed.py +89 -0
  29. alloccontext/ingest/fred.py +138 -0
  30. alloccontext/ingest/http_errors.py +29 -0
  31. alloccontext/ingest/kalshi.py +84 -0
  32. alloccontext/ingest/kalshi_api.py +199 -0
  33. alloccontext/ingest/kalshi_client.py +95 -0
  34. alloccontext/ingest/kalshi_files.py +44 -0
  35. alloccontext/ingest/kalshi_state.py +67 -0
  36. alloccontext/ingest/kraken_client.py +177 -0
  37. alloccontext/ingest/kraken_portfolio.py +161 -0
  38. alloccontext/ingest/macro_calendar.py +310 -0
  39. alloccontext/ingest/macro_normalize.py +98 -0
  40. alloccontext/ingest/market_snapshots.py +113 -0
  41. alloccontext/ingest/outcome.py +110 -0
  42. alloccontext/ingest/parse_helpers.py +23 -0
  43. alloccontext/ingest/runner.py +148 -0
  44. alloccontext/mcp/__init__.py +1 -0
  45. alloccontext/mcp/assets.py +153 -0
  46. alloccontext/mcp/bazaar.py +630 -0
  47. alloccontext/mcp/contracts.py +286 -0
  48. alloccontext/mcp/handlers.py +487 -0
  49. alloccontext/mcp/http.py +250 -0
  50. alloccontext/mcp/payment_middleware.py +211 -0
  51. alloccontext/mcp/server.py +319 -0
  52. alloccontext/mcp/staleness.py +30 -0
  53. alloccontext/mcp/validation.py +56 -0
  54. alloccontext/mcp/x402_bazaar_dynamic.py +104 -0
  55. alloccontext/mcp/x402_config.py +131 -0
  56. alloccontext/mcp/x402_pricing.py +55 -0
  57. alloccontext/mcp/x402_stables.py +179 -0
  58. alloccontext/rollup/__init__.py +1 -0
  59. alloccontext/rollup/band.py +50 -0
  60. alloccontext/rollup/breadth.py +45 -0
  61. alloccontext/rollup/cf_math.py +103 -0
  62. alloccontext/rollup/cluster.py +149 -0
  63. alloccontext/rollup/cluster_config.py +86 -0
  64. alloccontext/rollup/comparison.py +67 -0
  65. alloccontext/rollup/context.py +118 -0
  66. alloccontext/rollup/delta.py +109 -0
  67. alloccontext/rollup/etf.py +113 -0
  68. alloccontext/rollup/fear_greed.py +61 -0
  69. alloccontext/rollup/macro.py +185 -0
  70. alloccontext/rollup/portfolio.py +137 -0
  71. alloccontext/rollup/rebalance.py +125 -0
  72. alloccontext/rollup/regime.py +188 -0
  73. alloccontext/rollup/sentiment.py +118 -0
  74. alloccontext/rollup/snapshots.py +64 -0
  75. alloccontext/rollup/tape.py +176 -0
  76. alloccontext/status_report.py +321 -0
  77. alloccontext/store/__init__.py +0 -0
  78. alloccontext/store/db.py +216 -0
  79. alloccontext/store/jsonutil.py +10 -0
  80. alloccontext/store/meta.py +20 -0
  81. alloccontext/store/retention.py +63 -0
  82. alloccontext/store/status.py +89 -0
  83. alloccontext/timeutil.py +11 -0
  84. alloccontext/x402_production_check.py +193 -0
  85. alloccontext/x402_smoke_redact.py +41 -0
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from typing import Any
5
+
6
+ from alloccontext.rollup.cf_math import (
7
+ pct_change_over_minutes,
8
+ range_pct_over_minutes,
9
+ scale_pct_map,
10
+ trend_pct_for_index,
11
+ )
12
+ from alloccontext.rollup.cluster import (
13
+ MarketQuote,
14
+ build_cluster_snapshot,
15
+ cluster_advisory_fields,
16
+ sentiment_up_fraction,
17
+ )
18
+ from alloccontext.rollup.cluster_config import RollupConfig
19
+
20
+
21
+ def _volatility_regime(
22
+ range_pct: float | None,
23
+ *,
24
+ high_threshold: float,
25
+ medium_threshold: float,
26
+ ) -> str | None:
27
+ if range_pct is None:
28
+ return None
29
+ if range_pct >= high_threshold:
30
+ return "high"
31
+ if range_pct >= medium_threshold:
32
+ return "medium"
33
+ return "low"
34
+
35
+
36
+ def _dominant_vol_regime(regimes: dict[str, str]) -> str | None:
37
+ if not regimes:
38
+ return None
39
+ order = {"high": 3, "medium": 2, "low": 1}
40
+ return max(regimes.values(), key=lambda value: order.get(value, 0))
41
+
42
+
43
+ def format_tape_summary(
44
+ *,
45
+ trend_60m: dict[str, float | None],
46
+ vol_regime: str | None,
47
+ sentiment_up_frac: float | None,
48
+ ) -> str:
49
+ scored = [(asset, pct) for asset, pct in trend_60m.items() if pct is not None]
50
+ if not scored:
51
+ direction = "mixed"
52
+ else:
53
+ down = sum(1 for _, pct in scored if pct < -0.05)
54
+ up = sum(1 for _, pct in scored if pct > 0.05)
55
+ if down >= max(3, len(scored) - 1):
56
+ direction = "broad down"
57
+ elif up >= max(3, len(scored) - 1):
58
+ direction = "broad up"
59
+ elif up > down:
60
+ direction = "mixed, leaning up"
61
+ elif down > up:
62
+ direction = "mixed, leaning down"
63
+ else:
64
+ direction = "mixed"
65
+
66
+ if vol_regime == "high":
67
+ vol_label = "high short vol"
68
+ elif vol_regime == "medium":
69
+ vol_label = "elevated short vol"
70
+ elif vol_regime == "low":
71
+ vol_label = "calm short vol"
72
+ else:
73
+ vol_label = "short vol n/a"
74
+
75
+ if sentiment_up_frac is None:
76
+ sentiment_label = "hourly sentiment n/a"
77
+ elif sentiment_up_frac >= 0.55:
78
+ sentiment_label = "crowd leaning YES on hourly"
79
+ elif sentiment_up_frac <= 0.45:
80
+ sentiment_label = "crowd leaning NO on hourly"
81
+ else:
82
+ sentiment_label = "mixed hourly sentiment"
83
+
84
+ return f"Tape today: {direction}, {vol_label}, {sentiment_label}."
85
+
86
+
87
+ def build_live_tape_context(
88
+ config: RollupConfig,
89
+ *,
90
+ cf_history: dict[str, list[dict[str, Any]]] | None,
91
+ markets: list[MarketQuote],
92
+ now: dt.datetime | None = None,
93
+ ) -> tuple[dict[str, Any], dict[str, Any], dt.datetime] | None:
94
+ if not cf_history:
95
+ return None
96
+
97
+ now = now or dt.datetime.now(dt.timezone.utc)
98
+ ctx = config.context_filter
99
+ trend_cfg = config.trend_filter
100
+
101
+ trend_60m: dict[str, float | None] = {}
102
+ trend_15m: dict[str, float | None] = {}
103
+ trend_5m: dict[str, float | None] = {}
104
+ vol_by_asset: dict[str, str | None] = {}
105
+
106
+ for asset_row in config.crypto:
107
+ index = asset_row.cf_index
108
+ if not index:
109
+ continue
110
+ trend_60m[asset_row.asset] = trend_pct_for_index(
111
+ cf_history,
112
+ index,
113
+ now,
114
+ lookback_minutes=trend_cfg.lookback_minutes,
115
+ min_samples=trend_cfg.min_samples,
116
+ enabled=trend_cfg.enabled,
117
+ )
118
+ trend_15m[asset_row.asset] = pct_change_over_minutes(
119
+ cf_history,
120
+ index,
121
+ now,
122
+ lookback_minutes=ctx.short_drift_15m_minutes,
123
+ min_samples=ctx.min_samples_short,
124
+ )
125
+ trend_5m[asset_row.asset] = pct_change_over_minutes(
126
+ cf_history,
127
+ index,
128
+ now,
129
+ lookback_minutes=ctx.short_drift_5m_minutes,
130
+ min_samples=ctx.min_samples_short,
131
+ )
132
+ range_pct = range_pct_over_minutes(
133
+ cf_history,
134
+ index,
135
+ now,
136
+ lookback_minutes=ctx.volatility_lookback_minutes,
137
+ min_samples=ctx.min_samples_short,
138
+ )
139
+ vol_by_asset[asset_row.asset] = _volatility_regime(
140
+ range_pct,
141
+ high_threshold=ctx.high_volatility_pct,
142
+ medium_threshold=ctx.medium_volatility_pct,
143
+ )
144
+
145
+ cluster = build_cluster_snapshot(
146
+ config.cluster_log,
147
+ crypto_assets=config.crypto,
148
+ cf_history=cf_history,
149
+ markets=markets,
150
+ now=now,
151
+ min_samples_short=ctx.min_samples_short,
152
+ )
153
+ dominant_vol = _dominant_vol_regime(
154
+ {asset: regime for asset, regime in vol_by_asset.items() if regime}
155
+ )
156
+ sentiment_up_frac = cluster.sentiment_up_frac if cluster else None
157
+ if sentiment_up_frac is None and markets:
158
+ sentiment_up_frac, _ = sentiment_up_fraction(markets)
159
+
160
+ tape_context = {
161
+ "summary": format_tape_summary(
162
+ trend_60m=scale_pct_map(trend_60m),
163
+ vol_regime=dominant_vol,
164
+ sentiment_up_frac=sentiment_up_frac,
165
+ ),
166
+ "trend_60m_pct": scale_pct_map(trend_60m),
167
+ "trend_15m_pct": scale_pct_map(trend_15m),
168
+ "trend_5m_pct": scale_pct_map(trend_5m),
169
+ "volatility_regime": dominant_vol,
170
+ "volatility_by_asset": {
171
+ asset: regime for asset, regime in vol_by_asset.items() if regime
172
+ }
173
+ or None,
174
+ }
175
+ cluster_context = cluster_advisory_fields(cluster)
176
+ return tape_context, cluster_context, now
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+ from alloccontext.config import AppConfig
12
+ from alloccontext.mcp.staleness import age_seconds, parse_as_of
13
+ from alloccontext.store.db import SCHEMA_VERSION
14
+ from alloccontext.store.status import ingest_status
15
+ from alloccontext.timeutil import utc_now
16
+
17
+
18
+ def default_mcp_health_url() -> str:
19
+ explicit = os.environ.get("ALLOC_CONTEXT_HEALTH_URL", "").strip()
20
+ if explicit:
21
+ return explicit
22
+ host = os.environ.get("ALLOC_CONTEXT_MCP_HEALTH_HOST", "127.0.0.1").strip()
23
+ port = os.environ.get("ALLOC_CONTEXT_MCP_PORT", "8000").strip()
24
+ return f"http://{host}:{port}/health"
25
+
26
+
27
+ def _age_from_iso(ts: str | None, *, now: datetime | None = None) -> int | None:
28
+ if not ts:
29
+ return None
30
+ try:
31
+ return age_seconds(parse_as_of(str(ts)), now=now)
32
+ except (TypeError, ValueError):
33
+ return None
34
+
35
+
36
+ def format_age(seconds: int | None) -> str:
37
+ if seconds is None:
38
+ return "unknown"
39
+ if seconds < 60:
40
+ return f"{seconds}s ago"
41
+ if seconds < 3600:
42
+ return f"{seconds // 60}m ago"
43
+ if seconds < 86_400:
44
+ return f"{seconds // 3600}h ago"
45
+ return f"{seconds // 86_400}d ago"
46
+
47
+
48
+ def probe_mcp_health(url: str, *, timeout_seconds: float = 5.0) -> dict[str, Any]:
49
+ try:
50
+ response = requests.get(url, timeout=timeout_seconds)
51
+ detail: dict[str, Any] = {
52
+ "url": url,
53
+ "reachable": True,
54
+ "http_status": response.status_code,
55
+ "ok": response.status_code == 200,
56
+ }
57
+ if response.status_code != 200:
58
+ detail["error"] = response.text[:200]
59
+ return detail
60
+ try:
61
+ body = response.json()
62
+ except json.JSONDecodeError:
63
+ detail["ok"] = False
64
+ detail["error"] = "response was not JSON"
65
+ return detail
66
+ if not isinstance(body, dict):
67
+ detail["ok"] = False
68
+ detail["error"] = "response was not a JSON object"
69
+ return detail
70
+ detail["service_ok"] = bool(body.get("ok"))
71
+ detail["ingest_ok"] = body.get("ingest_ok")
72
+ detail["source_health"] = body.get("source_health")
73
+ detail["status_detail"] = body.get("status_detail")
74
+ if not detail["service_ok"]:
75
+ detail["ok"] = False
76
+ return detail
77
+ except requests.RequestException as exc:
78
+ return {
79
+ "url": url,
80
+ "reachable": False,
81
+ "ok": False,
82
+ "error": str(exc),
83
+ }
84
+
85
+
86
+ def _classify_sources(
87
+ config: AppConfig,
88
+ last_by_source: dict[str, dict[str, Any]],
89
+ *,
90
+ now: datetime,
91
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
92
+ optional = config.ingest.optional_sources
93
+ required: list[dict[str, Any]] = []
94
+ optional_rows: list[dict[str, Any]] = []
95
+
96
+ def row_for(name: str, ingest_row: dict[str, Any] | None) -> dict[str, Any]:
97
+ if ingest_row is None:
98
+ return {
99
+ "source": name,
100
+ "ok": False,
101
+ "never_run": True,
102
+ "finished_at": None,
103
+ "age_seconds": None,
104
+ "rows_upserted": None,
105
+ "error": "no ingest run recorded",
106
+ }
107
+ finished_at = ingest_row.get("finished_at")
108
+ age = _age_from_iso(finished_at, now=now)
109
+ error = ingest_row.get("error")
110
+ return {
111
+ "source": name,
112
+ "ok": error is None,
113
+ "never_run": False,
114
+ "finished_at": finished_at,
115
+ "age_seconds": age,
116
+ "rows_upserted": ingest_row.get("rows_upserted"),
117
+ "error": error,
118
+ }
119
+
120
+ for name, enabled in sorted(config.ingest.sources.items()):
121
+ if not enabled:
122
+ continue
123
+ entry = row_for(name, last_by_source.get(name))
124
+ if name in optional:
125
+ optional_rows.append(entry)
126
+ else:
127
+ required.append(entry)
128
+
129
+ enabled_names = {n for n, e in config.ingest.sources.items() if e}
130
+ for name, ingest_row in sorted(last_by_source.items()):
131
+ if name in enabled_names:
132
+ continue
133
+ if name not in optional:
134
+ continue
135
+ optional_rows.append(row_for(name, ingest_row))
136
+
137
+ return required, optional_rows
138
+
139
+
140
+ def mcp_health_ingest_summary(
141
+ config: AppConfig,
142
+ conn: sqlite3.Connection,
143
+ *,
144
+ now: datetime | None = None,
145
+ ) -> dict[str, Any]:
146
+ """Required-only ingest_ok for public MCP /health."""
147
+ ref = now or utc_now()
148
+ snapshot = ingest_status(conn, now=ref)
149
+ last_by_source = snapshot.get("last_ingest_by_source") or {}
150
+ required, optional_rows = _classify_sources(config, last_by_source, now=ref)
151
+ required_failures = [r["source"] for r in required if not r["ok"]]
152
+ optional_failures = [r["source"] for r in optional_rows if not r["ok"]]
153
+ return {
154
+ "ingest_ok": not required_failures,
155
+ "required_failures": required_failures,
156
+ "optional_feed_failures": optional_failures,
157
+ "source_health": snapshot.get("source_health") or {},
158
+ }
159
+
160
+
161
+ def build_status_report(
162
+ config: AppConfig,
163
+ conn: sqlite3.Connection,
164
+ *,
165
+ probe_mcp: bool = True,
166
+ mcp_health_url: str | None = None,
167
+ mcp_timeout_seconds: float = 5.0,
168
+ ) -> dict[str, Any]:
169
+ now = utc_now()
170
+ snapshot = ingest_status(conn, now=now)
171
+ last_by_source = snapshot.get("last_ingest_by_source") or {}
172
+ required, optional_rows = _classify_sources(config, last_by_source, now=now)
173
+
174
+ context_rows = conn.execute(
175
+ """
176
+ SELECT scope, as_of FROM context_snapshots
177
+ ORDER BY scope, as_of DESC
178
+ """
179
+ ).fetchall()
180
+ latest_by_scope: dict[str, str] = {}
181
+ for row in context_rows:
182
+ scope = str(row["scope"])
183
+ if scope not in latest_by_scope:
184
+ latest_by_scope[scope] = str(row["as_of"])
185
+
186
+ context_snapshots = [
187
+ {
188
+ "scope": scope,
189
+ "as_of": as_of,
190
+ "age_seconds": _age_from_iso(as_of, now=now),
191
+ }
192
+ for scope, as_of in sorted(latest_by_scope.items())
193
+ ]
194
+
195
+ portfolio = snapshot.get("portfolio_latest")
196
+ portfolio_freshness: dict[str, Any] | None = None
197
+ if portfolio and portfolio.get("ts"):
198
+ ts = str(portfolio["ts"])
199
+ portfolio_freshness = {
200
+ "ts": ts,
201
+ "age_seconds": _age_from_iso(ts, now=now),
202
+ "nav_usd": portfolio.get("nav_usd"),
203
+ }
204
+
205
+ required_failures = [r["source"] for r in required if not r["ok"]]
206
+ optional_failures = [r["source"] for r in optional_rows if not r["ok"]]
207
+ ingest_ok = not required_failures
208
+
209
+ mcp: dict[str, Any] | None = None
210
+ if probe_mcp:
211
+ url = (mcp_health_url or default_mcp_health_url()).strip()
212
+ mcp = probe_mcp_health(url, timeout_seconds=mcp_timeout_seconds)
213
+
214
+ summary_ok = ingest_ok and (mcp is None or mcp.get("ok"))
215
+
216
+ return {
217
+ "ok": summary_ok,
218
+ "db": str(config.paths.db),
219
+ "schema_version": SCHEMA_VERSION,
220
+ "horizon_days": config.horizon.days,
221
+ "ingest_sources_enabled": config.ingest.sources,
222
+ "ingest_ok": ingest_ok,
223
+ "required_failures": required_failures,
224
+ "optional_failures": optional_failures,
225
+ "ingest": {"required": required, "optional": optional_rows},
226
+ "context_snapshots": context_snapshots,
227
+ "portfolio_snapshot": portfolio_freshness,
228
+ "fear_greed_latest": snapshot.get("fear_greed_latest"),
229
+ "market_bars": snapshot.get("market_bars"),
230
+ "source_health": snapshot.get("source_health"),
231
+ "last_ingest_by_source": last_by_source,
232
+ "recent_ingest": snapshot.get("recent_ingest"),
233
+ "mcp_health": mcp,
234
+ "summary": {
235
+ "ok": summary_ok,
236
+ "ingest_ok": ingest_ok,
237
+ "mcp_ok": mcp.get("ok") if mcp else None,
238
+ },
239
+ }
240
+
241
+
242
+ def _format_ingest_section(title: str, rows: list[dict[str, Any]]) -> list[str]:
243
+ lines = [title]
244
+ if not rows:
245
+ lines.append(" (none)")
246
+ return lines
247
+ for row in rows:
248
+ name = row["source"]
249
+ if row.get("never_run"):
250
+ lines.append(f" {name:<16} NEVER RUN")
251
+ continue
252
+ status = "ok" if row.get("ok") else "FAIL"
253
+ age = format_age(row.get("age_seconds"))
254
+ rows_n = row.get("rows_upserted")
255
+ extra = f" rows={rows_n}" if rows_n is not None else ""
256
+ line = f" {name:<16} {status:<4} {age}{extra}"
257
+ if not row.get("ok") and row.get("error"):
258
+ err = str(row["error"])[:80]
259
+ line = f"{line} ({err})"
260
+ lines.append(line)
261
+ return lines
262
+
263
+
264
+ def format_status_report(report: dict[str, Any]) -> str:
265
+ lines = ["AllocContext status", f"DB: {report['db']} (schema v{report['schema_version']})"]
266
+ lines.append(f"Horizon: {report['horizon_days']} days")
267
+ lines.append("")
268
+
269
+ ingest = report.get("ingest") or {}
270
+ lines.extend(_format_ingest_section("Ingest (required)", ingest.get("required") or []))
271
+ lines.append("")
272
+ lines.extend(_format_ingest_section("Ingest (optional)", ingest.get("optional") or []))
273
+ lines.append("")
274
+
275
+ lines.append("Context snapshots")
276
+ snapshots = report.get("context_snapshots") or []
277
+ if not snapshots:
278
+ lines.append(" (none)")
279
+ else:
280
+ for row in snapshots:
281
+ age = format_age(row.get("age_seconds"))
282
+ lines.append(f" {row['scope']:<8} {row['as_of']} ({age})")
283
+ lines.append("")
284
+
285
+ portfolio = report.get("portfolio_snapshot")
286
+ if portfolio:
287
+ age = format_age(portfolio.get("age_seconds"))
288
+ nav = portfolio.get("nav_usd")
289
+ nav_part = f" NAV ${nav}" if nav is not None else ""
290
+ lines.append(f"Portfolio snapshot: {portfolio['ts']} ({age}){nav_part}")
291
+ else:
292
+ lines.append("Portfolio snapshot: (none)")
293
+ lines.append("")
294
+
295
+ mcp = report.get("mcp_health")
296
+ if mcp is None:
297
+ lines.append("MCP /health: (probe skipped)")
298
+ else:
299
+ lines.append(f"MCP /health: {mcp.get('url')}")
300
+ if not mcp.get("reachable"):
301
+ lines.append(f" unreachable ({mcp.get('error', 'unknown')})")
302
+ else:
303
+ http = mcp.get("http_status")
304
+ lines.append(f" HTTP {http} service_ok={mcp.get('service_ok')}")
305
+ if mcp.get("ingest_ok") is not None:
306
+ lines.append(f" ingest_ok={mcp.get('ingest_ok')}")
307
+ if mcp.get("status_detail"):
308
+ lines.append(f" detail: {mcp['status_detail']}")
309
+ if mcp.get("error") and not mcp.get("ok"):
310
+ lines.append(f" error: {mcp['error']}")
311
+
312
+ summary = report.get("summary") or {}
313
+ lines.append("")
314
+ overall = "OK" if summary.get("ok") else "ATTENTION"
315
+ lines.append(f"Overall: {overall}")
316
+ if not summary.get("ingest_ok"):
317
+ failed = report.get("required_failures") or []
318
+ lines.append(f" required ingest failures: {', '.join(failed)}")
319
+ if summary.get("mcp_ok") is False:
320
+ lines.append(" MCP health check failed")
321
+ return "\n".join(lines)
File without changes