feat: publish private PacketCourt evidence audit MVP
Browse filesBuilt with OpenAI Codex. Initial private hackathon deployment.
- .gitignore +6 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/nodeids +7 -0
- README.md +60 -7
- app.py +164 -0
- docs/BUILD_PLAN.md +23 -0
- packages.txt +2 -0
- pyproject.toml +10 -0
- requirements.txt +5 -0
- src/packetcourt/__init__.py +6 -0
- src/packetcourt/audit.py +162 -0
- src/packetcourt/models.py +54 -0
- src/packetcourt/ocr.py +21 -0
- src/packetcourt/parser.py +141 -0
- src/packetcourt/samples.py +24 -0
- tests/test_audit.py +46 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
.pytest_cache/
|
| 3 |
+
.venv/
|
| 4 |
+
*.pyc
|
| 5 |
+
.env
|
| 6 |
+
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"tests/test_audit.py::test_absolute_natural_claim_is_refused",
|
| 3 |
+
"tests/test_audit.py::test_fssai_claim_is_not_treated_as_health_endorsement",
|
| 4 |
+
"tests/test_audit.py::test_multigrain_exposes_refined_flour_context",
|
| 5 |
+
"tests/test_audit.py::test_no_added_sugar_is_contradicted_by_glucose_syrup",
|
| 6 |
+
"tests/test_audit.py::test_relative_best_before_date_is_resolved"
|
| 7 |
+
]
|
README.md
CHANGED
|
@@ -1,13 +1,66 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PacketCourt
|
| 3 |
+
emoji: ⚖️
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.0.1
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- track:backyard
|
| 13 |
+
- sponsor:openbmb
|
| 14 |
+
- sponsor:openai
|
| 15 |
+
- achievement:offbrand
|
| 16 |
+
- build-small-hackathon
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# PacketCourt
|
| 20 |
+
|
| 21 |
+
**The packet takes the stand.**
|
| 22 |
+
|
| 23 |
+
PacketCourt audits front-of-pack marketing claims against evidence printed on
|
| 24 |
+
the same Indian packaged-food label. It produces traceable, conservative
|
| 25 |
+
verdicts instead of an unexplained health score.
|
| 26 |
+
|
| 27 |
+
## Current MVP
|
| 28 |
+
|
| 29 |
+
- Reads front and back label photos with local OCR.
|
| 30 |
+
- Detects six common front claims.
|
| 31 |
+
- Links claims to ingredients, nutrition, FSSAI license, and expiry evidence.
|
| 32 |
+
- Resolves relative dates such as `best before 6 months from packaging`.
|
| 33 |
+
- Produces a machine-readable evidence case.
|
| 34 |
+
- Refuses unsupported legal, medical, and food-safety conclusions.
|
| 35 |
+
|
| 36 |
+
## Run Locally
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
python -m pip install -r requirements.txt
|
| 40 |
+
python app.py
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## Test
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
pytest
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
## Model Plan
|
| 50 |
+
|
| 51 |
+
Phase 2 integrates OpenBMB's 1.3B-parameter `MiniCPM-V-4.6` for evidence-region
|
| 52 |
+
discovery and OCR correction, plus a fine-tuned `MiniCPM5-1B` for constrained
|
| 53 |
+
claim-to-evidence mapping. Deterministic code remains responsible for numeric
|
| 54 |
+
calculations and final verdict triggers.
|
| 55 |
+
|
| 56 |
+
## Safety Boundary
|
| 57 |
+
|
| 58 |
+
PacketCourt does not declare products healthy, safe, illegal, or fraudulent.
|
| 59 |
+
It audits only the supplied label evidence and exposes uncertainty explicitly.
|
| 60 |
+
|
| 61 |
+
## Codex Attribution
|
| 62 |
+
|
| 63 |
+
The repository is being built with OpenAI Codex as the primary coding agent.
|
| 64 |
+
Codex is responsible for the initial architecture, deterministic audit engine,
|
| 65 |
+
tests, Gradio application, and deployment workflow.
|
| 66 |
+
|
app.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).parent
|
| 11 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 12 |
+
|
| 13 |
+
from packetcourt import audit_packet
|
| 14 |
+
from packetcourt.ocr import extract_text
|
| 15 |
+
from packetcourt.samples import SAMPLES
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
VERDICT_CLASS = {
|
| 19 |
+
"SUPPORTED BY PROVIDED LABEL": "supported",
|
| 20 |
+
"CONTRADICTED BY PROVIDED LABEL": "contradicted",
|
| 21 |
+
"TECHNICALLY TRUE, CONTEXT MISSING": "context",
|
| 22 |
+
"CANNOT VERIFY": "unknown",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def render_report(front_text: str, back_text: str) -> tuple[str, str]:
|
| 27 |
+
audit = audit_packet(front_text, back_text)
|
| 28 |
+
if not audit.claims:
|
| 29 |
+
cards = """
|
| 30 |
+
<div class="empty-state">
|
| 31 |
+
<div class="empty-mark">?</div>
|
| 32 |
+
<h3>No supported claim type detected</h3>
|
| 33 |
+
<p>Try a front label containing claims such as High Protein, No Added Sugar,
|
| 34 |
+
Multigrain, 100% Natural, FSSAI Approved, or No Preservatives.</p>
|
| 35 |
+
</div>
|
| 36 |
+
"""
|
| 37 |
+
else:
|
| 38 |
+
cards = "".join(
|
| 39 |
+
f"""
|
| 40 |
+
<article class="claim-card {VERDICT_CLASS[claim.verdict.value]}">
|
| 41 |
+
<div class="claim-top">
|
| 42 |
+
<span class="claim-name">{html.escape(claim.claim)}</span>
|
| 43 |
+
<span class="verdict">{html.escape(claim.verdict.value)}</span>
|
| 44 |
+
</div>
|
| 45 |
+
<p class="summary">{html.escape(claim.summary)}</p>
|
| 46 |
+
<div class="evidence-list">
|
| 47 |
+
{''.join(f'<div class="evidence"><b>{html.escape(e.source)}</b><span>{html.escape(e.text)}</span></div>' for e in claim.evidence)}
|
| 48 |
+
</div>
|
| 49 |
+
{f'<p class="caveat">{html.escape(claim.caveat)}</p>' if claim.caveat else ''}
|
| 50 |
+
</article>
|
| 51 |
+
"""
|
| 52 |
+
for claim in audit.claims
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
nutrition_rows = [
|
| 56 |
+
("Basis", audit.nutrition.basis),
|
| 57 |
+
("Protein", f"{audit.nutrition.protein_g:g}g" if audit.nutrition.protein_g is not None else "Not found"),
|
| 58 |
+
("Total sugar", f"{audit.nutrition.total_sugar_g:g}g" if audit.nutrition.total_sugar_g is not None else "Not found"),
|
| 59 |
+
("Added sugar", f"{audit.nutrition.added_sugar_g:g}g" if audit.nutrition.added_sugar_g is not None else "Not found"),
|
| 60 |
+
("Sodium", f"{audit.nutrition.sodium_mg:g}mg" if audit.nutrition.sodium_mg is not None else "Not found"),
|
| 61 |
+
]
|
| 62 |
+
facts = "".join(f"<div><span>{html.escape(k)}</span><b>{html.escape(v)}</b></div>" for k, v in nutrition_rows)
|
| 63 |
+
expiry = html.escape(audit.expiry.status)
|
| 64 |
+
report = f"""
|
| 65 |
+
<section class="report-shell">
|
| 66 |
+
<div class="report-heading">
|
| 67 |
+
<div><span class="eyebrow">EVIDENCE HEARING</span><h2>Packet claim audit</h2></div>
|
| 68 |
+
<span class="claim-count">{len(audit.claims)} claims examined</span>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="claim-grid">{cards}</div>
|
| 71 |
+
<div class="secondary-grid">
|
| 72 |
+
<section class="facts-panel"><span class="eyebrow">NUTRITION EVIDENCE</span>{facts}</section>
|
| 73 |
+
<section class="expiry-panel"><span class="eyebrow">DATE EVIDENCE</span><h3>{expiry}</h3><p>Expiry interpretation is shown as evidence, not a food-safety guarantee.</p></section>
|
| 74 |
+
</div>
|
| 75 |
+
</section>
|
| 76 |
+
"""
|
| 77 |
+
return report, json.dumps(audit.model_dump(mode="json"), indent=2)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def read_images(front_image: str | None, back_image: str | None) -> tuple[str, str, str]:
|
| 81 |
+
front, front_status = extract_text(front_image)
|
| 82 |
+
back, back_status = extract_text(back_image)
|
| 83 |
+
return front, back, f"Front: {front_status}\nBack: {back_status}"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def load_sample(name: str) -> tuple[str, str]:
|
| 87 |
+
sample = SAMPLES[name]
|
| 88 |
+
return sample["front"], sample["back"]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
CSS = """
|
| 92 |
+
:root { --ink:#171611; --paper:#f7f2e7; --red:#d64c3f; --green:#2c7458; --amber:#b97918; }
|
| 93 |
+
.gradio-container { max-width: 1180px !important; margin:auto; background:var(--paper); color:var(--ink); }
|
| 94 |
+
.hero { border:1px solid #d7cdbd; border-radius:28px; padding:34px; margin:14px 0 20px;
|
| 95 |
+
background:radial-gradient(circle at 90% 10%,#ffd7a8,transparent 35%),linear-gradient(135deg,#fffaf0,#f1e8d8); }
|
| 96 |
+
.hero h1 { font-size:clamp(42px,8vw,92px); line-height:.9; letter-spacing:-.07em; margin:8px 0 16px; }
|
| 97 |
+
.hero p { max-width:700px; font-size:18px; color:#514c43; }
|
| 98 |
+
.eyebrow { font:700 11px/1 monospace; letter-spacing:.16em; color:#6b6256; }
|
| 99 |
+
.report-shell { margin-top:18px; }.report-heading,.claim-top { display:flex; justify-content:space-between; gap:12px; align-items:start; }
|
| 100 |
+
.report-heading h2 { margin:6px 0 18px; font-size:34px; letter-spacing:-.04em; }
|
| 101 |
+
.claim-count,.verdict { font:700 10px/1.3 monospace; text-transform:uppercase; padding:8px 10px; border:1px solid #c9beae; border-radius:99px; }
|
| 102 |
+
.claim-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(290px,1fr)); gap:14px; }
|
| 103 |
+
.claim-card { background:#fffdf7; border:1px solid #d6ccbc; border-top:5px solid #777; border-radius:18px; padding:18px; box-shadow:0 8px 26px #5f493510; }
|
| 104 |
+
.claim-card.supported { border-top-color:var(--green); }.claim-card.contradicted { border-top-color:var(--red); }
|
| 105 |
+
.claim-card.context { border-top-color:var(--amber); }.claim-name { font-size:21px; font-weight:800; }
|
| 106 |
+
.summary { min-height:48px; color:#49443c; }.evidence-list { display:grid; gap:7px; }
|
| 107 |
+
.evidence { display:grid; gap:3px; background:#f4eee3; border-radius:10px; padding:10px; }
|
| 108 |
+
.evidence b { font:700 10px/1 monospace; text-transform:uppercase; color:#756b5e; }
|
| 109 |
+
.caveat { font-size:12px; color:#746b5f; border-top:1px dashed #cfc2af; padding-top:10px; }
|
| 110 |
+
.secondary-grid { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:14px; }
|
| 111 |
+
.facts-panel,.expiry-panel,.empty-state { border:1px solid #d6ccbc; border-radius:18px; padding:18px; background:#fffdf7; }
|
| 112 |
+
.facts-panel div { display:flex; justify-content:space-between; border-bottom:1px solid #ebe2d5; padding:9px 0; }
|
| 113 |
+
.empty-state { text-align:center; padding:35px; }.empty-mark { font-size:60px; color:var(--amber); }
|
| 114 |
+
@media(max-width:700px){.secondary-grid{grid-template-columns:1fr}.hero{padding:24px}.claim-top{display:grid}.verdict{width:fit-content}}
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
with gr.Blocks(title="PacketCourt") as demo:
|
| 119 |
+
gr.HTML(
|
| 120 |
+
"""
|
| 121 |
+
<section class="hero">
|
| 122 |
+
<span class="eyebrow">PACKETCOURT / INDIA</span>
|
| 123 |
+
<h1>The packet<br>takes the stand.</h1>
|
| 124 |
+
<p>Photograph the front and back of an Indian food packet. PacketCourt audits
|
| 125 |
+
marketing claims against the evidence printed on the same package.</p>
|
| 126 |
+
</section>
|
| 127 |
+
"""
|
| 128 |
+
)
|
| 129 |
+
with gr.Tabs():
|
| 130 |
+
with gr.Tab("Audit a packet"):
|
| 131 |
+
with gr.Row():
|
| 132 |
+
front_image = gr.Image(type="filepath", label="Front of packet", sources=["upload", "webcam"])
|
| 133 |
+
back_image = gr.Image(type="filepath", label="Back label", sources=["upload", "webcam"])
|
| 134 |
+
read_button = gr.Button("Read label photos", variant="secondary")
|
| 135 |
+
ocr_status = gr.Textbox(label="OCR status", interactive=False, lines=2)
|
| 136 |
+
with gr.Row():
|
| 137 |
+
front_text = gr.Textbox(label="Front claims", lines=7, placeholder="OCR output appears here. Correct it before auditing.")
|
| 138 |
+
back_text = gr.Textbox(label="Back-label evidence", lines=10, placeholder="Ingredients, nutrition panel, dates, and license evidence.")
|
| 139 |
+
audit_button = gr.Button("Put this packet on trial", variant="primary")
|
| 140 |
+
with gr.Tab("Try a prepared case"):
|
| 141 |
+
sample = gr.Dropdown(list(SAMPLES), value=list(SAMPLES)[0], label="Prepared evidence case")
|
| 142 |
+
sample_button = gr.Button("Load prepared case")
|
| 143 |
+
gr.Markdown("Prepared cases let judges test the complete audit flow without taking photos.")
|
| 144 |
+
with gr.Tab("Method"):
|
| 145 |
+
gr.Markdown(
|
| 146 |
+
"""
|
| 147 |
+
### Evidence before verdict
|
| 148 |
+
PacketCourt does not declare a product healthy, safe, illegal, or fraudulent.
|
| 149 |
+
It extracts visible claims, links them to supplied evidence, runs conservative
|
| 150 |
+
deterministic checks, and refuses conclusions it cannot support.
|
| 151 |
+
|
| 152 |
+
**Current Phase 1 claim types:** High Protein, No Added Sugar, Multigrain,
|
| 153 |
+
100% Natural, FSSAI Approved, and No Preservatives.
|
| 154 |
+
"""
|
| 155 |
+
)
|
| 156 |
+
report = gr.HTML()
|
| 157 |
+
raw_json = gr.Code(label="Machine-readable evidence case", language="json")
|
| 158 |
+
|
| 159 |
+
read_button.click(read_images, [front_image, back_image], [front_text, back_text, ocr_status])
|
| 160 |
+
sample_button.click(load_sample, sample, [front_text, back_text]).then(render_report, [front_text, back_text], [report, raw_json])
|
| 161 |
+
audit_button.click(render_report, [front_text, back_text], [report, raw_json])
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
demo.launch(css=CSS)
|
docs/BUILD_PLAN.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PacketCourt Build Plan
|
| 2 |
+
|
| 3 |
+
## Phase 1: Evidence Engine
|
| 4 |
+
|
| 5 |
+
- Parse common front-of-pack claims.
|
| 6 |
+
- Extract ingredient, nutrition, licensing, and expiry evidence.
|
| 7 |
+
- Produce conservative structured verdicts.
|
| 8 |
+
- Refuse unsupported legal, medical, and safety conclusions.
|
| 9 |
+
|
| 10 |
+
## Phase 2: Small-Model Pipeline
|
| 11 |
+
|
| 12 |
+
- Integrate MiniCPM-V-4.6 for evidence-region discovery and OCR correction.
|
| 13 |
+
- Build and publish an Indian packet-claim audit dataset.
|
| 14 |
+
- Fine-tune MiniCPM5-1B for constrained claim-to-evidence mapping.
|
| 15 |
+
- Evaluate against deterministic golden cases.
|
| 16 |
+
|
| 17 |
+
## Phase 3: Product And Submission
|
| 18 |
+
|
| 19 |
+
- Add evidence crops and front-versus-back persuasion-gap view.
|
| 20 |
+
- Add anonymized trace export.
|
| 21 |
+
- Validate with real Indian packaged foods and family users.
|
| 22 |
+
- Publish Field Notes, demo video, public GitHub repository, and Space.
|
| 23 |
+
|
packages.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tesseract-ocr
|
| 2 |
+
|
pyproject.toml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "packetcourt"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Evidence-first claim auditing for Indian packaged-food labels"
|
| 5 |
+
requires-python = ">=3.11"
|
| 6 |
+
|
| 7 |
+
[tool.pytest.ini_options]
|
| 8 |
+
pythonpath = ["src"]
|
| 9 |
+
testpaths = ["tests"]
|
| 10 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.0.1
|
| 2 |
+
pillow>=11.0.0
|
| 3 |
+
pydantic>=2.10.0
|
| 4 |
+
pytesseract>=0.3.13
|
| 5 |
+
|
src/packetcourt/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PacketCourt claim-audit engine."""
|
| 2 |
+
|
| 3 |
+
from .audit import audit_packet
|
| 4 |
+
|
| 5 |
+
__all__ = ["audit_packet"]
|
| 6 |
+
|
src/packetcourt/audit.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from .models import ClaimAudit, Evidence, PacketAudit, Verdict
|
| 6 |
+
from .parser import extract_claims, extract_ingredients, parse_expiry, parse_nutrition
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ADDED_SUGAR_TERMS = {
|
| 10 |
+
"sugar",
|
| 11 |
+
"glucose",
|
| 12 |
+
"glucose syrup",
|
| 13 |
+
"corn syrup",
|
| 14 |
+
"high fructose corn syrup",
|
| 15 |
+
"invert syrup",
|
| 16 |
+
"cane sugar",
|
| 17 |
+
"brown sugar",
|
| 18 |
+
"jaggery",
|
| 19 |
+
"honey",
|
| 20 |
+
"dextrose",
|
| 21 |
+
"fructose",
|
| 22 |
+
"sucrose",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _ingredient_evidence(ingredients: list[str], matches: list[str]) -> list[Evidence]:
|
| 27 |
+
return [Evidence(source="ingredient list", text=item) for item in matches]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _audit_claim(claim: str, back_text: str, ingredients: list[str], nutrition) -> ClaimAudit:
|
| 31 |
+
lowered_ingredients = [item.lower() for item in ingredients]
|
| 32 |
+
|
| 33 |
+
if claim == "No Added Sugar":
|
| 34 |
+
matches = [
|
| 35 |
+
original
|
| 36 |
+
for original, lowered in zip(ingredients, lowered_ingredients)
|
| 37 |
+
if any(re.search(rf"\b{re.escape(term)}\b", lowered) for term in ADDED_SUGAR_TERMS)
|
| 38 |
+
]
|
| 39 |
+
if matches:
|
| 40 |
+
return ClaimAudit(
|
| 41 |
+
claim=claim,
|
| 42 |
+
verdict=Verdict.CONTRADICTED,
|
| 43 |
+
summary="The provided ingredient list names one or more added-sugar ingredients.",
|
| 44 |
+
evidence=_ingredient_evidence(ingredients, matches),
|
| 45 |
+
caveat="This verdict only checks the supplied label text; it is not a laboratory analysis.",
|
| 46 |
+
)
|
| 47 |
+
if ingredients:
|
| 48 |
+
return ClaimAudit(
|
| 49 |
+
claim=claim,
|
| 50 |
+
verdict=Verdict.SUPPORTED,
|
| 51 |
+
summary="No common added-sugar term was found in the provided ingredient list.",
|
| 52 |
+
evidence=[Evidence(source="ingredient list", text=", ".join(ingredients))],
|
| 53 |
+
caveat="Unrecognized sweeteners or incomplete OCR may change this result.",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if claim == "Multigrain":
|
| 57 |
+
grain_terms = ("wheat", "rice", "oat", "barley", "ragi", "millet", "jowar", "bajra", "maize", "corn")
|
| 58 |
+
matches = [item for item in ingredients if any(term in item.lower() for term in grain_terms)]
|
| 59 |
+
if len(matches) >= 2:
|
| 60 |
+
first = ingredients[0] if ingredients else ""
|
| 61 |
+
return ClaimAudit(
|
| 62 |
+
claim=claim,
|
| 63 |
+
verdict=Verdict.CONTEXT_MISSING if "refined" in first.lower() else Verdict.SUPPORTED,
|
| 64 |
+
summary=(
|
| 65 |
+
"Multiple grains are listed, but refined grain appears first."
|
| 66 |
+
if "refined" in first.lower()
|
| 67 |
+
else "Multiple grain ingredients are present in the supplied ingredient list."
|
| 68 |
+
),
|
| 69 |
+
evidence=_ingredient_evidence(ingredients, matches),
|
| 70 |
+
caveat="Ingredient order indicates relative quantity, but exact grain percentages may be unavailable.",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
if claim == "High Protein":
|
| 74 |
+
if nutrition.protein_g is not None:
|
| 75 |
+
if nutrition.basis == "unknown":
|
| 76 |
+
return ClaimAudit(
|
| 77 |
+
claim=claim,
|
| 78 |
+
verdict=Verdict.CANNOT_VERIFY,
|
| 79 |
+
summary="Protein is listed, but its measurement basis could not be determined.",
|
| 80 |
+
evidence=[Evidence(source="nutrition panel", text=f"Protein {nutrition.protein_g:g}g")],
|
| 81 |
+
)
|
| 82 |
+
return ClaimAudit(
|
| 83 |
+
claim=claim,
|
| 84 |
+
verdict=Verdict.CONTEXT_MISSING,
|
| 85 |
+
summary="The protein quantity is visible, but claim compliance depends on product category and applicable rules.",
|
| 86 |
+
evidence=[
|
| 87 |
+
Evidence(
|
| 88 |
+
source="nutrition panel",
|
| 89 |
+
text=f"Protein {nutrition.protein_g:g}g ({nutrition.basis})",
|
| 90 |
+
)
|
| 91 |
+
],
|
| 92 |
+
caveat="PacketCourt does not make a regulatory-compliance determination in this prototype.",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
if claim == "No Preservatives":
|
| 96 |
+
preservative_pattern = r"\b(?:preservative|sodium benzoate|potassium sorbate|ins\s*2\d{2})\b"
|
| 97 |
+
matches = [item for item in ingredients if re.search(preservative_pattern, item, re.IGNORECASE)]
|
| 98 |
+
if matches:
|
| 99 |
+
return ClaimAudit(
|
| 100 |
+
claim=claim,
|
| 101 |
+
verdict=Verdict.CONTRADICTED,
|
| 102 |
+
summary="The ingredient list contains a recognizable preservative term or code.",
|
| 103 |
+
evidence=_ingredient_evidence(ingredients, matches),
|
| 104 |
+
)
|
| 105 |
+
if ingredients:
|
| 106 |
+
return ClaimAudit(
|
| 107 |
+
claim=claim,
|
| 108 |
+
verdict=Verdict.SUPPORTED,
|
| 109 |
+
summary="No recognizable preservative term was found in the supplied ingredient list.",
|
| 110 |
+
evidence=[Evidence(source="ingredient list", text=", ".join(ingredients))],
|
| 111 |
+
caveat="Incomplete OCR or unfamiliar additive codes may change this result.",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if claim == "100% Natural":
|
| 115 |
+
return ClaimAudit(
|
| 116 |
+
claim=claim,
|
| 117 |
+
verdict=Verdict.CANNOT_VERIFY,
|
| 118 |
+
summary="An absolute naturalness claim cannot be established from package text alone.",
|
| 119 |
+
evidence=[Evidence(source="front claim", text=claim)],
|
| 120 |
+
caveat="PacketCourt refuses to infer product composition beyond the supplied label.",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
if claim == "FSSAI Approved":
|
| 124 |
+
license_match = re.search(r"\bfssai\b.{0,30}(\d{14})", back_text, re.IGNORECASE)
|
| 125 |
+
evidence = [Evidence(source="back label", text=f"FSSAI license number {license_match.group(1)}")] if license_match else []
|
| 126 |
+
return ClaimAudit(
|
| 127 |
+
claim=claim,
|
| 128 |
+
verdict=Verdict.CONTEXT_MISSING,
|
| 129 |
+
summary="An FSSAI license indicates regulatory registration; it is not a health endorsement.",
|
| 130 |
+
evidence=evidence or [Evidence(source="front claim", text=claim)],
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return ClaimAudit(
|
| 134 |
+
claim=claim,
|
| 135 |
+
verdict=Verdict.CANNOT_VERIFY,
|
| 136 |
+
summary="The supplied back-label evidence is insufficient for this claim.",
|
| 137 |
+
evidence=[Evidence(source="front claim", text=claim)],
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def audit_packet(front_text: str, back_text: str) -> PacketAudit:
|
| 142 |
+
claims = extract_claims(front_text)
|
| 143 |
+
ingredients = extract_ingredients(back_text)
|
| 144 |
+
nutrition = parse_nutrition(back_text)
|
| 145 |
+
expiry = parse_expiry(back_text)
|
| 146 |
+
|
| 147 |
+
limitations = [
|
| 148 |
+
"PacketCourt audits only the text and images supplied by the user.",
|
| 149 |
+
"Verdicts are evidence summaries, not legal, medical, or food-safety determinations.",
|
| 150 |
+
"Users should verify low-confidence OCR against the physical packet.",
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
return PacketAudit(
|
| 154 |
+
claims=[_audit_claim(claim, back_text, ingredients, nutrition) for claim in claims],
|
| 155 |
+
nutrition=nutrition,
|
| 156 |
+
ingredients=ingredients,
|
| 157 |
+
expiry=expiry,
|
| 158 |
+
front_text=front_text,
|
| 159 |
+
back_text=back_text,
|
| 160 |
+
limitations=limitations,
|
| 161 |
+
)
|
| 162 |
+
|
src/packetcourt/models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from enum import StrEnum
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Verdict(StrEnum):
|
| 9 |
+
SUPPORTED = "SUPPORTED BY PROVIDED LABEL"
|
| 10 |
+
CONTRADICTED = "CONTRADICTED BY PROVIDED LABEL"
|
| 11 |
+
CONTEXT_MISSING = "TECHNICALLY TRUE, CONTEXT MISSING"
|
| 12 |
+
CANNOT_VERIFY = "CANNOT VERIFY"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Evidence(BaseModel):
|
| 16 |
+
source: str
|
| 17 |
+
text: str
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ClaimAudit(BaseModel):
|
| 21 |
+
claim: str
|
| 22 |
+
verdict: Verdict
|
| 23 |
+
summary: str
|
| 24 |
+
evidence: list[Evidence] = Field(default_factory=list)
|
| 25 |
+
caveat: str = ""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class NutritionFacts(BaseModel):
|
| 29 |
+
basis: str = "unknown"
|
| 30 |
+
serving_size_g: float | None = None
|
| 31 |
+
package_size_g: float | None = None
|
| 32 |
+
protein_g: float | None = None
|
| 33 |
+
total_sugar_g: float | None = None
|
| 34 |
+
added_sugar_g: float | None = None
|
| 35 |
+
sodium_mg: float | None = None
|
| 36 |
+
saturated_fat_g: float | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ExpiryInfo(BaseModel):
|
| 40 |
+
packed_on: str | None = None
|
| 41 |
+
best_before: str | None = None
|
| 42 |
+
instruction: str | None = None
|
| 43 |
+
status: str = "Not enough label evidence"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class PacketAudit(BaseModel):
|
| 47 |
+
claims: list[ClaimAudit]
|
| 48 |
+
nutrition: NutritionFacts
|
| 49 |
+
ingredients: list[str]
|
| 50 |
+
expiry: ExpiryInfo
|
| 51 |
+
front_text: str
|
| 52 |
+
back_text: str
|
| 53 |
+
limitations: list[str]
|
| 54 |
+
|
src/packetcourt/ocr.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def extract_text(image_path: str | None) -> tuple[str, str]:
|
| 9 |
+
if not image_path:
|
| 10 |
+
return "", "No image supplied."
|
| 11 |
+
try:
|
| 12 |
+
import pytesseract
|
| 13 |
+
|
| 14 |
+
image = Image.open(Path(image_path)).convert("RGB")
|
| 15 |
+
text = pytesseract.image_to_string(image, config="--psm 6").strip()
|
| 16 |
+
if text:
|
| 17 |
+
return text, "OCR completed with Tesseract. Verify against the packet."
|
| 18 |
+
return "", "OCR found no readable text. Use a closer photo or paste the label text."
|
| 19 |
+
except Exception as exc:
|
| 20 |
+
return "", f"OCR unavailable: {exc}. Paste the label text to continue."
|
| 21 |
+
|
src/packetcourt/parser.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import calendar
|
| 4 |
+
import re
|
| 5 |
+
from datetime import date
|
| 6 |
+
|
| 7 |
+
from .models import ExpiryInfo, NutritionFacts
|
| 8 |
+
|
| 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"),
|
| 15 |
+
("FSSAI Approved", r"\bfssai[\s-]*approved\b"),
|
| 16 |
+
("No Preservatives", r"\bno[\s-]*preservatives?\b"),
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def normalize_space(text: str) -> str:
|
| 21 |
+
return re.sub(r"\s+", " ", text or "").strip()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def extract_claims(front_text: str) -> list[str]:
|
| 25 |
+
text = normalize_space(front_text).lower()
|
| 26 |
+
return [name for name, pattern in CLAIM_PATTERNS if re.search(pattern, text)]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _number_after(label: str, text: str, unit: str) -> float | None:
|
| 30 |
+
pattern = rf"\b{label}\b[^0-9]{{0,24}}(\d+(?:\.\d+)?)\s*{unit}\b"
|
| 31 |
+
match = re.search(pattern, text, re.IGNORECASE)
|
| 32 |
+
return float(match.group(1)) if match else None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def parse_nutrition(back_text: str) -> NutritionFacts:
|
| 36 |
+
text = normalize_space(back_text)
|
| 37 |
+
basis = "unknown"
|
| 38 |
+
if re.search(r"\bper\s*100\s*g\b", text, re.IGNORECASE):
|
| 39 |
+
basis = "per 100g"
|
| 40 |
+
elif re.search(r"\bper\s*serving\b", text, re.IGNORECASE):
|
| 41 |
+
basis = "per serving"
|
| 42 |
+
|
| 43 |
+
return NutritionFacts(
|
| 44 |
+
basis=basis,
|
| 45 |
+
serving_size_g=_number_after(r"serving\s*size", text, "g"),
|
| 46 |
+
package_size_g=_number_after(r"net\s*(?:weight|wt)", text, "g"),
|
| 47 |
+
protein_g=_number_after("protein", text, "g"),
|
| 48 |
+
total_sugar_g=_number_after(r"total\s*sugars?", text, "g"),
|
| 49 |
+
added_sugar_g=_number_after(r"added\s*sugars?", text, "g"),
|
| 50 |
+
sodium_mg=_number_after("sodium", text, "mg"),
|
| 51 |
+
saturated_fat_g=_number_after(r"saturated\s*fat", text, "g"),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def extract_ingredients(back_text: str) -> list[str]:
|
| 56 |
+
match = re.search(
|
| 57 |
+
r"\bingredients?\s*:\s*(.+?)(?=\b(?:nutrition|allergen|contains|best before|mfd|pkd|manufactured|packed)\b|$)",
|
| 58 |
+
normalize_space(back_text),
|
| 59 |
+
re.IGNORECASE,
|
| 60 |
+
)
|
| 61 |
+
if not match:
|
| 62 |
+
return []
|
| 63 |
+
return [item.strip(" .") for item in re.split(r"[,;]", match.group(1)) if item.strip()]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _parse_date(value: str) -> date | None:
|
| 67 |
+
clean = value.strip().upper().replace(".", " ").replace("-", " ").replace("/", " ")
|
| 68 |
+
formats = [
|
| 69 |
+
r"(?P<day>\d{1,2})\s+(?P<month>\d{1,2})\s+(?P<year>\d{2,4})",
|
| 70 |
+
r"(?P<day>\d{1,2})\s+(?P<month>[A-Z]{3,9})\s+(?P<year>\d{2,4})",
|
| 71 |
+
]
|
| 72 |
+
for pattern in formats:
|
| 73 |
+
match = re.search(pattern, clean)
|
| 74 |
+
if not match:
|
| 75 |
+
continue
|
| 76 |
+
year = int(match.group("year"))
|
| 77 |
+
year += 2000 if year < 100 else 0
|
| 78 |
+
month_raw = match.group("month")
|
| 79 |
+
if month_raw.isdigit():
|
| 80 |
+
month = int(month_raw)
|
| 81 |
+
else:
|
| 82 |
+
month = next(
|
| 83 |
+
(i for i, name in enumerate(calendar.month_abbr) if name and month_raw.startswith(name.upper())),
|
| 84 |
+
0,
|
| 85 |
+
)
|
| 86 |
+
try:
|
| 87 |
+
return date(year, month, int(match.group("day")))
|
| 88 |
+
except ValueError:
|
| 89 |
+
return None
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _add_months(value: date, months: int) -> date:
|
| 94 |
+
month_index = value.month - 1 + months
|
| 95 |
+
year = value.year + month_index // 12
|
| 96 |
+
month = month_index % 12 + 1
|
| 97 |
+
day = min(value.day, calendar.monthrange(year, month)[1])
|
| 98 |
+
return date(year, month, day)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def parse_expiry(back_text: str) -> ExpiryInfo:
|
| 102 |
+
text = normalize_space(back_text)
|
| 103 |
+
packed_match = re.search(
|
| 104 |
+
r"\b(?:pkd|packed(?:\s+on)?|mfd|manufactured(?:\s+on)?)\s*[:\-]?\s*"
|
| 105 |
+
r"(\d{1,2}[\s/\-.]+(?:\d{1,2}|[A-Za-z]{3,9})[\s/\-.]+\d{2,4})",
|
| 106 |
+
text,
|
| 107 |
+
re.IGNORECASE,
|
| 108 |
+
)
|
| 109 |
+
packed = _parse_date(packed_match.group(1)) if packed_match else None
|
| 110 |
+
|
| 111 |
+
direct_match = re.search(
|
| 112 |
+
r"\b(?:exp|expiry|use\s+by|best\s+before)\s*[:\-]?\s*"
|
| 113 |
+
r"(\d{1,2}[\s/\-.]+(?:\d{1,2}|[A-Za-z]{3,9})[\s/\-.]+\d{2,4})",
|
| 114 |
+
text,
|
| 115 |
+
re.IGNORECASE,
|
| 116 |
+
)
|
| 117 |
+
best_before = _parse_date(direct_match.group(1)) if direct_match else None
|
| 118 |
+
|
| 119 |
+
relative_match = re.search(
|
| 120 |
+
r"\bbest\s+before\s+(\d+)\s+months?\s+from\s+(?:packaging|packing|manufacture|manufacturing)\b",
|
| 121 |
+
text,
|
| 122 |
+
re.IGNORECASE,
|
| 123 |
+
)
|
| 124 |
+
instruction = relative_match.group(0) if relative_match else None
|
| 125 |
+
if packed and relative_match:
|
| 126 |
+
best_before = _add_months(packed, int(relative_match.group(1)))
|
| 127 |
+
|
| 128 |
+
if best_before:
|
| 129 |
+
status = f"Best-before evidence resolves to {best_before.isoformat()}"
|
| 130 |
+
elif instruction and not packed:
|
| 131 |
+
status = "Relative shelf-life found, but the starting date is missing"
|
| 132 |
+
else:
|
| 133 |
+
status = "No resolvable best-before date found"
|
| 134 |
+
|
| 135 |
+
return ExpiryInfo(
|
| 136 |
+
packed_on=packed.isoformat() if packed else None,
|
| 137 |
+
best_before=best_before.isoformat() if best_before else None,
|
| 138 |
+
instruction=instruction,
|
| 139 |
+
status=status,
|
| 140 |
+
)
|
| 141 |
+
|
src/packetcourt/samples.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SAMPLES = {
|
| 2 |
+
"Protein cereal with context gap": {
|
| 3 |
+
"front": "POWER START HIGH PROTEIN MULTIGRAIN 100% NATURAL",
|
| 4 |
+
"back": (
|
| 5 |
+
"Ingredients: Refined wheat flour, rolled oats, ragi flour, sugar, cocoa powder, salt. "
|
| 6 |
+
"Nutrition information per 100g: Protein 12.4g, Total Sugars 22g, Added Sugars 18g, "
|
| 7 |
+
"Sodium 410mg, Saturated Fat 3.2g. Net weight 300g. "
|
| 8 |
+
"PKD: 13 JUN 26. Best before 6 months from packaging."
|
| 9 |
+
),
|
| 10 |
+
},
|
| 11 |
+
"No-added-sugar contradiction": {
|
| 12 |
+
"front": "NO ADDED SUGAR | FSSAI APPROVED",
|
| 13 |
+
"back": (
|
| 14 |
+
"Ingredients: Rolled oats, glucose syrup, peanuts, cocoa. "
|
| 15 |
+
"Nutrition information per 100g: Protein 8g, Total Sugars 19g, Added Sugars 12g. "
|
| 16 |
+
"FSSAI Lic. No. 12345678901234. Use by: 08 JUL 2026."
|
| 17 |
+
),
|
| 18 |
+
},
|
| 19 |
+
"Conservative refusal": {
|
| 20 |
+
"front": "100% NATURAL HIGH PROTEIN",
|
| 21 |
+
"back": "Ingredients: Chickpea flour, spices, salt. Protein 9g. Best before 09/2026.",
|
| 22 |
+
},
|
| 23 |
+
}
|
| 24 |
+
|
tests/test_audit.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from packetcourt import audit_packet
|
| 2 |
+
from packetcourt.models import Verdict
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def by_claim(result, name):
|
| 6 |
+
return next(claim for claim in result.claims if claim.claim == name)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_no_added_sugar_is_contradicted_by_glucose_syrup():
|
| 10 |
+
result = audit_packet(
|
| 11 |
+
"NO ADDED SUGAR",
|
| 12 |
+
"Ingredients: rolled oats, glucose syrup, peanuts. Nutrition per 100g: Total Sugar 18g.",
|
| 13 |
+
)
|
| 14 |
+
assert by_claim(result, "No Added Sugar").verdict == Verdict.CONTRADICTED
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_multigrain_exposes_refined_flour_context():
|
| 18 |
+
result = audit_packet(
|
| 19 |
+
"MULTIGRAIN",
|
| 20 |
+
"Ingredients: refined wheat flour, oats, ragi flour, salt.",
|
| 21 |
+
)
|
| 22 |
+
assert by_claim(result, "Multigrain").verdict == Verdict.CONTEXT_MISSING
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_absolute_natural_claim_is_refused():
|
| 26 |
+
result = audit_packet("100% NATURAL", "Ingredients: chickpea flour, salt.")
|
| 27 |
+
assert by_claim(result, "100% Natural").verdict == Verdict.CANNOT_VERIFY
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_relative_best_before_date_is_resolved():
|
| 31 |
+
result = audit_packet(
|
| 32 |
+
"HIGH PROTEIN",
|
| 33 |
+
"PKD: 13 JUN 26. Best before 6 months from packaging. Nutrition per 100g: Protein 12g.",
|
| 34 |
+
)
|
| 35 |
+
assert result.expiry.best_before == "2026-12-13"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_fssai_claim_is_not_treated_as_health_endorsement():
|
| 39 |
+
result = audit_packet(
|
| 40 |
+
"FSSAI APPROVED",
|
| 41 |
+
"FSSAI Lic. No. 12345678901234. Ingredients: oats.",
|
| 42 |
+
)
|
| 43 |
+
audit = by_claim(result, "FSSAI Approved")
|
| 44 |
+
assert audit.verdict == Verdict.CONTEXT_MISSING
|
| 45 |
+
assert "not a health endorsement" in audit.summary
|
| 46 |
+
|