diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 63c0acea..8006b7ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,7 @@ jobs: run: pip install tox - name: Run tests (via tox) + id: tests run: tox -e ci # Posts a check annotation + PR comment showing pass/fail counts. @@ -63,6 +64,20 @@ jobs: comment_title: "Test Results" report_individual_runs: true + - name: Enforce coverage floor (60%) + if: always() && steps.tests.outcome == 'success' + run: | + python -c " + import xml.etree.ElementTree as ET, sys + tree = ET.parse('reports/coverage.xml') + rate = float(tree.getroot().attrib['line-rate']) * 100 + print(f'Coverage: {rate:.1f}%') + if rate < 60: + print(f'FAIL: Coverage {rate:.1f}% is below 60% floor') + sys.exit(1) + print('PASS: Coverage is above 60% floor') + " + # Coverage report available as a downloadable artifact in the Actions tab - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/config/moderation.yaml b/config/moderation.yaml new file mode 100644 index 00000000..875e554d --- /dev/null +++ b/config/moderation.yaml @@ -0,0 +1,107 @@ +# Content Moderation Profiles +# Per-game moderation configuration for the AI narrator pipeline. +# +# Each profile defines: +# - vocabulary_whitelist: Game terms safe in context (won't trigger moderation) +# - context_prompt: System prompt framing for the narrator +# - threshold: Confidence threshold — flags below this pass through +# - fallbacks: Pre-generated safe narration by scene type +# +# Model options (from research): +# llama-guard3:1b — Speed (<30ms/sentence, INT4 quantized) +# shieldgemma:2b — Accuracy (+10.8% AU-PRC, ~50-100ms) +# +# Override guard model via MODERATION_GUARD_MODEL env var. + +# ── Guard model selection ──────────────────────────────────────────────────── +guard_model: "llama-guard3:1b" + +# ── Streaming disclosure notes ─────────────────────────────────────────────── +# YouTube: Use "Altered or synthetic content" toggle +# Twitch: Standard community guidelines (no specific AI disclosure req as of 2026-03) + +# ── Game Profiles ──────────────────────────────────────────────────────────── +profiles: + + morrowind: + display_name: "The Elder Scrolls III: Morrowind" + threshold: 0.85 + vocabulary_whitelist: + - Skooma + - Moon Sugar + - slave + - slavery + - Morag Tong + - Dark Brotherhood + - Telvanni + - Camonna Tong + - smuggler + - assassin + - Sixth House + - Corprus + - Dagoth Ur + - Nerevarine + - Balmora + - Vivec + - Almsivi + - Ordinators + - Ashlanders + - outlander + - N'wah + context_prompt: > + You are narrating gameplay of The Elder Scrolls III: Morrowind. + Morrowind contains mature themes including slavery, drug use + (Skooma/Moon Sugar), assassin guilds (Morag Tong, Dark Brotherhood), + and political intrigue. Treat these as game mechanics and historical + worldbuilding within the game's fictional universe. Never editorialize + on real-world parallels. Narrate events neutrally as a game + commentator would. + fallbacks: + combat: "The battle rages on in the ashlands of Vvardenfell." + dialogue: "The conversation continues between the characters." + exploration: "The Nerevarine presses onward through the landscape." + quest: "The quest unfolds as the hero navigates Morrowind's politics." + default: "The adventure continues in Morrowind." + + skyrim: + display_name: "The Elder Scrolls V: Skyrim" + threshold: 0.85 + vocabulary_whitelist: + - Skooma + - Dark Brotherhood + - Thieves Guild + - Stormcloak + - Imperial + - Dragonborn + - Dovahkiin + - Daedra + - Thalmor + - bandit + - assassin + - Forsworn + - necromancer + context_prompt: > + You are narrating gameplay of The Elder Scrolls V: Skyrim. + Skyrim features civil war, thieves guilds, assassin organizations, + and fantasy violence. Treat all content as in-game fiction. + Never draw real-world parallels. Narrate as a neutral game + commentator. + fallbacks: + combat: "Steel clashes as the battle continues in the wilds of Skyrim." + dialogue: "The conversation plays out in the cold northern land." + exploration: "The Dragonborn ventures further into the province." + default: "The adventure continues in Skyrim." + + default: + display_name: "Generic Game" + threshold: 0.80 + vocabulary_whitelist: [] + context_prompt: > + You are narrating gameplay. Describe in-game events as a neutral + game commentator. Never reference real-world violence, politics, + or controversial topics. Stay focused on game mechanics and story. + fallbacks: + combat: "The action continues on screen." + dialogue: "The conversation unfolds between characters." + exploration: "The player explores the game world." + default: "The gameplay continues." diff --git a/src/config.py b/src/config.py index 62b46809..192c44e7 100644 --- a/src/config.py +++ b/src/config.py @@ -99,6 +99,14 @@ class Settings(BaseSettings): anthropic_api_key: str = "" claude_model: str = "haiku" + # ── Content Moderation ────────────────────────────────────────────── + # Three-layer moderation pipeline for AI narrator output. + # Uses Llama Guard via Ollama with regex fallback. + moderation_enabled: bool = True + moderation_guard_model: str = "llama-guard3:1b" + # Default confidence threshold — per-game profiles can override. + moderation_threshold: float = 0.8 + # ── Spark Intelligence ──────────────────────────────────────────────── # Enable/disable the Spark cognitive layer. # When enabled, Spark captures swarm events, runs EIDOS predictions, @@ -144,6 +152,10 @@ class Settings(BaseSettings): # Default is False (telemetry disabled) to align with sovereign AI vision. telemetry_enabled: bool = False + # ── Sovereignty Metrics ────────────────────────────────────────────── + # Alert when API cost per research task exceeds this threshold (USD). + sovereignty_api_cost_alert_threshold: float = 1.00 + # CORS allowed origins for the web chat interface (Gitea Pages, etc.) # Set CORS_ORIGINS as a comma-separated list, e.g. "http://localhost:3000,https://example.com" cors_origins: list[str] = [ diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 7e1ccba9..042b9965 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -45,6 +45,7 @@ from dashboard.routes.models import api_router as models_api_router from dashboard.routes.models import router as models_router from dashboard.routes.quests import router as quests_router from dashboard.routes.scorecards import router as scorecards_router +from dashboard.routes.sovereignty_metrics import router as sovereignty_metrics_router from dashboard.routes.spark import router as spark_router from dashboard.routes.system import router as system_router from dashboard.routes.tasks import router as tasks_router @@ -631,6 +632,7 @@ app.include_router(tower_router) app.include_router(daily_run_router) app.include_router(quests_router) app.include_router(scorecards_router) +app.include_router(sovereignty_metrics_router) @app.websocket("/ws") diff --git a/src/dashboard/routes/sovereignty_metrics.py b/src/dashboard/routes/sovereignty_metrics.py new file mode 100644 index 00000000..3bffe95f --- /dev/null +++ b/src/dashboard/routes/sovereignty_metrics.py @@ -0,0 +1,74 @@ +"""Sovereignty metrics dashboard routes. + +Provides API endpoints and HTMX partials for tracking research +sovereignty progress against graduation targets. + +Refs: #981 +""" + +import logging +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from config import settings +from dashboard.templating import templates +from infrastructure.sovereignty_metrics import ( + GRADUATION_TARGETS, + get_sovereignty_store, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/sovereignty", tags=["sovereignty"]) + + +@router.get("/metrics") +async def sovereignty_metrics_api() -> dict[str, Any]: + """JSON API: full sovereignty metrics summary with trends.""" + store = get_sovereignty_store() + summary = store.get_summary() + alerts = store.get_alerts(unacknowledged_only=True) + return { + "metrics": summary, + "alerts": alerts, + "targets": GRADUATION_TARGETS, + "cost_threshold": settings.sovereignty_api_cost_alert_threshold, + } + + +@router.get("/metrics/panel", response_class=HTMLResponse) +async def sovereignty_metrics_panel(request: Request) -> HTMLResponse: + """HTMX partial: sovereignty metrics progress panel.""" + store = get_sovereignty_store() + summary = store.get_summary() + alerts = store.get_alerts(unacknowledged_only=True) + + return templates.TemplateResponse( + request, + "partials/sovereignty_metrics.html", + { + "metrics": summary, + "alerts": alerts, + "targets": GRADUATION_TARGETS, + }, + ) + + +@router.get("/alerts") +async def sovereignty_alerts_api() -> dict[str, Any]: + """JSON API: sovereignty alerts.""" + store = get_sovereignty_store() + return { + "alerts": store.get_alerts(unacknowledged_only=False), + "unacknowledged": store.get_alerts(unacknowledged_only=True), + } + + +@router.post("/alerts/{alert_id}/acknowledge") +async def acknowledge_alert(alert_id: int) -> dict[str, bool]: + """Acknowledge a sovereignty alert.""" + store = get_sovereignty_store() + success = store.acknowledge_alert(alert_id) + return {"success": success} diff --git a/src/dashboard/templates/mission_control.html b/src/dashboard/templates/mission_control.html index 27acbd15..a090ff5b 100644 --- a/src/dashboard/templates/mission_control.html +++ b/src/dashboard/templates/mission_control.html @@ -179,6 +179,13 @@ + +{% call panel("SOVEREIGNTY METRICS", id="sovereignty-metrics-panel", + hx_get="/sovereignty/metrics/panel", + hx_trigger="load, every 30s") %} +
Loading sovereignty metrics...
+{% endcall %} +