DIV-45 commited on
Commit
e52b262
·
verified ·
1 Parent(s): a424512

feat: publish private PacketCourt evidence audit MVP

Browse files

Built with OpenAI Codex. Initial private hackathon deployment.

.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: Packetcourt
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+