Step 1: package scaffold, classifier, constraints, metric modules
Browse files- schemas: EvalRequest/EvalReport contracts with is_proxy/requires_judge
honesty flags baked into the type layer
- classify: rule votes + bge prototype referee + instruction/source split
- constraints: IFEval-style extraction and deterministic checkers
- metrics: readability (Flesch/FK bands), lexical (MTLD, cliches, rhythm),
relevance (bge, per-paragraph drift), faithfulness (SummaC-ZS over
nli-deberta-v3-small), hallucination risk signals, static code ladder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- pyproject.toml +24 -0
- rubriq/__init__.py +3 -0
- rubriq/classify.py +230 -0
- rubriq/constraints.py +286 -0
- rubriq/metrics/__init__.py +0 -0
- rubriq/metrics/code_static.py +109 -0
- rubriq/metrics/faithfulness.py +103 -0
- rubriq/metrics/hallucination_risk.py +78 -0
- rubriq/metrics/lexical.py +85 -0
- rubriq/metrics/readability.py +63 -0
- rubriq/metrics/relevance.py +52 -0
- rubriq/metrics/structure.py +73 -0
- rubriq/models.py +64 -0
- rubriq/schemas.py +123 -0
- rubriq/textutils.py +77 -0
pyproject.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "rubriq"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Score any LLM's output locally - no API calls - and get research-grounded prompts that fix what failed."
|
| 5 |
+
requires-python = ">=3.11"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"fastapi>=0.110",
|
| 8 |
+
"uvicorn>=0.29",
|
| 9 |
+
"pydantic>=2.6",
|
| 10 |
+
"numpy>=1.26",
|
| 11 |
+
"sentence-transformers>=3.0",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
[project.optional-dependencies]
|
| 15 |
+
dev = [
|
| 16 |
+
"pytest>=8.0",
|
| 17 |
+
"httpx>=0.27",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[tool.setuptools.packages.find]
|
| 21 |
+
include = ["rubriq*"]
|
| 22 |
+
|
| 23 |
+
[tool.pytest.ini_options]
|
| 24 |
+
testpaths = ["tests"]
|
rubriq/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rubriq: local, research-grounded LLM output quality evaluation."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
rubriq/classify.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task classification: rule signals first, embedding prototypes as referee.
|
| 2 |
+
|
| 3 |
+
Design (RESEARCH.md 1.8 adaptation + standard practice):
|
| 4 |
+
- High-precision regex/structure rules vote on task type. A confident rule
|
| 5 |
+
vote decides directly — "Summarize the following" needs no neural network.
|
| 6 |
+
- When rules disagree or stay silent, the instruction is embedded and
|
| 7 |
+
compared against prototype sentences per task type (bge-small).
|
| 8 |
+
- Separately, the prompt is split into instruction vs. pasted source
|
| 9 |
+
material; source presence turns QA into QA_GROUNDED and unlocks the
|
| 10 |
+
NLI faithfulness parameter.
|
| 11 |
+
|
| 12 |
+
Every fired signal is recorded and surfaced in the UI, so a misclassification
|
| 13 |
+
is diagnosable by the user (and overridable in a later UI iteration).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import re
|
| 19 |
+
|
| 20 |
+
from .models import get_embedder
|
| 21 |
+
from .schemas import TaskClassification, TaskType
|
| 22 |
+
from .textutils import word_count
|
| 23 |
+
|
| 24 |
+
# ---------------------------------------------------------------- source split
|
| 25 |
+
|
| 26 |
+
_DELIM_BLOCK = re.compile(
|
| 27 |
+
r"(?s)(?:\"{3}|'{3}|`{3}|<{2,}|={3,}|-{3,}|###)\s*\n?(.+?)(?:\"{3}|'{3}|`{3}|>{2,}|={3,}|-{3,}|###|\Z)"
|
| 28 |
+
)
|
| 29 |
+
_SOURCE_INTRO = re.compile(
|
| 30 |
+
r"(?i)(?:following (?:text|article|document|passage|email|report|code|excerpt|transcript|paragraph)s?|"
|
| 31 |
+
r"text below|below is|here is the (?:text|article|document|passage|code)|this (?:text|article|document))\s*[:.]?\s*\n"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def split_instruction_and_source(prompt: str) -> tuple[str, str | None]:
|
| 36 |
+
"""Separate the user's instruction from pasted source material.
|
| 37 |
+
|
| 38 |
+
Order of attempts: explicit delimiter blocks, then "the following text:"
|
| 39 |
+
intros, then a length heuristic (one short leading/trailing instruction
|
| 40 |
+
line attached to a long body). Conservative: when unsure, the whole
|
| 41 |
+
prompt is instruction and no faithfulness checking happens — a false
|
| 42 |
+
source is worse than a missed one because it would generate false
|
| 43 |
+
unfaithfulness findings.
|
| 44 |
+
"""
|
| 45 |
+
m = _DELIM_BLOCK.search(prompt)
|
| 46 |
+
if m and word_count(m.group(1)) >= 40:
|
| 47 |
+
source = m.group(1).strip()
|
| 48 |
+
instruction = (prompt[:m.start()] + " " + prompt[m.end():]).strip()
|
| 49 |
+
if instruction:
|
| 50 |
+
return instruction, source
|
| 51 |
+
|
| 52 |
+
m = _SOURCE_INTRO.search(prompt)
|
| 53 |
+
if m:
|
| 54 |
+
source = prompt[m.end():].strip()
|
| 55 |
+
instruction = prompt[:m.end()].strip()
|
| 56 |
+
if word_count(source) >= 40:
|
| 57 |
+
return instruction, source
|
| 58 |
+
|
| 59 |
+
# Length heuristic: short first paragraph, long remainder.
|
| 60 |
+
parts = prompt.split("\n", 1)
|
| 61 |
+
if len(parts) == 2:
|
| 62 |
+
head, rest = parts[0].strip(), parts[1].strip()
|
| 63 |
+
if head and word_count(head) <= 60 and word_count(rest) >= 150 and _looks_instructional(head):
|
| 64 |
+
return head, rest
|
| 65 |
+
|
| 66 |
+
return prompt.strip(), None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _looks_instructional(line: str) -> bool:
|
| 70 |
+
return bool(re.search(
|
| 71 |
+
r"(?i)\b(summari[sz]e|rewrite|translate|extract|answer|explain|list|analy[sz]e|"
|
| 72 |
+
r"convert|classify|describe|edit|fix|review|compare|based on|according to)\b",
|
| 73 |
+
line,
|
| 74 |
+
)) or line.rstrip().endswith("?")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ---------------------------------------------------------------- rule signals
|
| 78 |
+
|
| 79 |
+
_RULES: list[tuple[TaskType, str, re.Pattern]] = [
|
| 80 |
+
(TaskType.SUMMARIZATION, "prompt asks to summarize",
|
| 81 |
+
re.compile(r"(?i)\b(summari[sz]e|summary|tl;?dr|condense|key points? of|main points? of|abstract of)\b")),
|
| 82 |
+
(TaskType.CODE_GENERATION, "prompt asks for code",
|
| 83 |
+
re.compile(r"(?i)\b(write|create|implement|generate|fix|debug|refactor)\b.{0,60}\b(function|class|script|code|program|method|module|regex|query|endpoint|component)\b")),
|
| 84 |
+
(TaskType.CODE_GENERATION, "prompt names a programming language",
|
| 85 |
+
re.compile(r"(?i)\bin (python|javascript|typescript|java|c\+\+|c#|go|rust|sql|html|css|bash|powershell)\b")),
|
| 86 |
+
(TaskType.CREATIVE_WRITING, "prompt asks for creative writing",
|
| 87 |
+
re.compile(r"(?i)\b(write|compose|craft)\b.{0,50}\b(story|poem|song|haiku|novel|fiction|tale|screenplay|lyrics|limerick)\b")),
|
| 88 |
+
(TaskType.EXTRACTION, "prompt asks to extract structured data",
|
| 89 |
+
re.compile(r"(?i)\b(extract|pull out|identify all|list (?:all|every))\b.{0,80}\b(names?|dates?|entities|emails?|numbers?|fields?|values?|keywords?|items)\b")),
|
| 90 |
+
(TaskType.EXTRACTION, "prompt requests JSON/CSV/table output from text",
|
| 91 |
+
re.compile(r"(?i)\b(as|in|into|to) (?:a |valid )?(json|csv|yaml|xml|table)\b")),
|
| 92 |
+
(TaskType.REASONING, "prompt poses a math/logic problem",
|
| 93 |
+
re.compile(r"(?i)\b(calculate|compute|solve|prove|how many|what is the (?:probability|total|sum|difference|product)|step[- ]by[- ]step|logic puzzle|riddle)\b")),
|
| 94 |
+
(TaskType.REWRITING, "prompt asks to rewrite/edit existing text",
|
| 95 |
+
re.compile(r"(?i)\b(rewrite|rephrase|paraphrase|edit|proofread|improve|polish|make (?:this|it) (?:more|less|sound)|change the tone|translate)\b")),
|
| 96 |
+
(TaskType.QA_OPEN, "prompt asks a direct question",
|
| 97 |
+
re.compile(r"(?i)^(?:please\s+)?(what|who|when|where|why|how|which|is|are|does|do|can|could|should|explain)\b")),
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
_CODE_FENCE = re.compile(r"```")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _rule_votes(instruction: str, output: str) -> list[tuple[TaskType, str]]:
|
| 104 |
+
votes: list[tuple[TaskType, str]] = []
|
| 105 |
+
for task, why, pat in _RULES:
|
| 106 |
+
if pat.search(instruction):
|
| 107 |
+
votes.append((task, why))
|
| 108 |
+
if _CODE_FENCE.search(output) and word_count(re.sub(r"```.*?```", "", output, flags=re.S)) < word_count(output) * 0.5:
|
| 109 |
+
votes.append((TaskType.CODE_GENERATION, "output is mostly code blocks"))
|
| 110 |
+
return votes
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ------------------------------------------------------------ prototype backup
|
| 114 |
+
|
| 115 |
+
_PROTOTYPES: dict[TaskType, list[str]] = {
|
| 116 |
+
TaskType.SUMMARIZATION: [
|
| 117 |
+
"Summarize this article in a few sentences.",
|
| 118 |
+
"Give me the key points of the following document.",
|
| 119 |
+
],
|
| 120 |
+
TaskType.CODE_GENERATION: [
|
| 121 |
+
"Write a Python function that parses a CSV file.",
|
| 122 |
+
"Implement a REST endpoint that returns user data.",
|
| 123 |
+
],
|
| 124 |
+
TaskType.CREATIVE_WRITING: [
|
| 125 |
+
"Write a short story about a lighthouse keeper.",
|
| 126 |
+
"Compose a poem about autumn in free verse.",
|
| 127 |
+
],
|
| 128 |
+
TaskType.EXTRACTION: [
|
| 129 |
+
"Extract all person names and dates from this text as JSON.",
|
| 130 |
+
"List every product mentioned in the reviews below.",
|
| 131 |
+
],
|
| 132 |
+
TaskType.REASONING: [
|
| 133 |
+
"If a train leaves at 3pm traveling 60 mph, when does it arrive?",
|
| 134 |
+
"Solve this logic puzzle and show your reasoning.",
|
| 135 |
+
],
|
| 136 |
+
TaskType.REWRITING: [
|
| 137 |
+
"Rewrite this paragraph in a more formal tone.",
|
| 138 |
+
"Paraphrase the following text for a younger audience.",
|
| 139 |
+
],
|
| 140 |
+
TaskType.QA_OPEN: [
|
| 141 |
+
"What are the main causes of inflation?",
|
| 142 |
+
"Explain how photosynthesis works.",
|
| 143 |
+
],
|
| 144 |
+
TaskType.GENERAL: [
|
| 145 |
+
"Help me draft an email to my landlord.",
|
| 146 |
+
"Give me advice on preparing for a job interview.",
|
| 147 |
+
],
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
_proto_cache: dict | None = None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _prototype_scores(instruction: str) -> dict[TaskType, float] | None:
|
| 154 |
+
global _proto_cache
|
| 155 |
+
embedder = get_embedder()
|
| 156 |
+
if embedder is None:
|
| 157 |
+
return None
|
| 158 |
+
import numpy as np
|
| 159 |
+
if _proto_cache is None:
|
| 160 |
+
flat, owners = [], []
|
| 161 |
+
for task, examples in _PROTOTYPES.items():
|
| 162 |
+
flat.extend(examples)
|
| 163 |
+
owners.extend([task] * len(examples))
|
| 164 |
+
vecs = embedder.encode(flat, normalize_embeddings=True)
|
| 165 |
+
_proto_cache = (np.asarray(vecs), owners)
|
| 166 |
+
vecs, owners = _proto_cache
|
| 167 |
+
q = embedder.encode([instruction], normalize_embeddings=True)[0]
|
| 168 |
+
sims = vecs @ q
|
| 169 |
+
best: dict[TaskType, float] = {}
|
| 170 |
+
for owner, sim in zip(owners, sims):
|
| 171 |
+
best[owner] = max(best.get(owner, -1.0), float(sim))
|
| 172 |
+
return best
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ------------------------------------------------------------------- classifier
|
| 176 |
+
|
| 177 |
+
def classify(prompt: str, output: str) -> TaskClassification:
|
| 178 |
+
instruction, source = split_instruction_and_source(prompt)
|
| 179 |
+
votes = _rule_votes(instruction, output)
|
| 180 |
+
signals = [why for _, why in votes]
|
| 181 |
+
|
| 182 |
+
vote_counts: dict[TaskType, int] = {}
|
| 183 |
+
for task, _ in votes:
|
| 184 |
+
vote_counts[task] = vote_counts.get(task, 0) + 1
|
| 185 |
+
|
| 186 |
+
task: TaskType
|
| 187 |
+
confidence: float
|
| 188 |
+
|
| 189 |
+
if vote_counts:
|
| 190 |
+
ranked = sorted(vote_counts.items(), key=lambda kv: -kv[1])
|
| 191 |
+
if len(ranked) == 1 or ranked[0][1] > ranked[1][1]:
|
| 192 |
+
task, confidence = ranked[0][0], min(0.95, 0.7 + 0.1 * ranked[0][1])
|
| 193 |
+
else:
|
| 194 |
+
# Tie -> embeddings referee among tied candidates.
|
| 195 |
+
tied = {t for t, c in ranked if c == ranked[0][1]}
|
| 196 |
+
proto = _prototype_scores(instruction)
|
| 197 |
+
if proto:
|
| 198 |
+
task = max(tied, key=lambda t: proto.get(t, -1.0))
|
| 199 |
+
confidence = 0.6
|
| 200 |
+
signals.append("embedding similarity broke a rule tie")
|
| 201 |
+
else:
|
| 202 |
+
task, confidence = ranked[0][0], 0.5
|
| 203 |
+
signals.append("rule tie, embeddings unavailable; first match kept")
|
| 204 |
+
else:
|
| 205 |
+
proto = _prototype_scores(instruction)
|
| 206 |
+
if proto:
|
| 207 |
+
task = max(proto, key=proto.get) # type: ignore[arg-type]
|
| 208 |
+
top = proto[task]
|
| 209 |
+
task = task if top >= 0.45 else TaskType.GENERAL
|
| 210 |
+
confidence = round(min(0.85, max(0.35, top)), 2)
|
| 211 |
+
signals.append(f"embedding prototype match ({task.value}, sim={top:.2f})")
|
| 212 |
+
else:
|
| 213 |
+
task, confidence = TaskType.GENERAL, 0.3
|
| 214 |
+
signals.append("no rule matched, embeddings unavailable")
|
| 215 |
+
|
| 216 |
+
# Source text upgrades/refines the task.
|
| 217 |
+
if source is not None:
|
| 218 |
+
signals.append("prompt contains pasted source text")
|
| 219 |
+
if task == TaskType.QA_OPEN:
|
| 220 |
+
task = TaskType.QA_GROUNDED
|
| 221 |
+
signals.append("question + source text -> grounded Q&A")
|
| 222 |
+
|
| 223 |
+
return TaskClassification(
|
| 224 |
+
task_type=task,
|
| 225 |
+
confidence=confidence,
|
| 226 |
+
signals=signals,
|
| 227 |
+
has_source_text=source is not None,
|
| 228 |
+
instruction=instruction,
|
| 229 |
+
source_text=source,
|
| 230 |
+
)
|
rubriq/constraints.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Instruction-following: extract verifiable constraints from the prompt,
|
| 2 |
+
check each with code (IFEval approach, RESEARCH.md 1.8).
|
| 3 |
+
|
| 4 |
+
Extraction is deliberately conservative. A constraint only becomes a check
|
| 5 |
+
when the pattern is unambiguous, and every extracted constraint is reported
|
| 6 |
+
to the user whether it passed or failed — so a wrong extraction is visible,
|
| 7 |
+
not a hidden scoring error. Counting checks like these are the case where
|
| 8 |
+
deterministic code beats LLM judges (MT-Bench bias findings, RESEARCH.md 1.6).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
|
| 17 |
+
from .schemas import Evidence, FailureMode, Finding
|
| 18 |
+
from .textutils import snippet, split_paragraphs, split_sentences, word_count
|
| 19 |
+
|
| 20 |
+
_NUM_WORDS = {
|
| 21 |
+
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
|
| 22 |
+
"seven": 7, "eight": 8, "nine": 9, "ten": 10, "a single": 1, "single": 1,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _num(tok: str) -> int:
|
| 27 |
+
tok = tok.strip().lower()
|
| 28 |
+
return int(tok) if tok.isdigit() else _NUM_WORDS[tok]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
_NUM_PAT = r"(\d+|one|two|three|four|five|six|seven|eight|nine|ten|a single|single)"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class Constraint:
|
| 36 |
+
kind: str # e.g. "max_words", "format_json"
|
| 37 |
+
description: str # human-readable, e.g. "at most 150 words"
|
| 38 |
+
source_quote: str # the prompt text that produced it
|
| 39 |
+
params: dict = field(default_factory=dict)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class CheckResult:
|
| 44 |
+
constraint: Constraint
|
| 45 |
+
passed: bool
|
| 46 |
+
detail: str # e.g. "212 words (limit 150)"
|
| 47 |
+
evidence: list[Evidence] = field(default_factory=list)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------- extraction
|
| 51 |
+
|
| 52 |
+
_UNIT = r"(words?|sentences?|paragraphs?|bullet ?points?|bullets?|items?|characters?|lines?|sections?)"
|
| 53 |
+
|
| 54 |
+
_LIMIT_PATTERNS = [
|
| 55 |
+
# (regex, bound kind); bound in {"max","min","exact","approx"}
|
| 56 |
+
(re.compile(rf"(?i)\b(?:in|within|under|at most|no more than|not more than|maximum(?: of)?|max\.?|up to|fewer than|less than|no longer than)\s+{_NUM_PAT}\s+{_UNIT}"), "max"),
|
| 57 |
+
(re.compile(rf"(?i)\b(?:at least|minimum(?: of)?|min\.?|no fewer than|more than|over)\s+{_NUM_PAT}\s+{_UNIT}"), "min"),
|
| 58 |
+
(re.compile(rf"(?i)\bexactly\s+{_NUM_PAT}\s+{_UNIT}"), "exact"),
|
| 59 |
+
(re.compile(rf"(?i)\b(?:about|around|approximately|roughly|~)\s*{_NUM_PAT}\s+{_UNIT}"), "approx"),
|
| 60 |
+
(re.compile(rf"(?i)\b{_NUM_PAT}[- ]{_UNIT}\b(?:\s+(?:summary|answer|response|list|essay|description|overview|explanation))"), "approx"),
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
_STRUCT_PATTERNS: list[tuple[re.Pattern, str, str]] = [
|
| 64 |
+
(re.compile(r"(?i)\b(?:as|in|into|to|return|output|respond(?: only)? (?:in|with)|format(?:ted)? as)\s+(?:a |an |valid |pure |raw )?json\b"), "format_json", "output should be valid JSON"),
|
| 65 |
+
(re.compile(r"(?i)\bas (?:a |an )?(?:markdown )?table\b"), "format_table", "output should be a table"),
|
| 66 |
+
(re.compile(r"(?i)\b(?:as|in|using) (?:a )?(?:bullet(?:ed)?|bulleted) (?:points?|list)\b|\bbullet points? only\b"), "format_bullets", "output should be a bulleted list"),
|
| 67 |
+
(re.compile(r"(?i)\bnumbered list\b"), "format_numbered", "output should be a numbered list"),
|
| 68 |
+
(re.compile(r"(?i)\b(?:in |as )(?:a )?single paragraph\b|\bone paragraph\b"), "single_paragraph", "output should be one paragraph"),
|
| 69 |
+
(re.compile(r"(?i)\bin (?:a )?single sentence\b|\bone sentence\b"), "single_sentence", "output should be one sentence"),
|
| 70 |
+
(re.compile(r"(?i)\ball (?:in )?lower ?case\b|\bin lowercase only\b"), "all_lowercase", "output should be all lowercase"),
|
| 71 |
+
(re.compile(r"(?i)\ball (?:in )?(?:upper ?case|caps)\b"), "all_uppercase", "output should be all uppercase"),
|
| 72 |
+
(re.compile(r"(?i)\bno bullet points?\b|\bwithout (?:any )?bullets?\b|\bdo not use bullet\b"), "no_bullets", "output should not use bullet points"),
|
| 73 |
+
(re.compile(r"(?i)\bin plain (?:text|prose)\b|\bno markdown\b|\bwithout (?:any )?(?:markdown|formatting)\b"), "no_markdown", "output should be plain prose without markdown"),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
_INCLUDE_PAT = re.compile(
|
| 77 |
+
r"(?i)\b(?:must (?:include|mention|contain)|be sure to (?:include|mention)|"
|
| 78 |
+
r"include|mention|don'?t forget to (?:include|mention))\s+"
|
| 79 |
+
r"(?:the (?:word|term|phrase)s?\s+)?[\"“']([^\"”']{2,60})[\"”']"
|
| 80 |
+
)
|
| 81 |
+
_EXCLUDE_PAT = re.compile(
|
| 82 |
+
r"(?i)\b(?:do not|don'?t|never|avoid)\s+"
|
| 83 |
+
r"(?:use|mention|include|say)(?:\s+the\s+(?:word|term|phrase)s?)?\s+[\"“']([^\"”']{2,60})[\"”']"
|
| 84 |
+
)
|
| 85 |
+
_START_PAT = re.compile(r"(?i)\b(?:begin|start)\s+(?:your\s+)?(?:response|answer|output|reply|with)?\s*with\s+[\"“']([^\"”']{2,80})[\"”']")
|
| 86 |
+
_END_PAT = re.compile(r"(?i)\bend\s+(?:your\s+)?(?:response|answer|output|reply)?\s*with\s+[\"“']([^\"”']{2,80})[\"”']")
|
| 87 |
+
|
| 88 |
+
_BULLET_LINE = re.compile(r"(?m)^\s*(?:[-*•]|\d+[.)])\s+\S")
|
| 89 |
+
_NUMBERED_LINE = re.compile(r"(?m)^\s*\d+[.)]\s+\S")
|
| 90 |
+
_MD_MARKUP = re.compile(r"(?m)(^#{1,6}\s)|(\*\*)|(^\s*[-*]\s)|(```)|(^\s*>\s)|(\|.+\|)")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def extract_constraints(instruction: str) -> list[Constraint]:
|
| 94 |
+
found: list[Constraint] = []
|
| 95 |
+
seen: set[tuple] = set()
|
| 96 |
+
|
| 97 |
+
for pat, bound in _LIMIT_PATTERNS:
|
| 98 |
+
for m in pat.finditer(instruction):
|
| 99 |
+
n, unit = _num(m.group(1)), m.group(2).lower()
|
| 100 |
+
unit_key = re.sub(r"\s|s$", "", unit)
|
| 101 |
+
unit_key = {"bulletpoint": "bullet", "item": "bullet"}.get(unit_key, unit_key)
|
| 102 |
+
key = (bound, unit_key, n)
|
| 103 |
+
if key in seen:
|
| 104 |
+
continue
|
| 105 |
+
seen.add(key)
|
| 106 |
+
found.append(Constraint(
|
| 107 |
+
kind=f"{bound}_{unit_key}",
|
| 108 |
+
description=f"{ {'max':'at most','min':'at least','exact':'exactly','approx':'about'}[bound] } {n} {unit}",
|
| 109 |
+
source_quote=m.group(0),
|
| 110 |
+
params={"n": n, "bound": bound, "unit": unit_key},
|
| 111 |
+
))
|
| 112 |
+
|
| 113 |
+
for pat, kind, desc in _STRUCT_PATTERNS:
|
| 114 |
+
m = pat.search(instruction)
|
| 115 |
+
if m and ("struct", kind) not in seen:
|
| 116 |
+
seen.add(("struct", kind))
|
| 117 |
+
found.append(Constraint(kind=kind, description=desc, source_quote=m.group(0)))
|
| 118 |
+
|
| 119 |
+
for m in _INCLUDE_PAT.finditer(instruction):
|
| 120 |
+
term = m.group(1).strip()
|
| 121 |
+
if ("include", term.lower()) not in seen:
|
| 122 |
+
seen.add(("include", term.lower()))
|
| 123 |
+
found.append(Constraint(kind="must_include", description=f'must include "{term}"',
|
| 124 |
+
source_quote=m.group(0), params={"term": term}))
|
| 125 |
+
for m in _EXCLUDE_PAT.finditer(instruction):
|
| 126 |
+
term = m.group(1).strip()
|
| 127 |
+
if ("exclude", term.lower()) not in seen:
|
| 128 |
+
seen.add(("exclude", term.lower()))
|
| 129 |
+
found.append(Constraint(kind="must_exclude", description=f'must not use "{term}"',
|
| 130 |
+
source_quote=m.group(0), params={"term": term}))
|
| 131 |
+
m = _START_PAT.search(instruction)
|
| 132 |
+
if m:
|
| 133 |
+
found.append(Constraint(kind="starts_with", description=f'must start with "{m.group(1)}"',
|
| 134 |
+
source_quote=m.group(0), params={"term": m.group(1)}))
|
| 135 |
+
m = _END_PAT.search(instruction)
|
| 136 |
+
if m:
|
| 137 |
+
found.append(Constraint(kind="ends_with", description=f'must end with "{m.group(1)}"',
|
| 138 |
+
source_quote=m.group(0), params={"term": m.group(1)}))
|
| 139 |
+
return found
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ------------------------------------------------------------------- checking
|
| 143 |
+
|
| 144 |
+
def _count_units(output: str, unit: str) -> int:
|
| 145 |
+
if unit == "word":
|
| 146 |
+
return word_count(output)
|
| 147 |
+
if unit == "sentence":
|
| 148 |
+
return len(split_sentences(output))
|
| 149 |
+
if unit == "paragraph":
|
| 150 |
+
return len(split_paragraphs(output))
|
| 151 |
+
if unit == "character":
|
| 152 |
+
return len(output.strip())
|
| 153 |
+
if unit == "line":
|
| 154 |
+
return len([ln for ln in output.splitlines() if ln.strip()])
|
| 155 |
+
if unit in ("bullet",):
|
| 156 |
+
return len(_BULLET_LINE.findall(output))
|
| 157 |
+
if unit == "section":
|
| 158 |
+
return len(re.findall(r"(?m)^#{1,6}\s+\S", output)) or len(split_paragraphs(output))
|
| 159 |
+
return word_count(output)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _strip_code_blocks(output: str) -> str:
|
| 163 |
+
return re.sub(r"```.*?```", "", output, flags=re.S)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def check_constraint(c: Constraint, output: str) -> CheckResult:
|
| 167 |
+
kind = c.kind
|
| 168 |
+
if kind.startswith(("max_", "min_", "exact_", "approx_")):
|
| 169 |
+
bound, unit = c.params["bound"], c.params["unit"]
|
| 170 |
+
n = c.params["n"]
|
| 171 |
+
actual = _count_units(output, unit)
|
| 172 |
+
if bound == "max":
|
| 173 |
+
ok = actual <= n
|
| 174 |
+
elif bound == "min":
|
| 175 |
+
ok = actual >= n
|
| 176 |
+
elif bound == "exact":
|
| 177 |
+
ok = actual == n
|
| 178 |
+
else: # approx: ±20% with a floor of ±2
|
| 179 |
+
tol = max(2, round(n * 0.2))
|
| 180 |
+
ok = abs(actual - n) <= tol
|
| 181 |
+
return CheckResult(c, ok, f"{actual} {unit}{'' if actual == 1 else 's'} (asked: {c.description})")
|
| 182 |
+
|
| 183 |
+
if kind == "format_json":
|
| 184 |
+
text = output.strip()
|
| 185 |
+
fence = re.search(r"```(?:json)?\s*(.+?)```", text, flags=re.S)
|
| 186 |
+
candidate = fence.group(1).strip() if fence else text
|
| 187 |
+
try:
|
| 188 |
+
json.loads(candidate)
|
| 189 |
+
extra = text != candidate and not fence
|
| 190 |
+
return CheckResult(c, not extra, "valid JSON" if not extra else "JSON present but wrapped in extra prose")
|
| 191 |
+
except json.JSONDecodeError as e:
|
| 192 |
+
return CheckResult(c, False, f"not valid JSON ({e.msg} at position {e.pos})",
|
| 193 |
+
[Evidence(quote=snippet(candidate[max(0, e.pos - 60):e.pos + 60]), note="around the parse error")])
|
| 194 |
+
|
| 195 |
+
if kind == "format_table":
|
| 196 |
+
ok = bool(re.search(r"(?m)^\|.+\|\s*$", output)) or bool(re.search(r"(?m)^.+\t.+$", output))
|
| 197 |
+
return CheckResult(c, ok, "table detected" if ok else "no table structure found in output")
|
| 198 |
+
|
| 199 |
+
if kind == "format_bullets":
|
| 200 |
+
n = len(_BULLET_LINE.findall(output))
|
| 201 |
+
return CheckResult(c, n >= 2, f"{n} bullet lines found")
|
| 202 |
+
|
| 203 |
+
if kind == "format_numbered":
|
| 204 |
+
n = len(_NUMBERED_LINE.findall(output))
|
| 205 |
+
return CheckResult(c, n >= 2, f"{n} numbered lines found")
|
| 206 |
+
|
| 207 |
+
if kind == "single_paragraph":
|
| 208 |
+
n = len(split_paragraphs(output))
|
| 209 |
+
return CheckResult(c, n == 1, f"{n} paragraphs")
|
| 210 |
+
|
| 211 |
+
if kind == "single_sentence":
|
| 212 |
+
n = len(split_sentences(output))
|
| 213 |
+
return CheckResult(c, n == 1, f"{n} sentences")
|
| 214 |
+
|
| 215 |
+
if kind == "all_lowercase":
|
| 216 |
+
letters = re.sub(r"[^A-Za-z]", "", output)
|
| 217 |
+
ok = letters.islower() if letters else True
|
| 218 |
+
bad = re.search(r"[A-Z][a-zA-Z]*", output)
|
| 219 |
+
ev = [Evidence(quote=snippet(bad.group(0)), note="uppercase found")] if bad else []
|
| 220 |
+
return CheckResult(c, ok, "all lowercase" if ok else "contains uppercase", ev)
|
| 221 |
+
|
| 222 |
+
if kind == "all_uppercase":
|
| 223 |
+
letters = re.sub(r"[^A-Za-z]", "", output)
|
| 224 |
+
ok = letters.isupper() if letters else True
|
| 225 |
+
return CheckResult(c, ok, "all caps" if ok else "contains lowercase")
|
| 226 |
+
|
| 227 |
+
if kind == "no_bullets":
|
| 228 |
+
n = len(_BULLET_LINE.findall(output))
|
| 229 |
+
return CheckResult(c, n == 0, "no bullets" if n == 0 else f"{n} bullet lines present")
|
| 230 |
+
|
| 231 |
+
if kind == "no_markdown":
|
| 232 |
+
m = _MD_MARKUP.search(output)
|
| 233 |
+
return CheckResult(c, m is None, "plain prose" if m is None else "markdown markup present",
|
| 234 |
+
[Evidence(quote=snippet(m.group(0)))] if m else [])
|
| 235 |
+
|
| 236 |
+
if kind == "must_include":
|
| 237 |
+
term = c.params["term"]
|
| 238 |
+
ok = term.lower() in output.lower()
|
| 239 |
+
return CheckResult(c, ok, f'"{term}" {"present" if ok else "missing from output"}')
|
| 240 |
+
|
| 241 |
+
if kind == "must_exclude":
|
| 242 |
+
term = c.params["term"]
|
| 243 |
+
i = output.lower().find(term.lower())
|
| 244 |
+
ok = i < 0
|
| 245 |
+
ev = [] if ok else [Evidence(quote=snippet(output[max(0, i - 50):i + len(term) + 50]))]
|
| 246 |
+
return CheckResult(c, ok, f'"{term}" {"absent" if ok else "appears in output"}', ev)
|
| 247 |
+
|
| 248 |
+
if kind == "starts_with":
|
| 249 |
+
term = c.params["term"]
|
| 250 |
+
ok = output.strip().lower().startswith(term.lower())
|
| 251 |
+
return CheckResult(c, ok, "starts as required" if ok else f'output starts with "{snippet(output.strip(), 50)}"')
|
| 252 |
+
|
| 253 |
+
if kind == "ends_with":
|
| 254 |
+
term = c.params["term"]
|
| 255 |
+
ok = output.strip().rstrip(".!?").lower().endswith(term.lower().rstrip(".!?"))
|
| 256 |
+
return CheckResult(c, ok, "ends as required" if ok else f'output ends with "…{output.strip()[-50:]}"')
|
| 257 |
+
|
| 258 |
+
return CheckResult(c, True, "constraint recognized but no checker bound (reported, not scored)")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def run_checks(instruction: str, output: str) -> list[CheckResult]:
|
| 262 |
+
prose = output
|
| 263 |
+
constraints = extract_constraints(instruction)
|
| 264 |
+
# Word/sentence limits should not count code inside fences for mixed outputs.
|
| 265 |
+
results = []
|
| 266 |
+
for c in constraints:
|
| 267 |
+
target = prose
|
| 268 |
+
if c.kind.split("_")[-1] in ("word", "sentence") and "```" in output:
|
| 269 |
+
target = _strip_code_blocks(output)
|
| 270 |
+
results.append(check_constraint(c, target))
|
| 271 |
+
return results
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def to_findings(results: list[CheckResult]) -> list[Finding]:
|
| 275 |
+
findings = []
|
| 276 |
+
for r in results:
|
| 277 |
+
if r.passed:
|
| 278 |
+
continue
|
| 279 |
+
findings.append(Finding(
|
| 280 |
+
failure_mode=FailureMode.FORMAT_VIOLATION if r.constraint.kind.startswith(("format", "single", "no_", "all_"))
|
| 281 |
+
else FailureMode.MISSED_CONSTRAINT,
|
| 282 |
+
detail=f"Prompt asked for {r.constraint.description} — output has {r.detail}.",
|
| 283 |
+
evidence=r.evidence + [Evidence(quote=r.constraint.source_quote, source="prompt", note="the instruction that set this constraint")],
|
| 284 |
+
data={"kind": r.constraint.kind, **r.constraint.params, "actual": r.detail},
|
| 285 |
+
))
|
| 286 |
+
return findings
|
rubriq/metrics/__init__.py
ADDED
|
File without changes
|
rubriq/metrics/code_static.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Static code checks (RESEARCH.md 1.11). No execution in v1 — the ladder:
|
| 2 |
+
does it parse, is requested API surface present, are there placeholder stubs.
|
| 3 |
+
Honesty note attached by the scorer: static analysis cannot prove the code
|
| 4 |
+
runs or is correct; that is HumanEval-style territory reserved for judge mode.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import ast
|
| 10 |
+
import re
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
|
| 13 |
+
from ..textutils import snippet
|
| 14 |
+
|
| 15 |
+
_FENCE = re.compile(r"```(\w+)?\s*\n(.*?)```", re.S)
|
| 16 |
+
_PLACEHOLDERS = re.compile(
|
| 17 |
+
r"(?i)(?:#|//|/\*)?\s*(TODO|FIXME|your[_ ](?:api[_ ]key|code|logic)[_ ]here|"
|
| 18 |
+
r"implement (?:this|me)|\.\.\.\s*$|<[A-Z_]+>|pass\s*#\s*placeholder)", re.M)
|
| 19 |
+
_REQUESTED_NAME = re.compile(
|
| 20 |
+
r"(?i)\b(?:function|method|class)\s+(?:called|named)\s+[`'\"]?([A-Za-z_][A-Za-z0-9_]*)")
|
| 21 |
+
|
| 22 |
+
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class CodeBlock:
|
| 27 |
+
language: str
|
| 28 |
+
code: str
|
| 29 |
+
parses: bool | None # None = no parser for this language, balance-checked only
|
| 30 |
+
parse_error: str = ""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class CodeStaticResult:
|
| 35 |
+
blocks: list[CodeBlock] = field(default_factory=list)
|
| 36 |
+
has_code: bool = False
|
| 37 |
+
placeholders: list[str] = field(default_factory=list)
|
| 38 |
+
requested_names_missing: list[str] = field(default_factory=list)
|
| 39 |
+
prose_only: bool = False # code was requested but none found
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _balanced(code: str) -> bool:
|
| 43 |
+
"""Bracket balance ignoring string literals (rough, for non-Python)."""
|
| 44 |
+
stack = []
|
| 45 |
+
in_str: str | None = None
|
| 46 |
+
prev = ""
|
| 47 |
+
for ch in code:
|
| 48 |
+
if in_str:
|
| 49 |
+
if ch == in_str and prev != "\\":
|
| 50 |
+
in_str = None
|
| 51 |
+
elif ch in "\"'":
|
| 52 |
+
in_str = ch
|
| 53 |
+
elif ch in _BRACKETS:
|
| 54 |
+
stack.append(_BRACKETS[ch])
|
| 55 |
+
elif ch in _BRACKETS.values():
|
| 56 |
+
if not stack or stack.pop() != ch:
|
| 57 |
+
return False
|
| 58 |
+
prev = ch
|
| 59 |
+
return not stack
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def compute(instruction: str, output: str) -> CodeStaticResult:
|
| 63 |
+
result = CodeStaticResult()
|
| 64 |
+
fences = _FENCE.findall(output)
|
| 65 |
+
|
| 66 |
+
# Unfenced fallback: output that is obviously bare code.
|
| 67 |
+
if not fences and re.search(r"(?m)^(def |class |function |import |from \w+ import |const |let |public )", output):
|
| 68 |
+
fences = [("", output)]
|
| 69 |
+
|
| 70 |
+
for lang, code in fences:
|
| 71 |
+
lang = (lang or "").lower()
|
| 72 |
+
code = code.strip()
|
| 73 |
+
if not code:
|
| 74 |
+
continue
|
| 75 |
+
block = CodeBlock(language=lang or "unknown", code=code, parses=None)
|
| 76 |
+
if lang in ("python", "py", "") and re.search(r"(?m)^(def |class |import |from )", code):
|
| 77 |
+
block.language = "python"
|
| 78 |
+
try:
|
| 79 |
+
ast.parse(code)
|
| 80 |
+
block.parses = True
|
| 81 |
+
except SyntaxError as e:
|
| 82 |
+
block.parses = False
|
| 83 |
+
block.parse_error = f"line {e.lineno}: {e.msg}"
|
| 84 |
+
elif lang == "json":
|
| 85 |
+
import json
|
| 86 |
+
try:
|
| 87 |
+
json.loads(code)
|
| 88 |
+
block.parses = True
|
| 89 |
+
except json.JSONDecodeError as e:
|
| 90 |
+
block.parses = False
|
| 91 |
+
block.parse_error = e.msg
|
| 92 |
+
else:
|
| 93 |
+
block.parses = None if _balanced(code) else False
|
| 94 |
+
if block.parses is False:
|
| 95 |
+
block.parse_error = "unbalanced brackets/braces"
|
| 96 |
+
result.blocks.append(block)
|
| 97 |
+
|
| 98 |
+
result.has_code = bool(result.blocks)
|
| 99 |
+
result.prose_only = not result.has_code
|
| 100 |
+
|
| 101 |
+
all_code = "\n".join(b.code for b in result.blocks)
|
| 102 |
+
result.placeholders = [snippet(m.group(0), 60) for m in _PLACEHOLDERS.finditer(all_code)][:5]
|
| 103 |
+
|
| 104 |
+
for m in _REQUESTED_NAME.finditer(instruction):
|
| 105 |
+
name = m.group(1)
|
| 106 |
+
if not re.search(rf"\b{re.escape(name)}\b", all_code):
|
| 107 |
+
result.requested_names_missing.append(name)
|
| 108 |
+
|
| 109 |
+
return result
|
rubriq/metrics/faithfulness.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""NLI faithfulness — SummaC-ZS (Laban et al. 2022), RESEARCH.md 1.2.
|
| 2 |
+
|
| 3 |
+
Output sentences are scored for entailment against source chunks with
|
| 4 |
+
cross-encoder/nli-deberta-v3-small. Per output sentence we take the max
|
| 5 |
+
entailment over source chunks ("is this sentence supported anywhere?");
|
| 6 |
+
the document score is the mean; the least-supported sentences become quoted
|
| 7 |
+
evidence for unfaithful_content findings.
|
| 8 |
+
|
| 9 |
+
The cross-encoder returns logits over (contradiction, entailment, neutral)
|
| 10 |
+
— sbert's documented label order for the nli-deberta-v3 family.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
|
| 17 |
+
from ..models import get_nli
|
| 18 |
+
from ..textutils import snippet, split_sentences, word_count
|
| 19 |
+
|
| 20 |
+
_LABELS = ("contradiction", "entailment", "neutral")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class SentenceSupport:
|
| 25 |
+
sentence: str
|
| 26 |
+
entailment: float # max over source chunks
|
| 27 |
+
contradiction: float # at the chunk where entailment peaked
|
| 28 |
+
best_chunk: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class FaithfulnessResult:
|
| 33 |
+
mean_entailment: float
|
| 34 |
+
sentences: list[SentenceSupport]
|
| 35 |
+
unsupported: list[SentenceSupport] # entailment below threshold
|
| 36 |
+
contradicted: list[SentenceSupport] # contradiction dominant somewhere
|
| 37 |
+
threshold: float
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _chunks(source: str, max_words: int = 90) -> list[str]:
|
| 41 |
+
"""Sliding sentence windows so support spanning two sentences isn't missed."""
|
| 42 |
+
sents = split_sentences(source)
|
| 43 |
+
if not sents:
|
| 44 |
+
return [source]
|
| 45 |
+
chunks, cur, cur_w = [], [], 0
|
| 46 |
+
for s in sents:
|
| 47 |
+
w = word_count(s)
|
| 48 |
+
cur.append(s)
|
| 49 |
+
cur_w += w
|
| 50 |
+
if cur_w >= max_words:
|
| 51 |
+
chunks.append(" ".join(cur))
|
| 52 |
+
cur, cur_w = cur[-1:], word_count(cur[-1]) if cur else 0
|
| 53 |
+
if cur:
|
| 54 |
+
chunks.append(" ".join(cur))
|
| 55 |
+
return chunks or [source]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _score_sentences(sents: list[str]) -> list[str]:
|
| 59 |
+
"""Filter out non-claims: headers, tiny fragments, questions."""
|
| 60 |
+
return [s for s in sents if word_count(s) >= 4 and not s.strip().endswith("?")
|
| 61 |
+
and not s.strip().startswith("#")]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def compute(source: str, output: str, threshold: float = 0.5) -> FaithfulnessResult | None:
|
| 65 |
+
nli = get_nli()
|
| 66 |
+
if nli is None:
|
| 67 |
+
return None
|
| 68 |
+
import numpy as np
|
| 69 |
+
|
| 70 |
+
out_sents = _score_sentences(split_sentences(output))
|
| 71 |
+
if not out_sents:
|
| 72 |
+
return None
|
| 73 |
+
chunks = _chunks(source)
|
| 74 |
+
|
| 75 |
+
pairs = [(c, s) for s in out_sents for c in chunks]
|
| 76 |
+
logits = nli.predict(pairs, batch_size=16)
|
| 77 |
+
probs = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True)
|
| 78 |
+
probs = probs.reshape(len(out_sents), len(chunks), 3)
|
| 79 |
+
|
| 80 |
+
ent_idx = _LABELS.index("entailment")
|
| 81 |
+
con_idx = _LABELS.index("contradiction")
|
| 82 |
+
|
| 83 |
+
supports: list[SentenceSupport] = []
|
| 84 |
+
for i, s in enumerate(out_sents):
|
| 85 |
+
best_j = int(probs[i, :, ent_idx].argmax())
|
| 86 |
+
supports.append(SentenceSupport(
|
| 87 |
+
sentence=s,
|
| 88 |
+
entailment=round(float(probs[i, best_j, ent_idx]), 3),
|
| 89 |
+
contradiction=round(float(probs[i, :, con_idx].max()), 3),
|
| 90 |
+
best_chunk=snippet(chunks[best_j]),
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
unsupported = [s for s in supports if s.entailment < threshold]
|
| 94 |
+
contradicted = [s for s in supports if s.contradiction > 0.5]
|
| 95 |
+
mean_ent = round(sum(s.entailment for s in supports) / len(supports), 3)
|
| 96 |
+
|
| 97 |
+
return FaithfulnessResult(
|
| 98 |
+
mean_entailment=mean_ent,
|
| 99 |
+
sentences=supports,
|
| 100 |
+
unsupported=sorted(unsupported, key=lambda s: s.entailment)[:5],
|
| 101 |
+
contradicted=contradicted[:5],
|
| 102 |
+
threshold=threshold,
|
| 103 |
+
)
|
rubriq/metrics/hallucination_risk.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hallucination *risk signals* for ungrounded prompts (RESEARCH.md 1.9).
|
| 2 |
+
|
| 3 |
+
Without a source or knowledge base, no local method can verify claims
|
| 4 |
+
(FActScore's precondition). What we CAN do is flag the patterns that
|
| 5 |
+
correlate with fabrication: citation-shaped strings, precise unattributed
|
| 6 |
+
statistics, vague-authority claims, and confident framing around
|
| 7 |
+
unverifiable specifics. Wording contract: every finding says "unverifiable
|
| 8 |
+
here", never "false".
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
|
| 16 |
+
from ..textutils import snippet
|
| 17 |
+
|
| 18 |
+
_SIGNALS: list[tuple[str, re.Pattern, str]] = [
|
| 19 |
+
("citation_pattern",
|
| 20 |
+
re.compile(r"\([A-Z][A-Za-z-]+(?: (?:et al\.?|&|and) [A-Z][A-Za-z-]+)?,? (?:19|20)\d{2}\)|\[[0-9]{1,3}\]"),
|
| 21 |
+
"citation-shaped reference — LLMs fabricate plausible-looking citations; verify each one exists"),
|
| 22 |
+
("case_or_statute",
|
| 23 |
+
re.compile(r"\b[A-Z][a-z]+ v\.? [A-Z][a-z]+\b|\b\d+ U\.S\.C?\.? §? ?\d+\b"),
|
| 24 |
+
"legal citation pattern — a documented fabrication hotspot (Mata v. Avianca, 2023)"),
|
| 25 |
+
("precise_statistic",
|
| 26 |
+
re.compile(r"\b\d{1,3}(?:\.\d{1,2})?%|\b\d+(?:\.\d+)? (?:million|billion|trillion)\b"),
|
| 27 |
+
"precise statistic with no source in the prompt to check it against"),
|
| 28 |
+
("vague_authority",
|
| 29 |
+
re.compile(r"(?i)\b(?:studies|research|experts|scientists|reports?) (?:show|shows|suggest|indicate|have (?:shown|found)|found|say|agree|confirm)\b"),
|
| 30 |
+
"appeal to unnamed studies/experts — no way to trace the claim"),
|
| 31 |
+
("url",
|
| 32 |
+
re.compile(r"https?://\S+"),
|
| 33 |
+
"URL in output — LLMs generate dead or wrong links; verify before trusting"),
|
| 34 |
+
("specific_date_event",
|
| 35 |
+
re.compile(r"(?i)\b(?:on|in) (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2},? (?:19|20)\d{2}\b"),
|
| 36 |
+
"specific dated event — unverifiable without a source"),
|
| 37 |
+
("named_figure_quote",
|
| 38 |
+
re.compile(r"(?i)(?:said|stated|according to|wrote|declared)[,:]? ?[\"“]"),
|
| 39 |
+
"attributed quotation — fabricated quotes are a known failure pattern"),
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
_HEDGES = re.compile(r"(?i)\b(?:reportedly|allegedly|approximately|around|roughly|estimated|as of my knowledge|may|might|possibly|it is believed)\b")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class RiskSignal:
|
| 47 |
+
kind: str
|
| 48 |
+
quote: str
|
| 49 |
+
why: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class HallucinationRiskResult:
|
| 54 |
+
signals: list[RiskSignal] = field(default_factory=list)
|
| 55 |
+
hedge_count: int = 0
|
| 56 |
+
density_per_100w: float = 0.0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def compute(output: str, n_words: int) -> HallucinationRiskResult:
|
| 60 |
+
signals: list[RiskSignal] = []
|
| 61 |
+
seen: set[str] = set()
|
| 62 |
+
for kind, pat, why in _SIGNALS:
|
| 63 |
+
for m in pat.finditer(output):
|
| 64 |
+
start = max(0, m.start() - 60)
|
| 65 |
+
quote = snippet(output[start:m.end() + 60])
|
| 66 |
+
key = f"{kind}:{m.group(0)[:40]}"
|
| 67 |
+
if key in seen:
|
| 68 |
+
continue
|
| 69 |
+
seen.add(key)
|
| 70 |
+
signals.append(RiskSignal(kind=kind, quote=quote, why=why))
|
| 71 |
+
if sum(1 for s in signals if s.kind == kind) >= 3:
|
| 72 |
+
break # cap per kind; the report needs examples, not a census
|
| 73 |
+
|
| 74 |
+
return HallucinationRiskResult(
|
| 75 |
+
signals=signals,
|
| 76 |
+
hedge_count=len(_HEDGES.findall(output)),
|
| 77 |
+
density_per_100w=round(100 * len(signals) / max(1, n_words), 2),
|
| 78 |
+
)
|
rubriq/metrics/lexical.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lexical diversity and repetition (RESEARCH.md 1.10).
|
| 2 |
+
|
| 3 |
+
MTLD (McCarthy & Jarvis 2010) for length-robust diversity; cliché density
|
| 4 |
+
against a fixed list; sentence-length variance as the rhythm proxy; repeated
|
| 5 |
+
n-grams for mechanical repetition. All of these are labeled proxies in the
|
| 6 |
+
creative-writing bundle (carve-out #2).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from collections import Counter
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
|
| 15 |
+
from ..textutils import split_sentences, word_count, words
|
| 16 |
+
|
| 17 |
+
_CLICHES = [
|
| 18 |
+
"at the end of the day", "in this day and age", "the calm before the storm",
|
| 19 |
+
"a whirlwind of emotions", "time stood still", "heart skipped a beat",
|
| 20 |
+
"sent shivers down", "a rollercoaster of", "little did they know",
|
| 21 |
+
"in the blink of an eye", "against all odds", "a testament to",
|
| 22 |
+
"the fabric of", "a tapestry of", "nestled in", "bustling", "myriad of",
|
| 23 |
+
"delve into", "embark on a journey", "unlock the secrets", "hidden gem",
|
| 24 |
+
"breathtaking", "picturesque", "last but not least", "needless to say",
|
| 25 |
+
"it goes without saying", "each and every", "first and foremost",
|
| 26 |
+
"stood the test of time", "beacon of hope", "double-edged sword",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def mtld(tokens: list[str], threshold: float = 0.72) -> float | None:
|
| 31 |
+
"""Measure of Textual Lexical Diversity, bidirectional mean."""
|
| 32 |
+
if len(tokens) < 50:
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
def _pass(seq: list[str]) -> float:
|
| 36 |
+
factors, types, count = 0.0, set(), 0
|
| 37 |
+
for tok in seq:
|
| 38 |
+
count += 1
|
| 39 |
+
types.add(tok)
|
| 40 |
+
if len(types) / count <= threshold:
|
| 41 |
+
factors += 1
|
| 42 |
+
types, count = set(), 0
|
| 43 |
+
if count:
|
| 44 |
+
ttr = len(types) / count
|
| 45 |
+
factors += (1 - ttr) / (1 - threshold) if ttr > threshold else 1
|
| 46 |
+
return len(seq) / factors if factors else float(len(seq))
|
| 47 |
+
|
| 48 |
+
return round((_pass(tokens) + _pass(tokens[::-1])) / 2, 1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class LexicalStats:
|
| 53 |
+
mtld: float | None
|
| 54 |
+
cliche_hits: list[str]
|
| 55 |
+
cliches_per_100w: float
|
| 56 |
+
sentence_len_cv: float | None # coefficient of variation of sentence lengths
|
| 57 |
+
repeated_ngrams: list[str] # 4-grams appearing 3+ times
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def compute(text: str) -> LexicalStats:
|
| 61 |
+
toks = words(text)
|
| 62 |
+
n_w = max(1, len(toks))
|
| 63 |
+
low = text.lower()
|
| 64 |
+
hits = [c for c in _CLICHES if c in low]
|
| 65 |
+
|
| 66 |
+
sents = split_sentences(text)
|
| 67 |
+
lens = [word_count(s) for s in sents]
|
| 68 |
+
cv = None
|
| 69 |
+
if len(lens) >= 3:
|
| 70 |
+
mean = sum(lens) / len(lens)
|
| 71 |
+
var = sum((x - mean) ** 2 for x in lens) / len(lens)
|
| 72 |
+
cv = round((var ** 0.5) / mean, 2) if mean else None
|
| 73 |
+
|
| 74 |
+
grams = Counter(
|
| 75 |
+
" ".join(toks[i:i + 4]) for i in range(len(toks) - 3)
|
| 76 |
+
)
|
| 77 |
+
repeated = [g for g, c in grams.most_common(5) if c >= 3 and not re.fullmatch(r"(\w+ )*(the|of|and|a|to|in)( \w+)*", g)]
|
| 78 |
+
|
| 79 |
+
return LexicalStats(
|
| 80 |
+
mtld=mtld(toks),
|
| 81 |
+
cliche_hits=hits,
|
| 82 |
+
cliches_per_100w=round(100 * len(hits) / n_w, 2),
|
| 83 |
+
sentence_len_cv=cv,
|
| 84 |
+
repeated_ngrams=repeated,
|
| 85 |
+
)
|
rubriq/metrics/readability.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Readability and fluency statistics (RESEARCH.md 1.10).
|
| 2 |
+
|
| 3 |
+
Flesch Reading Ease / Flesch-Kincaid grade are audience-fit signals, not
|
| 4 |
+
quality scores: the score maps to a target band derived from the stated (or
|
| 5 |
+
inferred) audience, and distance from the band is what counts against the
|
| 6 |
+
output.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from ..textutils import count_syllables, split_sentences, words
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class ReadabilityStats:
|
| 18 |
+
flesch_reading_ease: float
|
| 19 |
+
fk_grade: float
|
| 20 |
+
avg_sentence_words: float
|
| 21 |
+
n_sentences: int
|
| 22 |
+
n_words: int
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute(text: str) -> ReadabilityStats | None:
|
| 26 |
+
sents = split_sentences(text)
|
| 27 |
+
toks = words(text)
|
| 28 |
+
if not sents or len(toks) < 10:
|
| 29 |
+
return None
|
| 30 |
+
syllables = sum(count_syllables(w) for w in toks)
|
| 31 |
+
n_w, n_s = len(toks), len(sents)
|
| 32 |
+
fre = 206.835 - 1.015 * (n_w / n_s) - 84.6 * (syllables / n_w)
|
| 33 |
+
fk = 0.39 * (n_w / n_s) + 11.8 * (syllables / n_w) - 15.59
|
| 34 |
+
return ReadabilityStats(
|
| 35 |
+
flesch_reading_ease=round(fre, 1),
|
| 36 |
+
fk_grade=round(fk, 1),
|
| 37 |
+
avg_sentence_words=round(n_w / n_s, 1),
|
| 38 |
+
n_sentences=n_s,
|
| 39 |
+
n_words=n_w,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Audience keyword -> acceptable FK grade band (inclusive).
|
| 44 |
+
_AUDIENCE_BANDS: list[tuple[tuple[str, ...], tuple[float, float], str]] = [
|
| 45 |
+
(("child", "kid", "5 year", "five year", "eli5", "young"), (0.0, 6.0), "young readers"),
|
| 46 |
+
(("teen", "high school", "student", "beginner", "layman", "non-technical", "general public", "simple"), (5.0, 10.0), "general readers"),
|
| 47 |
+
(("professional", "business", "executive",), (8.0, 14.0), "professional readers"),
|
| 48 |
+
(("expert", "academic", "technical", "clinician", "researcher", "phd", "specialist"), (10.0, 18.0), "expert readers"),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
DEFAULT_BAND = (6.0, 14.0)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def audience_band(audience_text: str, instruction: str) -> tuple[tuple[float, float], str]:
|
| 55 |
+
"""Pick the FK grade band from the advanced-options audience field,
|
| 56 |
+
falling back to audience words inside the instruction itself."""
|
| 57 |
+
for probe in (audience_text.lower(), instruction.lower()):
|
| 58 |
+
if not probe:
|
| 59 |
+
continue
|
| 60 |
+
for keys, band, label in _AUDIENCE_BANDS:
|
| 61 |
+
if any(k in probe for k in keys):
|
| 62 |
+
return band, label
|
| 63 |
+
return DEFAULT_BAND, "unspecified audience (default band)"
|
rubriq/metrics/relevance.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Answer relevance via embeddings (RESEARCH.md 1.3/1.4, RAGAS-style,
|
| 2 |
+
adapted to run without an LLM: cosine similarity between the instruction and
|
| 3 |
+
the output, plus per-paragraph drill-down to locate where drift happens).
|
| 4 |
+
|
| 5 |
+
bge models are asymmetric retrievers: queries get the documented
|
| 6 |
+
"Represent this sentence..." prefix, passages do not.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from ..models import get_embedder
|
| 14 |
+
from ..textutils import snippet, split_paragraphs
|
| 15 |
+
|
| 16 |
+
_BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class RelevanceResult:
|
| 21 |
+
overall: float # cosine, roughly 0..1 for bge
|
| 22 |
+
worst_paragraph: str | None
|
| 23 |
+
worst_paragraph_sim: float | None
|
| 24 |
+
paragraph_sims: list[tuple[str, float]]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def compute(instruction: str, output: str) -> RelevanceResult | None:
|
| 28 |
+
embedder = get_embedder()
|
| 29 |
+
if embedder is None:
|
| 30 |
+
return None
|
| 31 |
+
paragraphs = split_paragraphs(output) or [output]
|
| 32 |
+
texts = [_BGE_QUERY_PREFIX + instruction, output] + paragraphs
|
| 33 |
+
vecs = embedder.encode(texts, normalize_embeddings=True)
|
| 34 |
+
q, whole, para_vecs = vecs[0], vecs[1], vecs[2:]
|
| 35 |
+
overall = float(q @ whole)
|
| 36 |
+
sims = [(p, float(q @ v)) for p, v in zip(paragraphs, para_vecs)]
|
| 37 |
+
worst = min(sims, key=lambda x: x[1]) if len(sims) > 1 else (None, None)
|
| 38 |
+
return RelevanceResult(
|
| 39 |
+
overall=round(overall, 3),
|
| 40 |
+
worst_paragraph=snippet(worst[0]) if worst[0] else None,
|
| 41 |
+
worst_paragraph_sim=round(worst[1], 3) if worst[1] is not None else None,
|
| 42 |
+
paragraph_sims=[(snippet(p, 80), round(s, 3)) for p, s in sims],
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def similarity(a: str, b: str) -> float | None:
|
| 47 |
+
"""Symmetric semantic similarity (used by rewriting's meaning check)."""
|
| 48 |
+
embedder = get_embedder()
|
| 49 |
+
if embedder is None:
|
| 50 |
+
return None
|
| 51 |
+
va, vb = embedder.encode([a, b], normalize_embeddings=True)
|
| 52 |
+
return round(float(va @ vb), 3)
|
rubriq/metrics/structure.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structure, coherence proxy, and conciseness signals.
|
| 2 |
+
|
| 3 |
+
Coherence proxy per the Coh-Metrix tradition adapted to embeddings
|
| 4 |
+
(RESEARCH.md 1.10): adjacent-unit semantic similarity should sit in a
|
| 5 |
+
middle band — too low reads disjointed, too high reads repetitive.
|
| 6 |
+
Conciseness inverts MT-Bench's verbosity-bias finding (1.6).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
from ..models import get_embedder
|
| 14 |
+
from ..textutils import snippet, split_paragraphs, split_sentences, word_count
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class CoherenceResult:
|
| 19 |
+
mean_adjacent_sim: float
|
| 20 |
+
flagged_low: list[tuple[str, str, float]] # (unit_a, unit_b, sim) — disjointed jumps
|
| 21 |
+
flagged_high: list[tuple[str, str, float]] # near-duplicate units
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def coherence_proxy(text: str) -> CoherenceResult | None:
|
| 25 |
+
embedder = get_embedder()
|
| 26 |
+
if embedder is None:
|
| 27 |
+
return None
|
| 28 |
+
units = split_paragraphs(text)
|
| 29 |
+
if len(units) < 3:
|
| 30 |
+
units = split_sentences(text)
|
| 31 |
+
if len(units) < 3:
|
| 32 |
+
return None
|
| 33 |
+
vecs = embedder.encode(units, normalize_embeddings=True)
|
| 34 |
+
sims = [float(vecs[i] @ vecs[i + 1]) for i in range(len(vecs) - 1)]
|
| 35 |
+
low = [(snippet(units[i], 70), snippet(units[i + 1], 70), round(s, 2))
|
| 36 |
+
for i, s in enumerate(sims) if s < 0.35][:3]
|
| 37 |
+
high = [(snippet(units[i], 70), snippet(units[i + 1], 70), round(s, 2))
|
| 38 |
+
for i, s in enumerate(sims) if s > 0.92][:3]
|
| 39 |
+
return CoherenceResult(
|
| 40 |
+
mean_adjacent_sim=round(sum(sims) / len(sims), 3),
|
| 41 |
+
flagged_low=low,
|
| 42 |
+
flagged_high=high,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class ConcisenessResult:
|
| 48 |
+
output_words: int
|
| 49 |
+
expected_max: int | None # None when no expectation could be set
|
| 50 |
+
ratio: float | None
|
| 51 |
+
filler_hits: list[str]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
_FILLERS = [
|
| 55 |
+
"it is important to note that", "it is worth noting", "as mentioned earlier",
|
| 56 |
+
"in order to", "due to the fact that", "at this point in time",
|
| 57 |
+
"it goes without saying", "needless to say", "in the world of",
|
| 58 |
+
"in today's", "when it comes to", "at the end of the day",
|
| 59 |
+
"i hope this helps", "certainly!", "great question",
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def conciseness(instruction: str, output: str, task_expected_words: int | None) -> ConcisenessResult:
|
| 64 |
+
n = word_count(output)
|
| 65 |
+
low_out = output.lower()
|
| 66 |
+
hits = [f for f in _FILLERS if f in low_out]
|
| 67 |
+
ratio = round(n / task_expected_words, 2) if task_expected_words else None
|
| 68 |
+
return ConcisenessResult(
|
| 69 |
+
output_words=n,
|
| 70 |
+
expected_max=task_expected_words,
|
| 71 |
+
ratio=ratio,
|
| 72 |
+
filler_hits=hits,
|
| 73 |
+
)
|
rubriq/models.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lazy singletons for the two local models.
|
| 2 |
+
|
| 3 |
+
Checkpoints locked in RESEARCH.md:
|
| 4 |
+
- embeddings: BAAI/bge-small-en-v1.5
|
| 5 |
+
- NLI cross-encoder: cross-encoder/nli-deberta-v3-small
|
| 6 |
+
|
| 7 |
+
Both load on first use (first request pays the load; the API exposes a
|
| 8 |
+
warmup endpoint). If loading fails (no download yet, offline machine),
|
| 9 |
+
callers receive None and the affected metrics report skipped_reason instead
|
| 10 |
+
of fake numbers — degradation is visible, never silent.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import threading
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
|
| 21 |
+
NLI_MODEL = "cross-encoder/nli-deberta-v3-small"
|
| 22 |
+
|
| 23 |
+
_lock = threading.Lock()
|
| 24 |
+
_embedder = None
|
| 25 |
+
_nli = None
|
| 26 |
+
_embedder_failed = False
|
| 27 |
+
_nli_failed = False
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get_embedder():
|
| 31 |
+
global _embedder, _embedder_failed
|
| 32 |
+
if _embedder is not None or _embedder_failed:
|
| 33 |
+
return _embedder
|
| 34 |
+
with _lock:
|
| 35 |
+
if _embedder is None and not _embedder_failed:
|
| 36 |
+
try:
|
| 37 |
+
from sentence_transformers import SentenceTransformer
|
| 38 |
+
_embedder = SentenceTransformer(EMBEDDING_MODEL, device="cpu")
|
| 39 |
+
except Exception:
|
| 40 |
+
logger.exception("embedding model unavailable")
|
| 41 |
+
_embedder_failed = True
|
| 42 |
+
return _embedder
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_nli():
|
| 46 |
+
global _nli, _nli_failed
|
| 47 |
+
if _nli is not None or _nli_failed:
|
| 48 |
+
return _nli
|
| 49 |
+
with _lock:
|
| 50 |
+
if _nli is None and not _nli_failed:
|
| 51 |
+
try:
|
| 52 |
+
from sentence_transformers import CrossEncoder
|
| 53 |
+
_nli = CrossEncoder(NLI_MODEL, device="cpu")
|
| 54 |
+
except Exception:
|
| 55 |
+
logger.exception("NLI model unavailable")
|
| 56 |
+
_nli_failed = True
|
| 57 |
+
return _nli
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def availability() -> dict[str, bool]:
|
| 61 |
+
return {
|
| 62 |
+
"embeddings": get_embedder() is not None,
|
| 63 |
+
"nli": get_nli() is not None,
|
| 64 |
+
}
|
rubriq/schemas.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data contracts shared by every scorer implementation and the API.
|
| 2 |
+
|
| 3 |
+
The adapter rule: LocalScorer (v1) and LLMJudgeScorer (later) both consume
|
| 4 |
+
EvalRequest and produce EvalReport. Nothing downstream may depend on which
|
| 5 |
+
engine ran; honesty flags (is_proxy, requires_judge) live on the schema so
|
| 6 |
+
the UI cannot forget them.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from enum import Enum
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TaskType(str, Enum):
|
| 17 |
+
SUMMARIZATION = "summarization"
|
| 18 |
+
QA_GROUNDED = "qa_grounded" # question answered against source text in the prompt
|
| 19 |
+
QA_OPEN = "qa_open" # question with no source text to check against
|
| 20 |
+
CODE_GENERATION = "code_generation"
|
| 21 |
+
CREATIVE_WRITING = "creative_writing"
|
| 22 |
+
EXTRACTION = "extraction"
|
| 23 |
+
REASONING = "reasoning"
|
| 24 |
+
REWRITING = "rewriting"
|
| 25 |
+
GENERAL = "general"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class FailureMode(str, Enum):
|
| 29 |
+
UNFAITHFUL_CONTENT = "unfaithful_content"
|
| 30 |
+
HALLUCINATION_RISK = "hallucination_risk"
|
| 31 |
+
MISSED_CONSTRAINT = "missed_constraint"
|
| 32 |
+
INCOMPLETE_COVERAGE = "incomplete_coverage"
|
| 33 |
+
OFF_TOPIC_DRIFT = "off_topic_drift"
|
| 34 |
+
POOR_STRUCTURE = "poor_structure"
|
| 35 |
+
WRONG_REGISTER = "wrong_register"
|
| 36 |
+
VERBOSITY = "verbosity"
|
| 37 |
+
WEAK_REASONING = "weak_reasoning"
|
| 38 |
+
FORMAT_VIOLATION = "format_violation"
|
| 39 |
+
LOW_DIVERSITY = "low_diversity"
|
| 40 |
+
INVALID_CODE = "invalid_code"
|
| 41 |
+
INVENTED_FIELDS = "invented_fields"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ModelFamily(str, Enum):
|
| 45 |
+
CLAUDE = "claude"
|
| 46 |
+
GPT = "gpt"
|
| 47 |
+
GEMINI = "gemini"
|
| 48 |
+
OPEN_WEIGHTS = "open_weights" # llama, mistral, qwen, ...
|
| 49 |
+
GENERIC = "generic"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class Evidence(BaseModel):
|
| 53 |
+
"""A quote from the output (or prompt) backing a finding."""
|
| 54 |
+
quote: str
|
| 55 |
+
source: str = "output" # "output" | "prompt"
|
| 56 |
+
note: str = ""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class Finding(BaseModel):
|
| 60 |
+
"""One diagnosed problem, specific enough to drive a repair prompt."""
|
| 61 |
+
failure_mode: FailureMode
|
| 62 |
+
detail: str # human-readable diagnosis naming the problem
|
| 63 |
+
evidence: list[Evidence] = Field(default_factory=list)
|
| 64 |
+
data: dict = Field(default_factory=dict) # machine-readable specifics (e.g. limit=150, actual=212)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ParameterScore(BaseModel):
|
| 68 |
+
parameter: str # machine key, e.g. "faithfulness"
|
| 69 |
+
display_name: str
|
| 70 |
+
score: float | None # 0-100; None when the check could not run
|
| 71 |
+
verdict: str # one-line summary of how the output did
|
| 72 |
+
strengths: list[str] = Field(default_factory=list)
|
| 73 |
+
findings: list[Finding] = Field(default_factory=list)
|
| 74 |
+
is_proxy: bool = False # proxy metric (subjective-quality carve-out)
|
| 75 |
+
requires_judge: bool = False # cannot be judged locally at all; needs LLM-judge mode
|
| 76 |
+
skipped_reason: str = "" # set when score is None
|
| 77 |
+
rubric_source: str = "" # citation key into RESEARCH.md, e.g. "SummaC (Laban et al. 2022)"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TaskClassification(BaseModel):
|
| 81 |
+
task_type: TaskType
|
| 82 |
+
confidence: float # 0-1
|
| 83 |
+
signals: list[str] = Field(default_factory=list) # why, shown in UI
|
| 84 |
+
has_source_text: bool = False
|
| 85 |
+
instruction: str = "" # prompt minus pasted source material
|
| 86 |
+
source_text: str | None = None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class ImprovementPrompt(BaseModel):
|
| 90 |
+
failure_mode: FailureMode
|
| 91 |
+
title: str
|
| 92 |
+
diagnosis: str # names the specific problem found, with evidence
|
| 93 |
+
prompt_text: str # ready to paste
|
| 94 |
+
model_family: ModelFamily
|
| 95 |
+
technique_source: str # citation key into RESEARCH.md
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class OverallScore(BaseModel):
|
| 99 |
+
score: float # weighted 0-100 over scoreable parameters
|
| 100 |
+
note: str # what the number does and does not include
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class EvalOptions(BaseModel):
|
| 104 |
+
"""Advanced options; every field has a sensible default (per brief)."""
|
| 105 |
+
weights: dict[str, float] = Field(default_factory=dict) # parameter key -> weight override
|
| 106 |
+
audience: str = "" # e.g. "children", "expert clinicians"
|
| 107 |
+
tone: str = "" # expected register, free text
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class EvalRequest(BaseModel):
|
| 111 |
+
prompt: str
|
| 112 |
+
output: str
|
| 113 |
+
model_name: str = "" # free text, e.g. "gpt-4o", "claude sonnet"
|
| 114 |
+
options: EvalOptions = Field(default_factory=EvalOptions)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class EvalReport(BaseModel):
|
| 118 |
+
task: TaskClassification
|
| 119 |
+
parameters: list[ParameterScore]
|
| 120 |
+
overall: OverallScore
|
| 121 |
+
improvement_prompts: list[ImprovementPrompt]
|
| 122 |
+
honesty_notes: list[str] = Field(default_factory=list) # carve-out disclosures for this run
|
| 123 |
+
engine: str = "local" # which Scorer produced this
|
rubriq/textutils.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared text primitives: tokenization-free splitting and counting.
|
| 2 |
+
|
| 3 |
+
Kept dependency-free (no nltk/spacy) so the deterministic checks stay
|
| 4 |
+
deterministic and the install stays light.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
_ABBREVIATIONS = {
|
| 12 |
+
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "e.g",
|
| 13 |
+
"i.e", "fig", "no", "vol", "inc", "ltd", "co", "corp", "dept", "est",
|
| 14 |
+
"approx", "u.s", "u.k",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
_SENT_BOUNDARY = re.compile(r"(?<=[.!?])[\"')\]]*\s+(?=[A-Z0-9\"'(\[])")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def split_sentences(text: str) -> list[str]:
|
| 21 |
+
"""Regex sentence splitter with abbreviation guards. Good enough for
|
| 22 |
+
metric aggregation; not a linguistic gold standard and doesn't need to be.
|
| 23 |
+
"""
|
| 24 |
+
text = text.strip()
|
| 25 |
+
if not text:
|
| 26 |
+
return []
|
| 27 |
+
parts: list[str] = []
|
| 28 |
+
for para in re.split(r"\n\s*\n|\n(?=[-*#\d])", text):
|
| 29 |
+
para = para.strip()
|
| 30 |
+
if not para:
|
| 31 |
+
continue
|
| 32 |
+
start = 0
|
| 33 |
+
for m in _SENT_BOUNDARY.finditer(para):
|
| 34 |
+
candidate = para[start:m.start() + 1]
|
| 35 |
+
last_word = re.findall(r"[\w.]+", candidate[-12:].lower())
|
| 36 |
+
if last_word and last_word[-1].rstrip(".") in _ABBREVIATIONS:
|
| 37 |
+
continue
|
| 38 |
+
if candidate.strip():
|
| 39 |
+
parts.append(candidate.strip())
|
| 40 |
+
start = m.end()
|
| 41 |
+
tail = para[start:].strip()
|
| 42 |
+
if tail:
|
| 43 |
+
parts.append(tail)
|
| 44 |
+
return parts
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def split_paragraphs(text: str) -> list[str]:
|
| 48 |
+
return [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def word_count(text: str) -> int:
|
| 52 |
+
return len(re.findall(r"[A-Za-z0-9'’-]+", text))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def words(text: str) -> list[str]:
|
| 56 |
+
return re.findall(r"[A-Za-z'’-]+", text.lower())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def count_syllables(word: str) -> int:
|
| 60 |
+
"""Heuristic syllable count (vowel groups, silent-e adjustment)."""
|
| 61 |
+
w = word.lower().strip(".:;!?,'\"")
|
| 62 |
+
if not w:
|
| 63 |
+
return 0
|
| 64 |
+
groups = re.findall(r"[aeiouy]+", w)
|
| 65 |
+
n = len(groups)
|
| 66 |
+
if w.endswith("e") and n > 1 and not w.endswith(("le", "ee", "ye")):
|
| 67 |
+
n -= 1
|
| 68 |
+
return max(1, n)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def snippet(text: str, limit: int = 160) -> str:
|
| 72 |
+
"""Shorten a quote for evidence display without cutting mid-word."""
|
| 73 |
+
text = " ".join(text.split())
|
| 74 |
+
if len(text) <= limit:
|
| 75 |
+
return text
|
| 76 |
+
cut = text[:limit].rsplit(" ", 1)[0]
|
| 77 |
+
return cut + "…"
|