DIV-45 commited on
Commit
2441bab
·
verified ·
1 Parent(s): d401c5d

feat: dynamic audits and community evidence review agent

Browse files

Codex-authored dynamic claim handling, ambiguous date evidence, and approval-gated feedback learning queue.

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 POLICY_TOOLS[claim]
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(POLICY_TOOLS[name] == "inspect_ingredients" for name in claim_names):
48
  missing.append("A readable ingredient list")
49
- if claim_names and nutrition.basis == "unknown" and any(POLICY_TOOLS[name] == "inspect_nutrition" for name in claim_names):
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
- return [name for name, pattern in CLAIM_PATTERNS if re.search(pattern, text)]
 
 
 
 
 
 
 
 
 
 
 
 
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|best before|mfd|pkd|manufactured|packed)\b|$)",
 
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