grantforge-api / backend /endpoints /admin_diagnostics.py
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
17.4 kB
import os
import httpx
import time
from fastapi import APIRouter, Depends, Request, Query, HTTPException
from fastapi.responses import StreamingResponse
from core.telemetry import telemetry
from integrations.eurlex_client import EURLexClient
from core.search.regulation_snapshot import regulation_snapshot_store
from endpoints.admin import verify_admin
from core.subscription.middleware import verify_bearer_token
import secrets
router = APIRouter()
_DIAG_STREAM_TICKETS: dict[str, dict] = {}
_DIAG_TICKET_TTL = 60
def _mint_diag_ticket(user_id: str) -> str:
now = time.time()
for k, v in list(_DIAG_STREAM_TICKETS.items()):
if v.get("exp", 0) < now or v.get("used"):
_DIAG_STREAM_TICKETS.pop(k, None)
ticket = secrets.token_urlsafe(32)
_DIAG_STREAM_TICKETS[ticket] = {"user_id": user_id, "exp": now + _DIAG_TICKET_TTL, "used": False}
return ticket
def _consume_diag_ticket(ticket: str) -> str:
entry = _DIAG_STREAM_TICKETS.pop(ticket, None)
if not entry or entry.get("used") or entry.get("exp", 0) < time.time():
raise HTTPException(status_code=401, detail="Invalid or expired stream ticket")
return entry["user_id"]
async def ping_service(url: str, env_var: str):
start = time.perf_counter()
api_key = os.getenv(env_var)
if not api_key:
return {"status": "error", "message": f"{env_var} not set", "latency_ms": 0}
try:
async with httpx.AsyncClient(timeout=3.0) as client:
await client.get(url)
latency = int((time.perf_counter() - start) * 1000)
return {
"status": "ok",
"message": "API Key configured and service reachable",
"latency_ms": latency,
}
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
async def check_gemini():
return await ping_service(
"https://generativelanguage.googleapis.com/v1beta/models", "GOOGLE_API_KEY"
)
async def check_grok():
# Akceptuj XAI_API_KEY lub GROK_API_KEY (HF Secret zwykle: XAI_API_KEY)
env_var = (
"XAI_API_KEY"
if os.getenv("XAI_API_KEY")
else "GROK_API_KEY"
)
return await ping_service("https://api.x.ai/v1/models", env_var)
async def check_pinecone():
return await ping_service("https://api.pinecone.io", "PINECONE_API_KEY")
async def check_clerk():
return await ping_service(
"https://api.clerk.com/v1/users?limit=1", "CLERK_SECRET_KEY"
)
async def check_langsmith():
return await ping_service(
"https://api.smith.langchain.com/api/v1/workspaces", "LANGCHAIN_API_KEY"
)
async def check_crawl4ai():
from core.crawl4ai_client import check_crawl4ai_health
return await check_crawl4ai_health()
async def check_neo4j():
start = time.perf_counter()
neo4j_uri = os.getenv("NEO4J_URI")
if not neo4j_uri or "..." in neo4j_uri:
return {
"status": "disabled",
"message": "Neo4j nie jest skonfigurowane (NEO4J_URI brak). GraphRAG działa w trybie ograniczonym — opcjonalne dla MSP.",
"latency_ms": 0,
}
try:
from neo4j import AsyncGraphDatabase
driver = AsyncGraphDatabase.driver(
neo4j_uri,
auth=(os.getenv("NEO4J_USERNAME", os.getenv("NEO4J_USER", "neo4j")), os.getenv("NEO4J_PASSWORD", "")),
)
await driver.verify_connectivity()
await driver.close()
latency = int((time.perf_counter() - start) * 1000)
return {
"status": "ok",
"message": "Neo4j connection successful",
"latency_ms": latency,
}
except ImportError:
latency = int((time.perf_counter() - start) * 1000)
return {
"status": "ok",
"message": "NEO4J_URI configured",
"latency_ms": latency,
}
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
async def check_database():
start = time.perf_counter()
try:
from core.subscription.db import SessionLocal
from sqlalchemy import text
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
latency = int((time.perf_counter() - start) * 1000)
return {
"status": "ok",
"message": "Database connection successful",
"latency_ms": latency,
}
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
async def check_eurlex():
start = time.perf_counter()
try:
client = EURLexClient()
result = client.check_status()
latency = int((time.perf_counter() - start) * 1000)
result["latency_ms"] = latency
return result
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
async def check_parp():
start = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=3.0) as client:
await client.get("https://www.parp.gov.pl/")
latency = int((time.perf_counter() - start) * 1000)
return {"status": "ok", "message": "PARP website reachable", "latency_ms": latency}
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
async def check_ncbr():
start = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=3.0) as client:
await client.get("https://www.gov.pl/web/ncbr")
latency = int((time.perf_counter() - start) * 1000)
return {"status": "ok", "message": "NCBR website reachable", "latency_ms": latency}
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
async def check_bgk():
start = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=3.0) as client:
await client.get("https://www.bgk.pl/")
latency = int((time.perf_counter() - start) * 1000)
return {"status": "ok", "message": "BGK website reachable", "latency_ms": latency}
except Exception as e:
latency = int((time.perf_counter() - start) * 1000)
return {"status": "error", "message": str(e), "latency_ms": latency}
@router.get("/services_status")
async def get_system_health(_admin: dict = Depends(verify_admin)):
"""Returns the health status of various external dependencies."""
gemini_status = await check_gemini()
grok_status = await check_grok()
pinecone_status = await check_pinecone()
clerk_status = await check_clerk()
langsmith_status = await check_langsmith()
neo4j_status = await check_neo4j()
db_status = await check_database()
eurlex_status = await check_eurlex()
crawl4ai_status = await check_crawl4ai()
parp_status = await check_parp()
ncbr_status = await check_ncbr()
bgk_status = await check_bgk()
return {
"gemini": gemini_status,
"grok": grok_status,
"pinecone": pinecone_status,
"clerk": clerk_status,
"langsmith": langsmith_status,
"neo4j": neo4j_status,
"database": db_status,
"eurlex": eurlex_status,
"crawl4ai": crawl4ai_status,
"parp_gov": parp_status,
"ncbr_gov": ncbr_status,
"bgk": bgk_status,
}
@router.get("/sources-health")
async def get_acquisition_sources_health(_admin: dict = Depends(verify_admin)):
"""
Endpoint for Acquisition Health Dashboard.
Returns detailed health and scraping statistics for all data sources.
"""
try:
from core.search.grant_search_service import grant_search_service
health = await grant_search_service.get_sources_health()
return health
except Exception as e:
return {"status": "error", "error": str(e)}
@router.get("/watchdog-stats")
async def get_watchdog_stats(_admin: dict = Depends(verify_admin)):
"""Parses watchdog logs and returns statistics of auto-recovery interventions."""
stats = {
"total_interventions": 0,
"retries_429": 0,
"retries_500": 0,
"retries_refusal": 0,
"aborts": 0,
"recent_events": [],
}
try:
log_path = "logs/watchdog.log"
if not os.path.exists(log_path):
return stats
with open(log_path, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in lines:
line = line.strip()
if not line:
continue
stats["total_interventions"] += 1
if "[RETRY 429]" in line:
stats["retries_429"] += 1
elif "[RETRY 500]" in line:
stats["retries_500"] += 1
elif "[RETRY REFUSAL]" in line:
stats["retries_refusal"] += 1
elif "[ABORT]" in line:
stats["aborts"] += 1
stats["recent_events"] = [line.strip() for line in lines[-50:]]
stats["recent_events"].reverse()
except Exception as e:
stats["error"] = str(e)
return stats
@router.get("/instrument-pipeline")
async def instrument_pipeline_health(
limit: int = Query(2000, ge=10, le=10000),
_admin: dict = Depends(verify_admin),
):
"""
Instrument-first pipeline health for ops:
schema coverage, readiness mix, gold families, dossier flags.
"""
from core.subscription.db import SessionLocal
from core.projects.instrument_ops import (
build_plan_12m_metrics_snapshot,
compute_instrument_project_metrics,
gold_family_coverage,
)
from core.grants.dossier_ops import compute_readiness_distribution
session = SessionLocal()
try:
projects = compute_instrument_project_metrics(session, limit=limit)
dossier = compute_readiness_distribution(session, limit=min(limit, 5000))
return {
"status": "ok",
"gold": gold_family_coverage(),
"projects": projects,
"catalog_dossier": dossier,
"plan_12m": build_plan_12m_metrics_snapshot(
project_metrics=projects,
catalog_stats={"dossier_readiness": dossier},
),
}
finally:
session.close()
@router.get("/credibility-health")
async def credibility_pipeline_health(_admin: dict = Depends(verify_admin)):
"""
Najważniejszy endpoint dla wizji "najwyższej wiarygodności".
Raportuje stan całego łańcucha ugruntowania: GUS, MSP/GraphRAG, Regulation Snapshots,
EUR-Lex live, mock warnings, pokrycie reguł.
Umożliwia weryfikację, czy system jest godny zaufania na poziomie 'best source of grants on the internet'.
"""
report = {
"timestamp": __import__("datetime").datetime.utcnow().isoformat(),
"overall_credibility": "unknown",
"components": {}
}
try:
snaps = regulation_snapshot_store.list_all() if regulation_snapshot_store else []
total_rules = sum(len(s.key_rules or []) for s in snaps)
total_excl = sum(len(s.exclusions or []) for s in snaps)
programs = sorted(set(s.program for s in snaps))
report["components"]["regulation_engine"] = {
"snapshots": len(snaps),
"programs_covered": programs,
"total_structured_rules": total_rules,
"total_exclusions": total_excl,
"status": "strong" if len(snaps) >= 3 and total_rules > 15 else "limited",
"primary_storage": "Postgres"
}
except Exception as e:
report["components"]["regulation_engine"] = {"status": "error", "detail": str(e)}
try:
el = EURLexClient()
el_status = el.check_status()
sample = el.search_legal_acts("Fundusze Europejskie dla Nowoczesnej Gospodarki", limit=1)
report["components"]["eurlex_live"] = {
"status": el_status.get("status"),
"message": el_status.get("message"),
"sample_hits": len(sample),
"note": "Live source of truth for EU law — higher authority than national program rules"
}
except Exception as e:
report["components"]["eurlex_live"] = {"status": "error", "detail": str(e)}
report["components"]["gus_company_data"] = {
"real_gus_integration": "present (BIR1.1 + RegonAPI)",
"mock_only_for_specific_test_nips": True,
"recommendation": "Zawsze sprawdzaj flagę 'data_source' / 'using_real_data' w msp_analysis i company_data",
"risk": "Geoblocking na niektórych serwerach — fallbacki obniżają wiarygodność"
}
report["components"]["msp_graph_rag"] = {
"early_enforcement": "create_project + match + audit",
"mock_warning": "Zawsze emitowane w msp_risk_note + using_real_data",
"status": "transparent"
}
report["components"]["grounding_certificates"] = {
"emission": "Forced in generate_section (helpers.py) + RegulationSnapshot + Engine checks",
"content": "version_hash, effective_date, key_rules count, engine eligibility decisions",
"status": "active (Faza 3)"
}
try:
from core.search.grant_search_service import grant_search_service
from core.trust.trust_scorer import compute_platform_trust_score
aq_health = await grant_search_service.get_sources_health()
link_info = {
"high_quality_links_pct": aq_health.get("precise_regulation_links_percentage", 0),
"breakdown": aq_health.get("regulation_link_quality", {}),
"status": "good" if aq_health.get("precise_regulation_links_percentage", 0) >= 50 else "needs_improvement"
}
report["components"]["acquisition_regulation_link_quality"] = link_info
trust = compute_platform_trust_score(report, {
"snapshots_count": len(report.get("components", {}).get("regulation_engine", {}).get("snapshots", [])),
"total_structured_rules": report.get("components", {}).get("regulation_engine", {}).get("total_structured_rules", 0)
})
report["platform_trust_score"] = trust
except Exception as e:
report["components"]["acquisition_regulation_link_quality"] = {"status": "error", "detail": str(e)}
strong = sum(1 for c in report["components"].values() if isinstance(c, dict) and c.get("status") in ("strong", "active", "transparent", "present"))
report["overall_credibility"] = "high" if strong >= 4 else "building" if strong >= 2 else "needs_improvement"
report["recommendation_for_users"] = "Używaj /trigger-snapshot + /test-eligibility regularnie. Wgraj zewnętrzne wnioski do Reverse Audit dla maksymalnej weryfikacji."
try:
from core.trust.trust_scorer import compute_platform_trust_score
platform_score = compute_platform_trust_score(report, {
"snapshots_count": len(report.get("components", {}).get("regulation_engine", {}).get("snapshots", [])),
"total_structured_rules": report.get("components", {}).get("regulation_engine", {}).get("total_structured_rules", 0)
})
report["platform_credibility"] = {
"score": platform_score["overall_score"],
"level": platform_score["level"],
"breakdown": platform_score["breakdown"]
}
except Exception as e:
report["platform_credibility"] = {"error": str(e)}
return report
@router.post("/stream-ticket")
async def mint_diagnostics_stream_ticket(_admin: dict = Depends(verify_admin)):
"""Short-lived ticket for EventSource (avoid putting JWT in query string)."""
user_id = _admin.get("sub") or "admin"
ticket = _mint_diag_ticket(user_id)
return {"ticket": ticket, "expires_in": _DIAG_TICKET_TTL}
@router.get("/stream")
async def diagnostics_stream(
request: Request,
ticket: str = Query(None),
token: str = Query(None),
):
"""SSE endpoint for streaming real-time telemetry logs."""
if ticket:
_consume_diag_ticket(ticket)
elif token:
from core.subscription.auth_utils import is_dev_test_token
if is_dev_test_token(token):
verify_bearer_token(token)
else:
raise HTTPException(
status_code=401,
detail="JWT in query string is disabled; mint a stream ticket first",
)
else:
# Router-level verify_admin already ran for non-SSE; EventSource has no headers.
raise HTTPException(status_code=401, detail="Missing stream ticket")
return StreamingResponse(telemetry.subscribe(), media_type="text/event-stream")