mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
reNgine 2 could produce a PDF. This produces a deliverable, and it is built the way scan engines are built: sections are plug-and-play directories under reports/, each with a pydantic config model that IS the source of truth. That model feeds GET /reports/catalog, the builder renders its own form from it, and the API validates against it. Adding a section is a directory. Scan Engine is to stages what Report Template is to sections. WeasyPrint 69 renders it, chosen over Chromium and Typst after measuring what each can actually do. Only real CSS Paged Media gives a table of contents with true page numbers, running headers, a PDF outline and footnotes; SysReptor reaches the same verdict and pipes Chromium's HTML into WeasyPrint for exactly that reason. reNgine drops the Chromium hop because it templates in Jinja and draws its charts as SVG in Python, so a browser buys nothing and costs 400 MB per report. Typst was rejected because a user-uploadable theme would then have to be Typst code rather than CSS. A theme is data. shared/definitions/report_theme.py is the schema, six themes ship as YAML, and a user uploads their own; there is no markup in a theme, so there is no injection surface. Fonts are vendored so an offline instance still renders in Latin, Greek and Cyrillic. Everything the document claims is computed once, in reports/analysis/engine.py: a posture score whose every deduction is named, weaknesses ranked by the signals behind the rank rather than severity alone, attack paths that chain observations into a route, a remediation plan ordered by risk removed, per-dimension change against the previous run, and caveats that state what was never assessed. Two narrators read that one brief. Without AI the report still argues its case; with AI the prose is better and the model never sees a row, only the brief, capped and cached per check. An AI failure falls back and the report still completes. Generation is a job. POST /reports queues, the worker walks progress over SSE, and four formats come out of one render: PDF, self-contained HTML, Markdown and JSON. The estimate reads each section's own limits, so the page count it promises is close to the one it delivers. AI moves out of Settings into its own top-level page: connection, a model per tier, what it may be used for, what it has cost, and the narrative cache. Verified on a 1,379-host gov.cy scan: 124 pages in 19.5s across 19 sections, all six themes, and a broken key still producing a complete report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
217 lines
6.1 KiB
Python
217 lines
6.1 KiB
Python
"""What AI is allowed to do here, which models do it, and what it costs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
|
|
from shared.enums.instance import AIProvider
|
|
|
|
MAX_BRIEF_BYTES = 24_000
|
|
MAX_CALLS_PER_REPORT = 40
|
|
MAX_OUTPUT_TOKENS = 2_000
|
|
REQUEST_TIMEOUT = 120.0
|
|
CACHE_VERSION = "1"
|
|
|
|
|
|
class AITask(StrEnum):
|
|
EXECUTIVE_SUMMARY = "executive_summary"
|
|
RISK_NARRATIVE = "risk_narrative"
|
|
REMEDIATION_PLAN = "remediation_plan"
|
|
ISSUE_EXPLAINER = "issue_explainer"
|
|
ATTACK_PATH = "attack_path"
|
|
SURFACE_NARRATIVE = "surface_narrative"
|
|
|
|
|
|
AI_TASK_LABELS: dict[str, str] = {
|
|
AITask.EXECUTIVE_SUMMARY.value: "Executive summary",
|
|
AITask.RISK_NARRATIVE.value: "Risk narrative",
|
|
AITask.REMEDIATION_PLAN.value: "Remediation plan",
|
|
AITask.ISSUE_EXPLAINER.value: "Finding explanations",
|
|
AITask.ATTACK_PATH.value: "Attack path narrative",
|
|
AITask.SURFACE_NARRATIVE.value: "Attack surface narrative",
|
|
}
|
|
|
|
# tasks that run once per report against the brief, versus once per distinct check
|
|
REPORT_TASKS: tuple[str, ...] = (
|
|
AITask.EXECUTIVE_SUMMARY.value,
|
|
AITask.RISK_NARRATIVE.value,
|
|
AITask.REMEDIATION_PLAN.value,
|
|
AITask.SURFACE_NARRATIVE.value,
|
|
AITask.ATTACK_PATH.value,
|
|
)
|
|
|
|
# an explainer depends only on the check, so it is cached across every report and target
|
|
GLOBAL_CACHE_TASKS: tuple[str, ...] = (AITask.ISSUE_EXPLAINER.value,)
|
|
|
|
TASK_OUTPUT_TOKENS: dict[str, int] = {
|
|
AITask.EXECUTIVE_SUMMARY.value: 1400,
|
|
AITask.RISK_NARRATIVE.value: 900,
|
|
AITask.REMEDIATION_PLAN.value: 1200,
|
|
AITask.ISSUE_EXPLAINER.value: 700,
|
|
AITask.ATTACK_PATH.value: 900,
|
|
AITask.SURFACE_NARRATIVE.value: 800,
|
|
}
|
|
|
|
|
|
class Effort(StrEnum):
|
|
LOW = "low"
|
|
MEDIUM = "medium"
|
|
HIGH = "high"
|
|
|
|
|
|
TASK_EFFORT: dict[str, str] = {
|
|
AITask.EXECUTIVE_SUMMARY.value: Effort.MEDIUM.value,
|
|
AITask.RISK_NARRATIVE.value: Effort.LOW.value,
|
|
AITask.REMEDIATION_PLAN.value: Effort.MEDIUM.value,
|
|
AITask.ISSUE_EXPLAINER.value: Effort.LOW.value,
|
|
AITask.ATTACK_PATH.value: Effort.MEDIUM.value,
|
|
AITask.SURFACE_NARRATIVE.value: Effort.LOW.value,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelSpec:
|
|
id: str
|
|
label: str
|
|
provider: str
|
|
input_per_mtok: float | None = None
|
|
output_per_mtok: float | None = None
|
|
context: int = 200_000
|
|
adaptive_thinking: bool = False
|
|
supports_effort: bool = False
|
|
note: str = ""
|
|
|
|
|
|
# priced models are Anthropic's published rates; other providers are listed without a price
|
|
MODELS: tuple[ModelSpec, ...] = (
|
|
ModelSpec(
|
|
"claude-opus-5",
|
|
"Claude Opus 5",
|
|
AIProvider.ANTHROPIC.value,
|
|
5.0,
|
|
25.0,
|
|
1_000_000,
|
|
True,
|
|
True,
|
|
"Best writing. The default.",
|
|
),
|
|
ModelSpec(
|
|
"claude-sonnet-5",
|
|
"Claude Sonnet 5",
|
|
AIProvider.ANTHROPIC.value,
|
|
2.0,
|
|
10.0,
|
|
1_000_000,
|
|
True,
|
|
True,
|
|
"Cheaper, still strong.",
|
|
),
|
|
ModelSpec(
|
|
"claude-haiku-4-5",
|
|
"Claude Haiku 4.5",
|
|
AIProvider.ANTHROPIC.value,
|
|
1.0,
|
|
5.0,
|
|
200_000,
|
|
False,
|
|
False,
|
|
"Fastest and cheapest.",
|
|
),
|
|
ModelSpec(
|
|
"claude-opus-4-8",
|
|
"Claude Opus 4.8",
|
|
AIProvider.ANTHROPIC.value,
|
|
5.0,
|
|
25.0,
|
|
1_000_000,
|
|
True,
|
|
True,
|
|
),
|
|
ModelSpec("gpt-4o", "GPT-4o", AIProvider.OPENAI.value),
|
|
ModelSpec("gpt-4o-mini", "GPT-4o mini", AIProvider.OPENAI.value),
|
|
ModelSpec("gemini-1.5-pro", "Gemini 1.5 Pro", AIProvider.GOOGLE.value),
|
|
ModelSpec("gemini-1.5-flash", "Gemini 1.5 Flash", AIProvider.GOOGLE.value),
|
|
)
|
|
|
|
MODEL_BY_ID: dict[str, ModelSpec] = {m.id: m for m in MODELS}
|
|
|
|
DEFAULT_MODEL: dict[str, str] = {
|
|
AIProvider.ANTHROPIC.value: "claude-opus-5",
|
|
AIProvider.OPENAI.value: "gpt-4o-mini",
|
|
AIProvider.AZURE_OPENAI.value: "gpt-4o-mini",
|
|
AIProvider.GOOGLE.value: "gemini-1.5-flash",
|
|
}
|
|
|
|
# the cheap tier used for per-check explainers, which are cached forever once written
|
|
FAST_MODEL: dict[str, str] = {
|
|
AIProvider.ANTHROPIC.value: "claude-haiku-4-5",
|
|
AIProvider.OPENAI.value: "gpt-4o-mini",
|
|
AIProvider.AZURE_OPENAI.value: "gpt-4o-mini",
|
|
AIProvider.GOOGLE.value: "gemini-1.5-flash",
|
|
}
|
|
|
|
PROVIDER_LABELS: dict[str, str] = {
|
|
AIProvider.OPENAI.value: "OpenAI",
|
|
AIProvider.ANTHROPIC.value: "Anthropic",
|
|
AIProvider.AZURE_OPENAI.value: "Azure OpenAI",
|
|
AIProvider.GOOGLE.value: "Google",
|
|
}
|
|
|
|
PROVIDER_KEY_HINT: dict[str, str] = {
|
|
AIProvider.OPENAI.value: "sk-...",
|
|
AIProvider.ANTHROPIC.value: "sk-ant-...",
|
|
AIProvider.AZURE_OPENAI.value: "Azure resource key",
|
|
AIProvider.GOOGLE.value: "AIza...",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AIFeature:
|
|
key: str
|
|
label: str
|
|
help: str
|
|
default: bool = False
|
|
|
|
|
|
# what the instance allows AI to be used for; a report still opts in per run
|
|
AI_FEATURES: tuple[AIFeature, ...] = (
|
|
AIFeature(
|
|
"report_narrative",
|
|
"Report narrative",
|
|
"Writes the executive summary, risk narrative and remediation plan from the computed brief.",
|
|
True,
|
|
),
|
|
AIFeature(
|
|
"report_findings",
|
|
"Finding explanations",
|
|
"Explains what a check means for this estate. Cached per check, so it is written once.",
|
|
False,
|
|
),
|
|
AIFeature(
|
|
"attack_paths",
|
|
"Attack path narrative",
|
|
"Describes how observed weaknesses chain together.",
|
|
False,
|
|
),
|
|
)
|
|
|
|
AI_FEATURE_KEYS: tuple[str, ...] = tuple(f.key for f in AI_FEATURES)
|
|
DEFAULT_AI_FEATURES: dict[str, bool] = {f.key: f.default for f in AI_FEATURES}
|
|
|
|
|
|
def model_for(provider: str, requested: str | None, *, fast: bool = False) -> str:
|
|
if requested and requested.strip():
|
|
return requested.strip()
|
|
table = FAST_MODEL if fast else DEFAULT_MODEL
|
|
return table.get(provider, DEFAULT_MODEL[AIProvider.ANTHROPIC.value])
|
|
|
|
|
|
def price(model: str, input_tokens: int, output_tokens: int) -> float | None:
|
|
spec = MODEL_BY_ID.get(model)
|
|
if spec is None or spec.input_per_mtok is None or spec.output_per_mtok is None:
|
|
return None
|
|
return (
|
|
input_tokens * spec.input_per_mtok + output_tokens * spec.output_per_mtok
|
|
) / 1_000_000
|