Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions backend/app/schemas/rule_suggestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
from __future__ import annotations

import datetime as _dt
from typing import Any
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, computed_field


class RuleSuggestionRead(BaseModel):
"""One pending or historical suggestion returned to the frontend."""
"""One pending or historical suggestion returned to the frontend.

The ``heuristic`` column encodes provenance — built-in engine
heuristics are named ``proactive_*`` while the AI suggestion
service stamps ``ai_<provider>``. Rather than make the frontend
string-match that prefix, we expose first-class ``source`` /
``source_label`` / ``ai_*`` / ``rationale`` fields below so the UI
can label every suggestion's origin consistently.
"""

model_config = ConfigDict(from_attributes=True)

Expand All @@ -29,6 +37,58 @@ class RuleSuggestionRead(BaseModel):
dismissed_reason: str | None
created_at: _dt.datetime

@computed_field # type: ignore[prop-decorator]
@property
def source(self) -> Literal["ai", "builtin"]:
"""Where the suggestion came from. AI suggestions stamp
``ai_<provider>``; everything else is the deterministic
built-in engine."""
return "ai" if self.heuristic.startswith("ai_") else "builtin"

@computed_field # type: ignore[prop-decorator]
@property
def ai_provider(self) -> str | None:
"""The provider kind (``ollama``, ``openai``, …) for AI
suggestions; ``None`` for built-in ones."""
if self.source != "ai":
return None
provider = self.evidence.get("provider_kind")
if provider:
return str(provider)
# Fall back to the heuristic suffix: ``ai_ollama`` → ``ollama``.
suffix = self.heuristic[len("ai_") :]
return suffix or None

@computed_field # type: ignore[prop-decorator]
@property
def ai_model(self) -> str | None:
"""The concrete model name the AI proposal was generated
with (e.g. ``qwen2.5-coder:14b``), when recorded."""
if self.source != "ai":
return None
model = self.evidence.get("model")
return str(model) if model else None

@computed_field # type: ignore[prop-decorator]
@property
def source_label(self) -> str:
"""Short human label for the source chip, e.g. ``AI · ollama``
or ``Built-in engine``."""
if self.source == "ai":
provider = self.ai_provider
return f"AI · {provider}" if provider else "AI"
return "Built-in engine"

@computed_field # type: ignore[prop-decorator]
@property
def rationale(self) -> str | None:
"""The one-line "why" behind the suggestion. AI proposals
carry the model's own ``rationale``; built-in heuristics that
populate an ``evidence['rationale']`` surface it here too."""
rationale = self.evidence.get("rationale")
text = str(rationale).strip() if rationale else ""
return text or None


class SuggestionDeployRequest(BaseModel):
"""Optional patch applied to the definition before it's saved as
Expand Down
62 changes: 59 additions & 3 deletions backend/app/services/ai/suggestions.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@

DEFAULT_DAILY_BUDGET = 50
TOP_FILES_LIMIT = 50
# Fallback confidence when the model omits / malforms the field.
DEFAULT_AI_CONFIDENCE = 0.5
SYSTEM_PROMPT = """\
You are an assistant that proposes rules for a media library
auditing tool called Auditarr. Each rule has a ``match`` block
Expand All @@ -83,7 +85,19 @@
1. Output ONLY a JSON array of proposed rules. No prose,
no markdown fences. Each entry is an object with:
- ``name``: short human-readable name
- ``rationale``: one-sentence explanation
- ``rationale``: ONE sentence that cites the SPECIFIC
evidence from the supplied data — the device, codec,
container, resolution, or observed counts that justify
the rule (e.g. "Apple TV transcoded HEVC 4K 18 times in
the last 30 days"). Generic rationales like "improves
playback" are NOT acceptable.
- ``confidence``: a number from 0 to 1 for how strongly the
supplied data supports this rule. More observations /
clearer signal = higher; a hunch with little data = low.
- ``files_affected``: your integer estimate of how many
library files this rule would match, inferred from
``library_summary`` and ``top_files``. Use 0 only if you
genuinely cannot estimate.
- ``definition``: the RuleDefinition object the engine
will execute
2. Each ``definition`` MUST match this shape:
Expand All @@ -102,6 +116,11 @@
7. Prefer ``set_severity`` + ``add_tag`` over destructive
actions. The user reviews every suggestion before it
deploys.
8. The data includes ``heuristic_suggestions`` already produced
by Auditarr's deterministic engine. Do NOT restate them —
propose rules that COMPLEMENT them, covering codecs, devices,
or patterns the heuristics did not. Quality over quantity: a
few specific, well-justified rules beat many speculative ones.

If you cannot propose any rules, return an empty JSON array: [].
"""
Expand Down Expand Up @@ -297,6 +316,14 @@
continue

name = str(proposal.get("name") or "AI suggestion")
# v1.12 — use the model's self-reported confidence + impact
# estimate (clamped) instead of hardcoding 0.5 / 0. This
# lets AI proposals rank against heuristic ones in the same
# review queue rather than all clustering at 50%.
confidence = _coerce_confidence(proposal.get("confidence"))
files_affected = _coerce_files_affected(
proposal.get("files_affected")
)
evidence = {
"rationale": str(proposal.get("rationale") or ""),
"provider_kind": provider_kind,
Expand Down Expand Up @@ -342,9 +369,9 @@
definition=definition,
heuristic=f"ai_{provider_kind}",
evidence=evidence,
files_affected=0,
files_affected=files_affected,
est_runtime_s=None,
confidence=0.5,
confidence=confidence,
dedup_key=dedup_key,
status="pending",
)
Expand Down Expand Up @@ -810,6 +837,32 @@
return f"ai:{provider_kind}:{safe_name}:{digest}"


def _coerce_confidence(raw: Any) -> float:
"""Clamp the model's self-reported confidence into [0, 1].

Missing / non-numeric / NaN → :data:`DEFAULT_AI_CONFIDENCE` so a
model that omits the field still ranks sensibly in the review
queue. Pure function, exposed for testing."""
try:
value = float(raw)
except (TypeError, ValueError):
return DEFAULT_AI_CONFIDENCE
if value != value: # NaN

Check warning

Code scanning / CodeQL

Comparison of identical values Warning

Comparison of identical values; use cmath.isnan() if testing for not-a-number.
return DEFAULT_AI_CONFIDENCE
return max(0.0, min(1.0, value))


def _coerce_files_affected(raw: Any) -> int:
"""Non-negative integer estimate of matched files. Missing /
invalid → 0 (the UI reads 0 as "no estimate"). Pure function,
exposed for testing."""
try:
value = int(raw)
except (TypeError, ValueError):
return 0
return max(0, value)


def _contains_delete_action(definition: dict[str, Any]) -> bool:
"""v1.9 audit fix (AI-4) — detect ``delete`` actions in any
proposed RuleDefinition.
Expand Down Expand Up @@ -903,9 +956,12 @@
__all__ = [
"AISuggestionService",
"AISuggestResult",
"DEFAULT_AI_CONFIDENCE",
"DEFAULT_DAILY_BUDGET",
"SYSTEM_PROMPT",
"_anonymize_path",
"_coerce_confidence",
"_coerce_files_affected",
"_compose_system_prompt",
"_contains_delete_action",
"_dedup_key_for_ai",
Expand Down
88 changes: 88 additions & 0 deletions backend/tests/unit/test_rule_suggestion_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Provenance fields on RuleSuggestionRead + AI confidence/impact coercion.

Pins that the API surfaces a first-class ``source`` (and AI provider /
model / rationale) derived from the ``heuristic`` column, so the frontend
labels built-in-engine vs AI suggestions consistently instead of
string-matching the heuristic prefix.
"""

from __future__ import annotations

import datetime as _dt

from app.schemas.rule_suggestion import RuleSuggestionRead
from app.services.ai.suggestions import (
DEFAULT_AI_CONFIDENCE,
_coerce_confidence,
_coerce_files_affected,
)


def _make(heuristic: str, evidence: dict) -> RuleSuggestionRead:
return RuleSuggestionRead(
id="s1",
name="n",
definition={},
heuristic=heuristic,
evidence=evidence,
files_affected=3,
est_runtime_s=None,
confidence=0.7,
dedup_key="k",
status="pending",
deployed_rule_id=None,
deployed_at=None,
dismissed_at=None,
dismissed_reason=None,
created_at=_dt.datetime.now(_dt.UTC),
)


def test_ai_suggestion_exposes_ai_provenance() -> None:
s = _make(
"ai_ollama",
{
"rationale": "Apple TV transcoded HEVC 18x in 30 days",
"model": "qwen2.5-coder:14b",
"provider_kind": "ollama",
},
)
assert s.source == "ai"
assert s.source_label == "AI · ollama"
assert s.ai_provider == "ollama"
assert s.ai_model == "qwen2.5-coder:14b"
assert s.rationale == "Apple TV transcoded HEVC 18x in 30 days"


def test_ai_provider_falls_back_to_heuristic_suffix() -> None:
s = _make("ai_openai", {}) # no provider_kind recorded
assert s.source == "ai"
assert s.ai_provider == "openai"
assert s.ai_model is None
assert s.rationale is None


def test_builtin_suggestion_has_builtin_source_and_no_ai_fields() -> None:
s = _make("proactive_bitrate_ceiling", {"client": "Apple TV"})
assert s.source == "builtin"
assert s.source_label == "Built-in engine"
assert s.ai_provider is None
assert s.ai_model is None
assert s.rationale is None


def test_coerce_confidence_clamps_and_defaults() -> None:
assert _coerce_confidence(0.9) == 0.9
assert _coerce_confidence(2.0) == 1.0
assert _coerce_confidence(-1) == 0.0
assert _coerce_confidence("0.4") == 0.4
assert _coerce_confidence(None) == DEFAULT_AI_CONFIDENCE
assert _coerce_confidence("nonsense") == DEFAULT_AI_CONFIDENCE


def test_coerce_files_affected_floors_at_zero() -> None:
assert _coerce_files_affected(12) == 12
assert _coerce_files_affected("5") == 5
assert _coerce_files_affected(-3) == 0
assert _coerce_files_affected(None) == 0
assert _coerce_files_affected("x") == 0
41 changes: 30 additions & 11 deletions frontend/src/features/dashboard/SuggestionReviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,15 @@ import { cn } from "@/lib/cn";
import { fmtNum } from "@/lib/format";

import { VisualRuleBuilder } from "../rules/VisualRuleBuilder";
import {
describeSuggestion,
heuristicLabel,
SuggestionSourceBadge,
suggestionSource,
} from "../rules/suggestionDisplay";

type Tab = "visual" | "evidence" | "json";

const HEURISTIC_LABEL: Record<string, string> = {
high_transcode_codec: "Transcode codec",
bitrate_ceiling: "Bitrate ceiling",
container_compat: "Container compatibility",
resolution_mismatch: "Resolution mismatch",
failed_playback: "Failed playback",
};

export function SuggestionReviewModal({
suggestion,
onClose,
Expand Down Expand Up @@ -147,9 +145,15 @@ export function SuggestionReviewModal({
{/* Header */}
<div className="flex items-center justify-between px-4 h-11 border-b border-border shrink-0">
<div className="flex items-center gap-2 min-w-0">
<Pill className="text-[10px] text-muted-2 border-border bg-surface-2 shrink-0">
{HEURISTIC_LABEL[suggestion.heuristic] ?? suggestion.heuristic}
</Pill>
<SuggestionSourceBadge
suggestion={suggestion}
className="text-[10px] shrink-0"
/>
{suggestionSource(suggestion) === "builtin" ? (
<Pill className="text-[10px] text-muted-2 border-border bg-surface-2 shrink-0">
{heuristicLabel(suggestion)}
</Pill>
) : null}
<h3 className="text-[13px] font-semibold m-0 truncate">Review suggestion</h3>
</div>
<button onClick={onClose} className="text-muted-2 hover:text-text" aria-label="Close">
Expand All @@ -159,6 +163,21 @@ export function SuggestionReviewModal({

{/* Body */}
<div className="p-4 flex flex-col gap-3 overflow-y-auto">
{/* Why this was suggested — the AI model's own rationale, or
a description derived from the built-in heuristic. */}
{(() => {
const why = describeSuggestion(suggestion);
if (!why) return null;
const isAi = suggestionSource(suggestion) === "ai";
return (
<div className="rounded-md border border-border bg-surface-sunk px-3 py-2 text-[12.5px] text-text-2 leading-relaxed">
<span className="text-muted-2 font-medium">
{isAi ? "Why the AI suggested this" : "Why this was suggested"}:
</span>{" "}
{why}
</div>
);
})()}
{/* Editable name + projection row */}
<div className="flex flex-col gap-1.5">
<span className="text-[10.5px] uppercase tracking-[0.06em] text-muted-2 font-semibold">
Expand Down
Loading
Loading