feat: dynamic audits and community evidence review agent
Browse filesCodex-authored dynamic claim handling, ambiguous date evidence, and approval-gated feedback learning queue.
- LICENSE +21 -0
- README.md +7 -0
- app.py +70 -0
- data/community_feedback_README.md +41 -0
- docs/COMMUNITY_LEARNING.md +23 -0
- frontend/app.js +27 -0
- frontend/index.html +16 -0
- frontend/styles.css +2 -1
- nemotron_space/README.md +28 -0
- nemotron_space/app.py +86 -0
- nemotron_space/requirements.txt +5 -0
- requirements.txt +1 -0
- router_dataset/README.md +29 -0
- src/packetcourt/audit.py +76 -0
- src/packetcourt/investigator.py +17 -4
- src/packetcourt/models.py +1 -0
- src/packetcourt/parser.py +39 -2
- tests/test_audit.py +23 -0
- vision_space/README.md +0 -1
- vision_space/requirements.txt +0 -1
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 N DIVIJ
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -32,6 +32,12 @@ verdicts instead of an unexplained health score.
|
|
| 32 |
|
| 33 |
**Open PacketCourt:** https://build-small-hackathon-packetcourt.hf.space/
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
## Why PacketCourt
|
| 36 |
|
| 37 |
A packet may lead with `HIGH PROTEIN`, `MULTIGRAIN`, or `100% NATURAL` while
|
|
@@ -162,6 +168,7 @@ Current deterministic evaluation result:
|
|
| 162 |
- Public transparent agent traces: https://huggingface.co/datasets/build-small-hackathon/packetcourt-traces
|
| 163 |
- Fine-tuned evidence router: https://huggingface.co/build-small-hackathon/packetcourt-evidence-router
|
| 164 |
- Public router training set: https://huggingface.co/datasets/build-small-hackathon/packetcourt-router-training
|
|
|
|
| 165 |
- Public Field Notes report: https://huggingface.co/datasets/build-small-hackathon/packetcourt-field-notes
|
| 166 |
- Public Codex-attributed GitHub repository: https://github.com/N-45div/PacketCourt
|
| 167 |
|
|
|
|
| 32 |
|
| 33 |
**Open PacketCourt:** https://build-small-hackathon-packetcourt.hf.space/
|
| 34 |
|
| 35 |
+
PacketCourt also includes a correction-driven Community Review Agent. User
|
| 36 |
+
feedback is bundled with the original evidence, investigation path, and
|
| 37 |
+
Nemotron review in a public queue. To prevent feedback poisoning, corrections
|
| 38 |
+
must be evidence-reviewed before they become eligible for the next router
|
| 39 |
+
fine-tune.
|
| 40 |
+
|
| 41 |
## Why PacketCourt
|
| 42 |
|
| 43 |
A packet may lead with `HIGH PROTEIN`, `MULTIGRAIN`, or `100% NATURAL` while
|
|
|
|
| 168 |
- Public transparent agent traces: https://huggingface.co/datasets/build-small-hackathon/packetcourt-traces
|
| 169 |
- Fine-tuned evidence router: https://huggingface.co/build-small-hackathon/packetcourt-evidence-router
|
| 170 |
- Public router training set: https://huggingface.co/datasets/build-small-hackathon/packetcourt-router-training
|
| 171 |
+
- Public community feedback queue: https://huggingface.co/datasets/build-small-hackathon/packetcourt-community-feedback
|
| 172 |
- Public Field Notes report: https://huggingface.co/datasets/build-small-hackathon/packetcourt-field-notes
|
| 173 |
- Public Codex-attributed GitHub repository: https://github.com/N-45div/PacketCourt
|
| 174 |
|
app.py
CHANGED
|
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import sys
|
|
|
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from tempfile import NamedTemporaryFile
|
|
|
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
import uvicorn
|
|
@@ -28,6 +31,12 @@ class AuditRequest(BaseModel):
|
|
| 28 |
back_text: str
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def run_audit(front_text: str, back_text: str):
|
| 32 |
result = audit_packet(front_text, back_text)
|
| 33 |
if not nemotron_is_configured():
|
|
@@ -112,6 +121,67 @@ def audit(request: AuditRequest) -> dict:
|
|
| 112 |
return run_audit(request.front_text, request.back_text).model_dump(mode="json")
|
| 113 |
|
| 114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
@app.post("/api/ocr")
|
| 116 |
async def ocr(front: UploadFile | None = File(default=None), back: UploadFile | None = File(default=None)) -> dict:
|
| 117 |
result: dict[str, dict[str, str]] = {}
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import sys
|
| 5 |
+
import json
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
from pathlib import Path
|
| 8 |
from tempfile import NamedTemporaryFile
|
| 9 |
+
from uuid import uuid4
|
| 10 |
|
| 11 |
import gradio as gr
|
| 12 |
import uvicorn
|
|
|
|
| 31 |
back_text: str
|
| 32 |
|
| 33 |
|
| 34 |
+
class FeedbackRequest(BaseModel):
|
| 35 |
+
verdict: str
|
| 36 |
+
correction: str = ""
|
| 37 |
+
audit: dict
|
| 38 |
+
|
| 39 |
+
|
| 40 |
def run_audit(front_text: str, back_text: str):
|
| 41 |
result = audit_packet(front_text, back_text)
|
| 42 |
if not nemotron_is_configured():
|
|
|
|
| 121 |
return run_audit(request.front_text, request.back_text).model_dump(mode="json")
|
| 122 |
|
| 123 |
|
| 124 |
+
@app.post("/api/feedback")
|
| 125 |
+
def feedback(request: FeedbackRequest) -> dict:
|
| 126 |
+
if request.verdict not in {"accurate", "needs_correction"}:
|
| 127 |
+
return {"status": "REJECTED", "message": "Choose accurate or needs correction."}
|
| 128 |
+
if request.verdict == "needs_correction" and len(request.correction.strip()) < 8:
|
| 129 |
+
return {"status": "REJECTED", "message": "Explain the correction so it can be reviewed."}
|
| 130 |
+
|
| 131 |
+
record_id = str(uuid4())
|
| 132 |
+
record = {
|
| 133 |
+
"id": record_id,
|
| 134 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 135 |
+
"verdict": request.verdict,
|
| 136 |
+
"correction": request.correction.strip()[:1200],
|
| 137 |
+
"front_text": str(request.audit.get("front_text", ""))[:3000],
|
| 138 |
+
"back_text": str(request.audit.get("back_text", ""))[:9000],
|
| 139 |
+
"claims": request.audit.get("claims", []),
|
| 140 |
+
"investigation": request.audit.get("investigation", {}),
|
| 141 |
+
"nemotron_review": request.audit.get("agent_review", {}),
|
| 142 |
+
"proposed_router_examples": [
|
| 143 |
+
{
|
| 144 |
+
"text": claim.get("claim", ""),
|
| 145 |
+
"candidate_tools": [
|
| 146 |
+
step.get("tool", "")
|
| 147 |
+
for step in request.audit.get("investigation", {}).get("steps", [])
|
| 148 |
+
],
|
| 149 |
+
}
|
| 150 |
+
for claim in request.audit.get("claims", [])
|
| 151 |
+
],
|
| 152 |
+
"review_status": "pending_human_review",
|
| 153 |
+
"training_eligible": False,
|
| 154 |
+
"learning_policy": "Only approved corrections enter the next evidence-router fine-tune.",
|
| 155 |
+
}
|
| 156 |
+
dataset_id = os.getenv("PACKETCOURT_FEEDBACK_DATASET")
|
| 157 |
+
if not dataset_id:
|
| 158 |
+
return {
|
| 159 |
+
"status": "UNAVAILABLE",
|
| 160 |
+
"message": "The community learning queue is not configured on this deployment.",
|
| 161 |
+
}
|
| 162 |
+
try:
|
| 163 |
+
from huggingface_hub import HfApi
|
| 164 |
+
|
| 165 |
+
HfApi().upload_file(
|
| 166 |
+
path_or_fileobj=json.dumps(record, indent=2).encode(),
|
| 167 |
+
path_in_repo=f"feedback/{record_id}.json",
|
| 168 |
+
repo_id=dataset_id,
|
| 169 |
+
repo_type="dataset",
|
| 170 |
+
commit_message=f"feedback: queue PacketCourt review {record_id[:8]}",
|
| 171 |
+
)
|
| 172 |
+
except Exception as exc:
|
| 173 |
+
return {
|
| 174 |
+
"status": "UNAVAILABLE",
|
| 175 |
+
"message": f"Feedback could not be persisted: {type(exc).__name__}",
|
| 176 |
+
}
|
| 177 |
+
return {
|
| 178 |
+
"status": "QUEUED",
|
| 179 |
+
"id": record_id,
|
| 180 |
+
"message": "Review queued. It will become training data only after evidence review.",
|
| 181 |
+
"dataset": f"https://huggingface.co/datasets/{dataset_id}",
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
@app.post("/api/ocr")
|
| 186 |
async def ocr(front: UploadFile | None = File(default=None), back: UploadFile | None = File(default=None)) -> dict:
|
| 187 |
result: dict[str, dict[str, str]] = {}
|
data/community_feedback_README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- build-small-hackathon
|
| 5 |
+
- packetcourt
|
| 6 |
+
- feedback
|
| 7 |
+
- traces
|
| 8 |
+
- human-in-the-loop
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# PacketCourt Community Feedback
|
| 12 |
+
|
| 13 |
+
This dataset is PacketCourt's public correction-driven learning queue.
|
| 14 |
+
|
| 15 |
+
Each submitted review preserves:
|
| 16 |
+
|
| 17 |
+
- the original front and back label evidence;
|
| 18 |
+
- PacketCourt's claim verdicts and investigation path;
|
| 19 |
+
- the independent Nemotron evidence-gap review;
|
| 20 |
+
- the user's correction or confirmation;
|
| 21 |
+
- candidate evidence-router examples.
|
| 22 |
+
|
| 23 |
+
## Learning policy
|
| 24 |
+
|
| 25 |
+
New records begin with:
|
| 26 |
+
|
| 27 |
+
```json
|
| 28 |
+
{
|
| 29 |
+
"review_status": "pending_human_review",
|
| 30 |
+
"training_eligible": false
|
| 31 |
+
}
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Public feedback never fine-tunes a production model immediately. That would
|
| 35 |
+
allow accidental or malicious feedback to poison later audits. A correction
|
| 36 |
+
must first be checked against the supplied packet evidence. Approved records
|
| 37 |
+
can then be promoted into a versioned router-training release and evaluated
|
| 38 |
+
against PacketCourt's golden cases before deployment.
|
| 39 |
+
|
| 40 |
+
Nemotron reviews investigations and missing evidence. It is not silently
|
| 41 |
+
self-modified by community feedback.
|
docs/COMMUNITY_LEARNING.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Community Learning Loop
|
| 2 |
+
|
| 3 |
+
```mermaid
|
| 4 |
+
flowchart LR
|
| 5 |
+
A["Packet audit"] --> R["User review"]
|
| 6 |
+
R --> Q["Public feedback queue"]
|
| 7 |
+
Q --> H["Evidence review"]
|
| 8 |
+
H -->|reject| X["Retain as rejected trace"]
|
| 9 |
+
H -->|approve| T["Versioned router training set"]
|
| 10 |
+
T --> F["Fine-tune tiny evidence router"]
|
| 11 |
+
F --> E["Golden-case regression evaluation"]
|
| 12 |
+
E -->|pass| D["Deploy reviewed checkpoint"]
|
| 13 |
+
E -->|fail| X
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
The loop is deliberately approval-gated. User feedback is valuable evidence,
|
| 17 |
+
but it is not automatically true. Every queued correction includes the audit,
|
| 18 |
+
investigation trace, and Nemotron review so a reviewer can decide whether it
|
| 19 |
+
should become training data.
|
| 20 |
+
|
| 21 |
+
PacketCourt's deterministic verdict engine and safety boundaries are never
|
| 22 |
+
rewritten by public feedback. Nemotron remains an independent reviewer rather
|
| 23 |
+
than a model that silently trains on its own outputs.
|
frontend/app.js
CHANGED
|
@@ -7,6 +7,8 @@ const verdictClass = {
|
|
| 7 |
"TECHNICALLY TRUE, CONTEXT MISSING": "context",
|
| 8 |
"CANNOT VERIFY": "unknown",
|
| 9 |
};
|
|
|
|
|
|
|
| 10 |
|
| 11 |
function setMode(mode) {
|
| 12 |
$$(".mode-switch button").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode));
|
|
@@ -46,6 +48,7 @@ function escapeHtml(value = "") {
|
|
| 46 |
}
|
| 47 |
|
| 48 |
function render(data) {
|
|
|
|
| 49 |
$("#claim-count").textContent = data.claims.length;
|
| 50 |
$("#router-model").textContent = data.investigation.router_model;
|
| 51 |
$("#agent-steps").innerHTML = data.investigation.steps.map((step, index) => `
|
|
@@ -103,6 +106,30 @@ function render(data) {
|
|
| 103 |
$("#results").scrollIntoView({ behavior: "smooth" });
|
| 104 |
}
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
$("#audit-text").addEventListener("click", async () => {
|
| 107 |
$("#audit-text").textContent = "Examining evidence...";
|
| 108 |
try { await runAudit($("#front-text").value, $("#back-text").value); }
|
|
|
|
| 7 |
"TECHNICALLY TRUE, CONTEXT MISSING": "context",
|
| 8 |
"CANNOT VERIFY": "unknown",
|
| 9 |
};
|
| 10 |
+
let currentAudit = null;
|
| 11 |
+
let feedbackVerdict = "";
|
| 12 |
|
| 13 |
function setMode(mode) {
|
| 14 |
$$(".mode-switch button").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode));
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
function render(data) {
|
| 51 |
+
currentAudit = data;
|
| 52 |
$("#claim-count").textContent = data.claims.length;
|
| 53 |
$("#router-model").textContent = data.investigation.router_model;
|
| 54 |
$("#agent-steps").innerHTML = data.investigation.steps.map((step, index) => `
|
|
|
|
| 106 |
$("#results").scrollIntoView({ behavior: "smooth" });
|
| 107 |
}
|
| 108 |
|
| 109 |
+
$$("[data-feedback]").forEach((button) => button.addEventListener("click", () => {
|
| 110 |
+
feedbackVerdict = button.dataset.feedback;
|
| 111 |
+
$$("[data-feedback]").forEach((choice) => choice.classList.toggle("active", choice === button));
|
| 112 |
+
}));
|
| 113 |
+
|
| 114 |
+
$("#submit-feedback").addEventListener("click", async () => {
|
| 115 |
+
if (!currentAudit || !feedbackVerdict) {
|
| 116 |
+
$("#feedback-status").textContent = "Run an audit and choose a review outcome first.";
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
$("#feedback-status").textContent = "Packaging evidence review...";
|
| 120 |
+
const response = await fetch("api/feedback", {
|
| 121 |
+
method: "POST",
|
| 122 |
+
headers: { "Content-Type": "application/json" },
|
| 123 |
+
body: JSON.stringify({
|
| 124 |
+
verdict: feedbackVerdict,
|
| 125 |
+
correction: $("#feedback-correction").value,
|
| 126 |
+
audit: currentAudit,
|
| 127 |
+
}),
|
| 128 |
+
});
|
| 129 |
+
const result = await response.json();
|
| 130 |
+
$("#feedback-status").textContent = result.message;
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
$("#audit-text").addEventListener("click", async () => {
|
| 134 |
$("#audit-text").textContent = "Examining evidence...";
|
| 135 |
try { await runAudit($("#front-text").value, $("#back-text").value); }
|
frontend/index.html
CHANGED
|
@@ -116,6 +116,22 @@
|
|
| 116 |
<article class="date-card"><p class="kicker">DATE EVIDENCE</p><h3 id="expiry-status"></h3><p id="opening-status"></p><p>Expiry interpretation is evidence, not a food-safety guarantee.</p></article>
|
| 117 |
</div>
|
| 118 |
<details><summary>View machine-readable evidence case</summary><pre id="raw-json"></pre></details>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
</section>
|
| 120 |
|
| 121 |
<section class="method">
|
|
|
|
| 116 |
<article class="date-card"><p class="kicker">DATE EVIDENCE</p><h3 id="expiry-status"></h3><p id="opening-status"></p><p>Expiry interpretation is evidence, not a food-safety guarantee.</p></article>
|
| 117 |
</div>
|
| 118 |
<details><summary>View machine-readable evidence case</summary><pre id="raw-json"></pre></details>
|
| 119 |
+
<section class="feedback-agent">
|
| 120 |
+
<div>
|
| 121 |
+
<p class="kicker">COMMUNITY REVIEW AGENT</p>
|
| 122 |
+
<h3>Help the next audit get sharper.</h3>
|
| 123 |
+
<p>Corrections enter a public review queue with this audit's evidence, router path, and Nemotron review. They never retrain production models without approval.</p>
|
| 124 |
+
</div>
|
| 125 |
+
<div class="feedback-controls">
|
| 126 |
+
<div class="feedback-choice">
|
| 127 |
+
<button class="button quiet" data-feedback="accurate">This audit is accurate</button>
|
| 128 |
+
<button class="button quiet" data-feedback="needs_correction">This needs correction</button>
|
| 129 |
+
</div>
|
| 130 |
+
<textarea id="feedback-correction" placeholder="What did PacketCourt miss or misread? Cite the packet evidence."></textarea>
|
| 131 |
+
<button class="button dark" id="submit-feedback">Queue evidence review <span>→</span></button>
|
| 132 |
+
<span id="feedback-status"></span>
|
| 133 |
+
</div>
|
| 134 |
+
</section>
|
| 135 |
</section>
|
| 136 |
|
| 137 |
<section class="method">
|
frontend/styles.css
CHANGED
|
@@ -20,6 +20,7 @@ main{max-width:1320px;margin:auto;padding:0 4vw}.hero{min-height:670px;display:g
|
|
| 20 |
.claim-grid{grid-template-columns:repeat(2,1fr)}.claim-card{background:var(--cream);border:1px solid var(--line);border-top:6px solid var(--muted);border-radius:16px;padding:23px}.claim-card.supported{border-top-color:var(--green)}.claim-card.contradicted{border-top-color:var(--red)}.claim-card.context{border-top-color:var(--amber)}.claim-top{display:flex;justify-content:space-between;gap:10px;align-items:start}.claim-name{font-size:21px;font-weight:800}.verdict{font:500 8px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;border:1px solid var(--line);border-radius:99px;padding:7px 9px;text-align:right}.confidence{display:block;margin-top:8px;font:500 8px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}.summary{min-height:45px;color:#534d43;line-height:1.5}.evidence{padding:11px 0;border-top:1px solid var(--line)}.evidence b,.evidence span{display:block}.evidence b{font:500 8px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}.evidence span{font-size:13px;margin-top:4px}.caveat{font-size:11px;color:var(--muted);margin-top:15px}
|
| 21 |
.evidence-summary{margin-top:16px}.evidence-summary article{padding:25px;border:1px solid var(--line);border-radius:16px;background:var(--cream)}#nutrition-grid div{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--line);font-size:13px}.date-card{background:var(--ink)!important;color:var(--cream)}.date-card .kicker{color:#cfc5b6}.date-card h3{font:700 28px/1.15 "Playfair Display"}details{margin-top:16px;border:1px solid var(--line);border-radius:14px;padding:17px;background:var(--cream)}summary{cursor:pointer;font-weight:700}pre{white-space:pre-wrap;font:11px/1.5 "DM Mono";overflow:auto}
|
| 22 |
.method{border-top:1px solid var(--line)}.method-grid{grid-template-columns:repeat(4,1fr);margin-top:40px}.method-grid div{padding:20px;border-top:2px solid var(--ink)}.method-grid span{font:500 10px "DM Mono";color:var(--red)}.method-grid p{font-size:13px;line-height:1.5;color:var(--muted)}
|
|
|
|
| 23 |
footer{display:flex;justify-content:space-between;gap:20px;padding:25px 5vw;border-top:1px solid var(--line);font:500 10px "DM Mono";color:var(--muted)}
|
| 24 |
-
@media(max-width:900px){.hero{grid-template-columns:1fr;min-height:auto;padding:80px 0}.hero-visual{height:430px}.trust-strip{grid-template-columns:1fr}.trust-strip div{border-right:0}.section-heading,.case-header,.agent-heading{align-items:start;flex-direction:column}.upload-grid,.text-grid,.claim-grid,.evidence-summary,.gap-grid,.agent-steps,.agent-stop{grid-template-columns:1fr}.method-grid{grid-template-columns:repeat(2,1fr)}}
|
| 25 |
@media(max-width:560px){.top-status,.engine-link{display:none}.hero h1{font-size:58px}.hero-visual{transform:scale(.8);transform-origin:left top;height:350px;width:125%}.workspace,.results,.method{padding:65px 0}.mode-switch{overflow:auto}.mode-switch button{white-space:nowrap;padding:13px 10px}.sample-grid,.method-grid{grid-template-columns:1fr}.case-score{width:90px;height:90px}.claim-top{display:block}.verdict{display:inline-block;margin-top:8px}footer{display:block}footer span{display:block;margin:5px 0}}
|
|
|
|
| 20 |
.claim-grid{grid-template-columns:repeat(2,1fr)}.claim-card{background:var(--cream);border:1px solid var(--line);border-top:6px solid var(--muted);border-radius:16px;padding:23px}.claim-card.supported{border-top-color:var(--green)}.claim-card.contradicted{border-top-color:var(--red)}.claim-card.context{border-top-color:var(--amber)}.claim-top{display:flex;justify-content:space-between;gap:10px;align-items:start}.claim-name{font-size:21px;font-weight:800}.verdict{font:500 8px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.08em;border:1px solid var(--line);border-radius:99px;padding:7px 9px;text-align:right}.confidence{display:block;margin-top:8px;font:500 8px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}.summary{min-height:45px;color:#534d43;line-height:1.5}.evidence{padding:11px 0;border-top:1px solid var(--line)}.evidence b,.evidence span{display:block}.evidence b{font:500 8px ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em;color:var(--muted);text-transform:uppercase}.evidence span{font-size:13px;margin-top:4px}.caveat{font-size:11px;color:var(--muted);margin-top:15px}
|
| 21 |
.evidence-summary{margin-top:16px}.evidence-summary article{padding:25px;border:1px solid var(--line);border-radius:16px;background:var(--cream)}#nutrition-grid div{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--line);font-size:13px}.date-card{background:var(--ink)!important;color:var(--cream)}.date-card .kicker{color:#cfc5b6}.date-card h3{font:700 28px/1.15 "Playfair Display"}details{margin-top:16px;border:1px solid var(--line);border-radius:14px;padding:17px;background:var(--cream)}summary{cursor:pointer;font-weight:700}pre{white-space:pre-wrap;font:11px/1.5 "DM Mono";overflow:auto}
|
| 22 |
.method{border-top:1px solid var(--line)}.method-grid{grid-template-columns:repeat(4,1fr);margin-top:40px}.method-grid div{padding:20px;border-top:2px solid var(--ink)}.method-grid span{font:500 10px "DM Mono";color:var(--red)}.method-grid p{font-size:13px;line-height:1.5;color:var(--muted)}
|
| 23 |
+
.feedback-agent{display:grid;grid-template-columns:.8fr 1.2fr;gap:28px;margin-top:18px;padding:26px;border:1px solid var(--line);border-radius:18px;background:var(--cream)}.feedback-agent h3{font:700 clamp(25px,4vw,42px)/1 Georgia,serif;margin:0}.feedback-agent p{font-size:12px;line-height:1.55;color:var(--muted)}.feedback-controls{display:grid;gap:10px}.feedback-choice{display:flex;gap:8px;flex-wrap:wrap}.feedback-choice button.active{background:var(--red);border-color:var(--red);color:white}.feedback-controls textarea{min-height:95px;padding:13px;border:1px solid var(--line);border-radius:11px;background:#f8f3e9;resize:vertical}.feedback-controls>span{font:500 9px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--green)}
|
| 24 |
footer{display:flex;justify-content:space-between;gap:20px;padding:25px 5vw;border-top:1px solid var(--line);font:500 10px "DM Mono";color:var(--muted)}
|
| 25 |
+
@media(max-width:900px){.hero{grid-template-columns:1fr;min-height:auto;padding:80px 0}.hero-visual{height:430px}.trust-strip{grid-template-columns:1fr}.trust-strip div{border-right:0}.section-heading,.case-header,.agent-heading{align-items:start;flex-direction:column}.upload-grid,.text-grid,.claim-grid,.evidence-summary,.gap-grid,.agent-steps,.agent-stop,.feedback-agent{grid-template-columns:1fr}.method-grid{grid-template-columns:repeat(2,1fr)}}
|
| 26 |
@media(max-width:560px){.top-status,.engine-link{display:none}.hero h1{font-size:58px}.hero-visual{transform:scale(.8);transform-origin:left top;height:350px;width:125%}.workspace,.results,.method{padding:65px 0}.mode-switch{overflow:auto}.mode-switch button{white-space:nowrap;padding:13px 10px}.sample-grid,.method-grid{grid-template-columns:1fr}.case-score{width:90px;height:90px}.claim-top{display:block}.verdict{display:inline-block;margin-top:8px}footer{display:block}footer span{display:block;margin:5px 0}}
|
nemotron_space/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: PacketCourt Nemotron Reviewer
|
| 3 |
+
emoji: 🟩
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: other
|
| 11 |
+
tags:
|
| 12 |
+
- build-small-hackathon
|
| 13 |
+
- sponsor:nvidia
|
| 14 |
+
- nemotron
|
| 15 |
+
- agent
|
| 16 |
+
- zerogpu
|
| 17 |
+
models:
|
| 18 |
+
- nvidia/Nemotron-Mini-4B-Instruct
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
# PacketCourt Nemotron Reviewer
|
| 22 |
+
|
| 23 |
+
Private ZeroGPU companion that uses NVIDIA Nemotron Mini 4B as an independent
|
| 24 |
+
evidence-gap reviewer for PacketCourt investigations.
|
| 25 |
+
|
| 26 |
+
Nemotron may request missing packet evidence or confirm that the bounded
|
| 27 |
+
investigation plan is complete. It never issues or overrides PacketCourt's
|
| 28 |
+
deterministic verdicts.
|
nemotron_space/app.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import traceback
|
| 5 |
+
from functools import lru_cache
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
import spaces
|
| 9 |
+
import torch
|
| 10 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 11 |
+
|
| 12 |
+
MODEL_ID = "nvidia/Nemotron-Mini-4B-Instruct"
|
| 13 |
+
|
| 14 |
+
SYSTEM = """You are PacketCourt's evidence-gap reviewer.
|
| 15 |
+
Review the supplied packet investigation plan. Do not judge whether food is
|
| 16 |
+
healthy, safe, legal, or fraudulent. Do not change claim verdicts. Return only
|
| 17 |
+
compact JSON with these keys:
|
| 18 |
+
- status: COMPLETE or NEEDS_EVIDENCE
|
| 19 |
+
- priority: one short sentence naming the most important next action
|
| 20 |
+
- evidence_request: one short sentence, or an empty string
|
| 21 |
+
- rationale: one short sentence grounded only in the supplied investigation
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@lru_cache(maxsize=1)
|
| 26 |
+
def load_model():
|
| 27 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
|
| 28 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 29 |
+
MODEL_ID,
|
| 30 |
+
trust_remote_code=True,
|
| 31 |
+
torch_dtype=torch.bfloat16,
|
| 32 |
+
device_map="auto",
|
| 33 |
+
)
|
| 34 |
+
model.eval()
|
| 35 |
+
return tokenizer, model
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@spaces.GPU(duration=180)
|
| 39 |
+
def review_investigation(snapshot: str) -> str:
|
| 40 |
+
try:
|
| 41 |
+
tokenizer, model = load_model()
|
| 42 |
+
messages = [
|
| 43 |
+
{"role": "system", "content": SYSTEM},
|
| 44 |
+
{"role": "user", "content": snapshot[:12000]},
|
| 45 |
+
]
|
| 46 |
+
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 47 |
+
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
|
| 48 |
+
generated = model.generate(**inputs, max_new_tokens=180, do_sample=False)
|
| 49 |
+
text = tokenizer.decode(generated[0][inputs.input_ids.shape[1] :], skip_special_tokens=True).strip()
|
| 50 |
+
start, end = text.find("{"), text.rfind("}")
|
| 51 |
+
if start == -1 or end == -1:
|
| 52 |
+
raise ValueError("Nemotron did not return JSON")
|
| 53 |
+
payload = json.loads(text[start : end + 1])
|
| 54 |
+
return json.dumps(
|
| 55 |
+
{
|
| 56 |
+
"status": "NEEDS_EVIDENCE" if payload.get("status") == "NEEDS_EVIDENCE" else "COMPLETE",
|
| 57 |
+
"priority": str(payload.get("priority", ""))[:240],
|
| 58 |
+
"evidence_request": str(payload.get("evidence_request", ""))[:240],
|
| 59 |
+
"rationale": str(payload.get("rationale", ""))[:320],
|
| 60 |
+
"model": MODEL_ID,
|
| 61 |
+
}
|
| 62 |
+
)
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
return json.dumps(
|
| 65 |
+
{
|
| 66 |
+
"status": "UNAVAILABLE",
|
| 67 |
+
"priority": "",
|
| 68 |
+
"evidence_request": "",
|
| 69 |
+
"rationale": f"{type(exc).__name__}: {exc}",
|
| 70 |
+
"model": MODEL_ID,
|
| 71 |
+
"diagnostic": traceback.format_exc(limit=3),
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
demo = gr.Interface(
|
| 77 |
+
fn=review_investigation,
|
| 78 |
+
inputs=gr.Textbox(label="Structured PacketCourt investigation snapshot", lines=14),
|
| 79 |
+
outputs=gr.Textbox(label="Nemotron evidence-gap review"),
|
| 80 |
+
title="PacketCourt Nemotron Reviewer",
|
| 81 |
+
description="NVIDIA Nemotron Mini 4B reviews bounded PacketCourt investigations for missing evidence.",
|
| 82 |
+
flagging_mode="never",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
demo.launch()
|
nemotron_space/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==5.49.1
|
| 2 |
+
spaces>=0.42.0
|
| 3 |
+
torch>=2.6.0
|
| 4 |
+
transformers>=4.53.0
|
| 5 |
+
accelerate>=1.8.0
|
requirements.txt
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
gradio==5.49.1
|
| 2 |
gradio_client==1.13.3
|
|
|
|
| 3 |
pillow>=11.0.0
|
| 4 |
pydantic>=2.10.0
|
| 5 |
pytesseract>=0.3.13
|
|
|
|
| 1 |
gradio==5.49.1
|
| 2 |
gradio_client==1.13.3
|
| 3 |
+
huggingface_hub>=0.33.0
|
| 4 |
pillow>=11.0.0
|
| 5 |
pydantic>=2.10.0
|
| 6 |
pytesseract>=0.3.13
|
router_dataset/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: cc-by-4.0
|
| 3 |
+
task_categories:
|
| 4 |
+
- text-classification
|
| 5 |
+
language:
|
| 6 |
+
- en
|
| 7 |
+
tags:
|
| 8 |
+
- build-small-hackathon
|
| 9 |
+
- packetcourt
|
| 10 |
+
- claim-routing
|
| 11 |
+
size_categories:
|
| 12 |
+
- n<1K
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# PacketCourt Evidence Router Training Set
|
| 16 |
+
|
| 17 |
+
Small, inspectable claim-routing dataset used to fine-tune
|
| 18 |
+
[`packetcourt-evidence-router`](https://huggingface.co/build-small-hackathon/packetcourt-evidence-router).
|
| 19 |
+
|
| 20 |
+
The five labels map packet text to the next bounded investigation tool:
|
| 21 |
+
|
| 22 |
+
- `ingredients`
|
| 23 |
+
- `nutrition`
|
| 24 |
+
- `license`
|
| 25 |
+
- `dates`
|
| 26 |
+
- `refuse_absolute`
|
| 27 |
+
|
| 28 |
+
The router only proposes a tool. PacketCourt's deterministic evidence engine
|
| 29 |
+
remains responsible for final verdicts and calculations.
|
src/packetcourt/audit.py
CHANGED
|
@@ -23,6 +23,17 @@ ADDED_SUGAR_TERMS = {
|
|
| 23 |
"sucrose",
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
def _ingredient_evidence(ingredients: list[str], matches: list[str]) -> list[Evidence]:
|
| 28 |
return [Evidence(source="ingredient list", text=item) for item in matches]
|
|
@@ -31,6 +42,31 @@ def _ingredient_evidence(ingredients: list[str], matches: list[str]) -> list[Evi
|
|
| 31 |
def _audit_claim(claim: str, back_text: str, ingredients: list[str], nutrition) -> ClaimAudit:
|
| 32 |
lowered_ingredients = [item.lower() for item in ingredients]
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
if claim == "No Added Sugar":
|
| 35 |
matches = [
|
| 36 |
original
|
|
@@ -185,6 +221,24 @@ def _audit_claim(claim: str, back_text: str, ingredients: list[str], nutrition)
|
|
| 185 |
confidence="high",
|
| 186 |
)
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
return ClaimAudit(
|
| 189 |
claim=claim,
|
| 190 |
verdict=Verdict.CANNOT_VERIFY,
|
|
@@ -240,6 +294,28 @@ def _persuasion_gap(claims: list[ClaimAudit], ingredients: list[str], whole_pack
|
|
| 240 |
evidence=[Evidence(source="claim interpretation", text="FSSAI registration is not a health score.")],
|
| 241 |
)
|
| 242 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
return findings
|
| 244 |
|
| 245 |
|
|
|
|
| 23 |
"sucrose",
|
| 24 |
}
|
| 25 |
|
| 26 |
+
SWEETENER_TERMS = {
|
| 27 |
+
"sucralose",
|
| 28 |
+
"aspartame",
|
| 29 |
+
"acesulfame",
|
| 30 |
+
"saccharin",
|
| 31 |
+
"stevia",
|
| 32 |
+
"maltitol",
|
| 33 |
+
"sorbitol",
|
| 34 |
+
"erythritol",
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
|
| 38 |
def _ingredient_evidence(ingredients: list[str], matches: list[str]) -> list[Evidence]:
|
| 39 |
return [Evidence(source="ingredient list", text=item) for item in matches]
|
|
|
|
| 42 |
def _audit_claim(claim: str, back_text: str, ingredients: list[str], nutrition) -> ClaimAudit:
|
| 43 |
lowered_ingredients = [item.lower() for item in ingredients]
|
| 44 |
|
| 45 |
+
if claim == "Sugar Free":
|
| 46 |
+
sweeteners = [
|
| 47 |
+
original
|
| 48 |
+
for original, lowered in zip(ingredients, lowered_ingredients)
|
| 49 |
+
if any(re.search(rf"\b{re.escape(term)}\b", lowered) for term in SWEETENER_TERMS)
|
| 50 |
+
]
|
| 51 |
+
if nutrition.total_sugar_g is not None:
|
| 52 |
+
return ClaimAudit(
|
| 53 |
+
claim=claim,
|
| 54 |
+
verdict=Verdict.CONTEXT_MISSING,
|
| 55 |
+
summary="A sugar quantity is visible, but sugar-free claim compliance depends on the declared basis and applicable product rules.",
|
| 56 |
+
evidence=[Evidence(source="nutrition panel", text=f"Total sugar {nutrition.total_sugar_g:g}g ({nutrition.basis})")]
|
| 57 |
+
+ _ingredient_evidence(ingredients, sweeteners),
|
| 58 |
+
caveat="Sweeteners are surfaced as context; their presence does not itself contradict a sugar-free claim.",
|
| 59 |
+
confidence="medium",
|
| 60 |
+
)
|
| 61 |
+
return ClaimAudit(
|
| 62 |
+
claim=claim,
|
| 63 |
+
verdict=Verdict.CANNOT_VERIFY,
|
| 64 |
+
summary="The packet claims sugar free, but no readable total-sugar quantity and measurement basis were supplied.",
|
| 65 |
+
evidence=[Evidence(source="front claim", text=claim)] + _ingredient_evidence(ingredients, sweeteners),
|
| 66 |
+
caveat="A readable nutrition panel is required. Sweeteners are relevant context but are not sugar.",
|
| 67 |
+
confidence="low",
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
if claim == "No Added Sugar":
|
| 71 |
matches = [
|
| 72 |
original
|
|
|
|
| 221 |
confidence="high",
|
| 222 |
)
|
| 223 |
|
| 224 |
+
meaningful_terms = [
|
| 225 |
+
token.lower()
|
| 226 |
+
for token in re.findall(r"[A-Za-z]{3,}", claim)
|
| 227 |
+
if token.lower() not in {"with", "extra", "real", "enriched", "fortified", "source", "contains"}
|
| 228 |
+
]
|
| 229 |
+
evidence_matches = [
|
| 230 |
+
item for item in ingredients if any(term in item.lower() for term in meaningful_terms)
|
| 231 |
+
]
|
| 232 |
+
if evidence_matches:
|
| 233 |
+
return ClaimAudit(
|
| 234 |
+
claim=claim,
|
| 235 |
+
verdict=Verdict.CONTEXT_MISSING,
|
| 236 |
+
summary="Related ingredient evidence is visible, but the packet does not provide enough quantified evidence to verify the full front claim.",
|
| 237 |
+
evidence=_ingredient_evidence(ingredients, evidence_matches),
|
| 238 |
+
caveat="PacketCourt will not infer quantity, quality, or nutritional significance from an ingredient name alone.",
|
| 239 |
+
confidence="medium",
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
return ClaimAudit(
|
| 243 |
claim=claim,
|
| 244 |
verdict=Verdict.CANNOT_VERIFY,
|
|
|
|
| 294 |
evidence=[Evidence(source="claim interpretation", text="FSSAI registration is not a health score.")],
|
| 295 |
)
|
| 296 |
)
|
| 297 |
+
if "Sugar Free" in claim_names:
|
| 298 |
+
sweeteners = [
|
| 299 |
+
item
|
| 300 |
+
for item in ingredients
|
| 301 |
+
if any(re.search(rf"\b{re.escape(term)}\b", item, re.IGNORECASE) for term in SWEETENER_TERMS)
|
| 302 |
+
]
|
| 303 |
+
maltodextrin = next((item for item in ingredients if "maltodextrin" in item.lower()), None)
|
| 304 |
+
if sweeteners or maltodextrin:
|
| 305 |
+
context = []
|
| 306 |
+
if maltodextrin:
|
| 307 |
+
context.append(f"the ingredient list begins with or includes “{maltodextrin}”")
|
| 308 |
+
if sweeteners:
|
| 309 |
+
context.append(f"sweetener evidence includes “{', '.join(sweeteners)}”")
|
| 310 |
+
findings.append(
|
| 311 |
+
PersuasionFinding(
|
| 312 |
+
headline="Sugar free does not mean ingredient-context free.",
|
| 313 |
+
front_impression="The front emphasizes the absence of sugar.",
|
| 314 |
+
quiet_context="; ".join(context).capitalize() + ".",
|
| 315 |
+
severity="medium",
|
| 316 |
+
evidence=_ingredient_evidence(ingredients, ([maltodextrin] if maltodextrin else []) + sweeteners),
|
| 317 |
+
)
|
| 318 |
+
)
|
| 319 |
return findings
|
| 320 |
|
| 321 |
|
src/packetcourt/investigator.py
CHANGED
|
@@ -5,6 +5,7 @@ from .models import InvestigationPlan, InvestigationStep
|
|
| 5 |
|
| 6 |
|
| 7 |
POLICY_TOOLS = {
|
|
|
|
| 8 |
"No Added Sugar": "inspect_ingredients",
|
| 9 |
"Multigrain": "inspect_ingredients",
|
| 10 |
"100% Natural": "apply_safety_boundary",
|
|
@@ -16,6 +17,16 @@ POLICY_TOOLS = {
|
|
| 16 |
"High Protein": "inspect_nutrition",
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
def build_investigation(
|
| 21 |
claim_names: list[str],
|
|
@@ -31,7 +42,7 @@ def build_investigation(
|
|
| 31 |
for claim in claim_names:
|
| 32 |
routed_tool, source = route_claim(claim)
|
| 33 |
router_model = source if source != "deterministic fallback" else router_model
|
| 34 |
-
tool = routed_tool or
|
| 35 |
if tool in seen:
|
| 36 |
continue
|
| 37 |
seen.add(tool)
|
|
@@ -44,14 +55,14 @@ def build_investigation(
|
|
| 44 |
)
|
| 45 |
)
|
| 46 |
|
| 47 |
-
if claim_names and not ingredients and any(
|
| 48 |
missing.append("A readable ingredient list")
|
| 49 |
-
if claim_names and nutrition.basis == "unknown" and any(
|
| 50 |
missing.append("A readable nutrition panel with its measurement basis")
|
| 51 |
if expiry.instruction and not expiry.packed_on:
|
| 52 |
missing.append("The packing or manufacturing date needed to resolve relative shelf life")
|
| 53 |
|
| 54 |
-
if expiry.best_before or expiry.instruction or expiry.after_opening_instruction:
|
| 55 |
steps.append(
|
| 56 |
InvestigationStep(
|
| 57 |
tool="resolve_dates",
|
|
@@ -59,6 +70,8 @@ def build_investigation(
|
|
| 59 |
status="completed" if expiry.best_before or expiry.after_opening_instruction else "needs evidence",
|
| 60 |
)
|
| 61 |
)
|
|
|
|
|
|
|
| 62 |
|
| 63 |
stop_reason = (
|
| 64 |
"Stopped with explicit missing-evidence requests."
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
POLICY_TOOLS = {
|
| 8 |
+
"Sugar Free": "inspect_nutrition",
|
| 9 |
"No Added Sugar": "inspect_ingredients",
|
| 10 |
"Multigrain": "inspect_ingredients",
|
| 11 |
"100% Natural": "apply_safety_boundary",
|
|
|
|
| 17 |
"High Protein": "inspect_nutrition",
|
| 18 |
}
|
| 19 |
|
| 20 |
+
def policy_tool_for(claim: str) -> str:
|
| 21 |
+
if claim in POLICY_TOOLS:
|
| 22 |
+
return POLICY_TOOLS[claim]
|
| 23 |
+
lowered = claim.lower()
|
| 24 |
+
if any(term in lowered for term in ("calcium", "dha", "protein", "sugar", "fat", "sodium")):
|
| 25 |
+
return "inspect_nutrition"
|
| 26 |
+
if any(term in lowered for term in ("real", "with", "contains", "ingredient", "grain", "natural")):
|
| 27 |
+
return "inspect_ingredients"
|
| 28 |
+
return "inspect_label_evidence"
|
| 29 |
+
|
| 30 |
|
| 31 |
def build_investigation(
|
| 32 |
claim_names: list[str],
|
|
|
|
| 42 |
for claim in claim_names:
|
| 43 |
routed_tool, source = route_claim(claim)
|
| 44 |
router_model = source if source != "deterministic fallback" else router_model
|
| 45 |
+
tool = routed_tool or policy_tool_for(claim)
|
| 46 |
if tool in seen:
|
| 47 |
continue
|
| 48 |
seen.add(tool)
|
|
|
|
| 55 |
)
|
| 56 |
)
|
| 57 |
|
| 58 |
+
if claim_names and not ingredients and any(policy_tool_for(name) == "inspect_ingredients" for name in claim_names):
|
| 59 |
missing.append("A readable ingredient list")
|
| 60 |
+
if claim_names and nutrition.basis == "unknown" and any(policy_tool_for(name) == "inspect_nutrition" for name in claim_names):
|
| 61 |
missing.append("A readable nutrition panel with its measurement basis")
|
| 62 |
if expiry.instruction and not expiry.packed_on:
|
| 63 |
missing.append("The packing or manufacturing date needed to resolve relative shelf life")
|
| 64 |
|
| 65 |
+
if expiry.best_before or expiry.instruction or expiry.after_opening_instruction or expiry.visible_date_texts:
|
| 66 |
steps.append(
|
| 67 |
InvestigationStep(
|
| 68 |
tool="resolve_dates",
|
|
|
|
| 70 |
status="completed" if expiry.best_before or expiry.after_opening_instruction else "needs evidence",
|
| 71 |
)
|
| 72 |
)
|
| 73 |
+
if expiry.visible_date_texts and not expiry.best_before:
|
| 74 |
+
missing.append("Labels identifying the visible dates as packed, manufactured, best-before, or expiry")
|
| 75 |
|
| 76 |
stop_reason = (
|
| 77 |
"Stopped with explicit missing-evidence requests."
|
src/packetcourt/models.py
CHANGED
|
@@ -62,6 +62,7 @@ class ExpiryInfo(BaseModel):
|
|
| 62 |
best_before: str | None = None
|
| 63 |
instruction: str | None = None
|
| 64 |
after_opening_instruction: str | None = None
|
|
|
|
| 65 |
status: str = "Not enough label evidence"
|
| 66 |
|
| 67 |
|
|
|
|
| 62 |
best_before: str | None = None
|
| 63 |
instruction: str | None = None
|
| 64 |
after_opening_instruction: str | None = None
|
| 65 |
+
visible_date_texts: list[str] = Field(default_factory=list)
|
| 66 |
status: str = "Not enough label evidence"
|
| 67 |
|
| 68 |
|
src/packetcourt/parser.py
CHANGED
|
@@ -9,6 +9,7 @@ from .models import ExpiryInfo, NutritionFacts, WholePacketNutrition
|
|
| 9 |
|
| 10 |
CLAIM_PATTERNS: list[tuple[str, str]] = [
|
| 11 |
("High Protein", r"\bhigh[\s-]*protein\b"),
|
|
|
|
| 12 |
("No Added Sugar", r"\bno[\s-]*added[\s-]*sugar\b"),
|
| 13 |
("Multigrain", r"\bmulti[\s-]*grain\b"),
|
| 14 |
("100% Natural", r"\b100\s*%\s*natural\b"),
|
|
@@ -19,6 +20,12 @@ CLAIM_PATTERNS: list[tuple[str, str]] = [
|
|
| 19 |
("Whole Grain", r"\b(?:made\s+with\s+)?whole[\s-]*grains?\b"),
|
| 20 |
]
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def normalize_space(text: str) -> str:
|
| 24 |
return re.sub(r"\s+", " ", text or "").strip()
|
|
@@ -26,7 +33,19 @@ def normalize_space(text: str) -> str:
|
|
| 26 |
|
| 27 |
def extract_claims(front_text: str) -> list[str]:
|
| 28 |
text = normalize_space(front_text).lower()
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def _number_after(label: str, text: str, unit: str) -> float | None:
|
|
@@ -89,7 +108,8 @@ def calculate_whole_packet(nutrition: NutritionFacts) -> WholePacketNutrition:
|
|
| 89 |
|
| 90 |
def extract_ingredients(back_text: str) -> list[str]:
|
| 91 |
match = re.search(
|
| 92 |
-
r"\bingredients?\s*:\s*(.+?)(?=\b(?:nutrition|allergen|contains|
|
|
|
|
| 93 |
normalize_space(back_text),
|
| 94 |
re.IGNORECASE,
|
| 95 |
)
|
|
@@ -135,6 +155,17 @@ def _add_months(value: date, months: int) -> date:
|
|
| 135 |
|
| 136 |
def parse_expiry(back_text: str) -> ExpiryInfo:
|
| 137 |
text = normalize_space(back_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
packed_match = re.search(
|
| 139 |
r"\b(?:pkd|packed(?:\s+on)?|mfd|manufactured(?:\s+on)?)\s*[:\-]?\s*"
|
| 140 |
r"(\d{1,2}[\s/\-.]+(?:\d{1,2}|[A-Za-z]{3,9})[\s/\-.]+\d{2,4})",
|
|
@@ -164,6 +195,11 @@ def parse_expiry(back_text: str) -> ExpiryInfo:
|
|
| 164 |
status = f"Best-before evidence resolves to {best_before.isoformat()}"
|
| 165 |
elif instruction and not packed:
|
| 166 |
status = "Relative shelf-life found, but the starting date is missing"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
else:
|
| 168 |
status = "No resolvable best-before date found"
|
| 169 |
|
|
@@ -177,5 +213,6 @@ def parse_expiry(back_text: str) -> ExpiryInfo:
|
|
| 177 |
best_before=best_before.isoformat() if best_before else None,
|
| 178 |
instruction=instruction,
|
| 179 |
after_opening_instruction=after_opening_match.group(0) if after_opening_match else None,
|
|
|
|
| 180 |
status=status,
|
| 181 |
)
|
|
|
|
| 9 |
|
| 10 |
CLAIM_PATTERNS: list[tuple[str, str]] = [
|
| 11 |
("High Protein", r"\bhigh[\s-]*protein\b"),
|
| 12 |
+
("Sugar Free", r"\bsugar[\s-]*free\b"),
|
| 13 |
("No Added Sugar", r"\bno[\s-]*added[\s-]*sugar\b"),
|
| 14 |
("Multigrain", r"\bmulti[\s-]*grain\b"),
|
| 15 |
("100% Natural", r"\b100\s*%\s*natural\b"),
|
|
|
|
| 20 |
("Whole Grain", r"\b(?:made\s+with\s+)?whole[\s-]*grains?\b"),
|
| 21 |
]
|
| 22 |
|
| 23 |
+
GENERIC_CLAIM_TERMS = re.compile(
|
| 24 |
+
r"\b(?:free|extra|real|enriched|fortified|rich|source|natural|healthy|safe|pure|with)\b",
|
| 25 |
+
re.IGNORECASE,
|
| 26 |
+
)
|
| 27 |
+
NON_CLAIM_LINES = re.compile(r"\b(?:nutraceutical|brand|flavour|flavor)\b", re.IGNORECASE)
|
| 28 |
+
|
| 29 |
|
| 30 |
def normalize_space(text: str) -> str:
|
| 31 |
return re.sub(r"\s+", " ", text or "").strip()
|
|
|
|
| 33 |
|
| 34 |
def extract_claims(front_text: str) -> list[str]:
|
| 35 |
text = normalize_space(front_text).lower()
|
| 36 |
+
claims = [name for name, pattern in CLAIM_PATTERNS if re.search(pattern, text)]
|
| 37 |
+
matched_patterns = [pattern for _, pattern in CLAIM_PATTERNS]
|
| 38 |
+
for raw_line in (front_text or "").splitlines():
|
| 39 |
+
line = normalize_space(raw_line).strip(" |•*-")
|
| 40 |
+
if (
|
| 41 |
+
3 <= len(line) <= 100
|
| 42 |
+
and GENERIC_CLAIM_TERMS.search(line)
|
| 43 |
+
and not NON_CLAIM_LINES.search(line)
|
| 44 |
+
and not any(re.search(pattern, line, re.IGNORECASE) for pattern in matched_patterns)
|
| 45 |
+
and line.lower() not in {claim.lower() for claim in claims}
|
| 46 |
+
):
|
| 47 |
+
claims.append(line)
|
| 48 |
+
return claims
|
| 49 |
|
| 50 |
|
| 51 |
def _number_after(label: str, text: str, unit: str) -> float | None:
|
|
|
|
| 108 |
|
| 109 |
def extract_ingredients(back_text: str) -> list[str]:
|
| 110 |
match = re.search(
|
| 111 |
+
r"\bingredients?\s*:\s*(.+?)(?=\b(?:nutrition|allergen|contains|net\s*(?:weight|wt)|storage|directions?"
|
| 112 |
+
r"|after[\s-]*opening|best before|mfd|pkd|manufactured|packed|fssai|dates?|unit sale price)\b|$)",
|
| 113 |
normalize_space(back_text),
|
| 114 |
re.IGNORECASE,
|
| 115 |
)
|
|
|
|
| 155 |
|
| 156 |
def parse_expiry(back_text: str) -> ExpiryInfo:
|
| 157 |
text = normalize_space(back_text)
|
| 158 |
+
visible_dates = list(
|
| 159 |
+
dict.fromkeys(
|
| 160 |
+
match.group(0).upper()
|
| 161 |
+
for match in re.finditer(
|
| 162 |
+
r"\b(?:\d{1,2}[\s/\-.]+)?(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)"
|
| 163 |
+
r"(?:[A-Z]*)[\s/\-.]+\d{2,4}\b",
|
| 164 |
+
text,
|
| 165 |
+
re.IGNORECASE,
|
| 166 |
+
)
|
| 167 |
+
)
|
| 168 |
+
)
|
| 169 |
packed_match = re.search(
|
| 170 |
r"\b(?:pkd|packed(?:\s+on)?|mfd|manufactured(?:\s+on)?)\s*[:\-]?\s*"
|
| 171 |
r"(\d{1,2}[\s/\-.]+(?:\d{1,2}|[A-Za-z]{3,9})[\s/\-.]+\d{2,4})",
|
|
|
|
| 195 |
status = f"Best-before evidence resolves to {best_before.isoformat()}"
|
| 196 |
elif instruction and not packed:
|
| 197 |
status = "Relative shelf-life found, but the starting date is missing"
|
| 198 |
+
elif visible_dates:
|
| 199 |
+
status = (
|
| 200 |
+
f"Visible date evidence found ({', '.join(visible_dates)}), but the label does not identify "
|
| 201 |
+
"which date is packing, manufacture, or expiry."
|
| 202 |
+
)
|
| 203 |
else:
|
| 204 |
status = "No resolvable best-before date found"
|
| 205 |
|
|
|
|
| 213 |
best_before=best_before.isoformat() if best_before else None,
|
| 214 |
instruction=instruction,
|
| 215 |
after_opening_instruction=after_opening_match.group(0) if after_opening_match else None,
|
| 216 |
+
visible_date_texts=visible_dates,
|
| 217 |
status=status,
|
| 218 |
)
|
tests/test_audit.py
CHANGED
|
@@ -88,3 +88,26 @@ def test_investigation_requests_missing_evidence_and_stops_explicitly():
|
|
| 88 |
assert any("nutrition panel" in item.lower() for item in result.investigation.missing_evidence)
|
| 89 |
assert "missing-evidence" in result.investigation.stop_reason
|
| 90 |
assert result.agent_review.status == "NOT_REQUESTED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
assert any("nutrition panel" in item.lower() for item in result.investigation.missing_evidence)
|
| 89 |
assert "missing-evidence" in result.investigation.stop_reason
|
| 90 |
assert result.agent_review.status == "NOT_REQUESTED"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_sugar_free_packet_surfaces_sweetener_and_requests_nutrition_panel():
|
| 94 |
+
result = audit_packet(
|
| 95 |
+
"Sugar Free\nEnriched with Extra Calcium with DHA\nReal Badam",
|
| 96 |
+
(
|
| 97 |
+
"Ingredients: Maltodextrin (65%), Badam, Soya Protein Isolate, Sucralose, "
|
| 98 |
+
"Vitamins and Mineral Mix. Net Weight: 200g. Dates: FEB 2024 - JUL 2025."
|
| 99 |
+
),
|
| 100 |
+
)
|
| 101 |
+
assert by_claim(result, "Sugar Free").verdict == Verdict.CANNOT_VERIFY
|
| 102 |
+
assert any("Sucralose" in evidence.text for evidence in by_claim(result, "Sugar Free").evidence)
|
| 103 |
+
assert any("sugar free" in finding.headline.lower() for finding in result.persuasion_gap)
|
| 104 |
+
assert result.ingredients[-1] == "Vitamins and Mineral Mix"
|
| 105 |
+
assert result.expiry.visible_date_texts == ["FEB 2024", "JUL 2025"]
|
| 106 |
+
assert any("visible dates" in item.lower() for item in result.investigation.missing_evidence)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_dynamic_front_claim_is_audited_instead_of_dropped():
|
| 110 |
+
result = audit_packet("Real Badam", "Ingredients: Maltodextrin, Badam. Net Weight: 200g.")
|
| 111 |
+
claim = by_claim(result, "Real Badam")
|
| 112 |
+
assert claim.verdict == Verdict.CONTEXT_MISSING
|
| 113 |
+
assert any("Badam" in evidence.text for evidence in claim.evidence)
|
vision_space/README.md
CHANGED
|
@@ -24,4 +24,3 @@ transcribe only visible evidence from front and back food-package photos.
|
|
| 24 |
|
| 25 |
The main PacketCourt product applies deterministic claim auditing, arithmetic,
|
| 26 |
and refusal rules after transcription.
|
| 27 |
-
|
|
|
|
| 24 |
|
| 25 |
The main PacketCourt product applies deterministic claim auditing, arithmetic,
|
| 26 |
and refusal rules after transcription.
|
|
|
vision_space/requirements.txt
CHANGED
|
@@ -5,4 +5,3 @@ pillow>=11.0.0
|
|
| 5 |
torch>=2.8.0
|
| 6 |
torchvision>=0.23.0
|
| 7 |
transformers>=5.7.0
|
| 8 |
-
|
|
|
|
| 5 |
torch>=2.8.0
|
| 6 |
torchvision>=0.23.0
|
| 7 |
transformers>=5.7.0
|
|
|