rigidhat commited on
Commit
6890ace
·
verified ·
1 Parent(s): bde3a6d

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +16 -7
  2. app.py +273 -0
  3. osha_1926_corpus.jsonl +0 -0
  4. requirements.txt +7 -0
README.md CHANGED
@@ -1,13 +1,22 @@
1
  ---
2
- title: Construction Code Cite
3
- emoji: 🌍
4
- colorFrom: purple
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.19.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: Construction Code-Citation Model
3
+ emoji: 🏗️
4
+ colorFrom: blue
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 4.44.1
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: Qwen 2.5 1.5B + LoRA for OSHA hazard + code citation
12
  ---
13
 
14
+ # Construction Code-Citation Model
15
+
16
+ Fine-tuned Qwen 2.5 1.5B for construction-safety incident classification and OSHA 29 CFR 1926 citation grounding.
17
+
18
+ - **Base:** [Qwen/Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct)
19
+ - **Adapter:** [rigidhat/qwen-2.5-construction-codecite-v1](https://huggingface.co/rigidhat/qwen-2.5-construction-codecite-v1)
20
+ - **Dataset:** [rigidhat/construction-code-corpus-v1](https://huggingface.co/datasets/rigidhat/construction-code-corpus-v1)
21
+
22
+ Built for the [Adaption Labs AutoScientist Challenge](https://adaptionlabs.ai/auto-scientist), "All Other Domains" category. Credit to Adaptive Data by Adaption.
app.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF Space demo for construction-code-cite (v1.1 LoRA).
2
+
3
+ Uses transformers + peft (not mlx-lm) so it runs on Space Linux CPU.
4
+ Fetches the OSHA 1926 corpus at cold boot from the public HF dataset,
5
+ loads the LoRA adapter on top of Qwen 2.5 1.5B-Instruct, and serves
6
+ strict-JSON hazard + citation predictions through Gradio.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import re
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import gradio as gr
17
+
18
+ BASE_MODEL = os.environ.get("BASE_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
19
+ ADAPTER_REPO = os.environ.get("ADAPTER_REPO", "rigidhat/qwen-2.5-construction-codecite-v1")
20
+ DATASET_REPO = os.environ.get("DATASET_REPO", "rigidhat/construction-code-corpus-v1")
21
+ MAX_NEW_TOKENS = 384
22
+ RAG_K = 5
23
+
24
+ CORPUS_PATH = Path(__file__).parent / "osha_1926_corpus.jsonl"
25
+
26
+
27
+ def ensure_corpus() -> Path:
28
+ if CORPUS_PATH.exists():
29
+ return CORPUS_PATH
30
+ from huggingface_hub import hf_hub_download
31
+
32
+ downloaded = hf_hub_download(
33
+ repo_id=DATASET_REPO,
34
+ filename="osha_1926_corpus.jsonl",
35
+ repo_type="dataset",
36
+ )
37
+ Path(downloaded).replace(CORPUS_PATH)
38
+ return CORPUS_PATH
39
+
40
+
41
+ _STANDARD_RE = re.compile(r"1926(?:\.\d+[A-Za-z]?)(?:\([a-zA-Z0-9ivxIVX]+\))*")
42
+
43
+
44
+ def build_verifier(corpus_path: Path):
45
+ sections: dict[str, dict] = {}
46
+ with corpus_path.open("r", encoding="utf-8") as fh:
47
+ for line in fh:
48
+ rec = json.loads(line)
49
+ cite = (rec.get("citation") or "").strip()
50
+ match = _STANDARD_RE.search(cite)
51
+ if match:
52
+ section = match.group(0).split("(")[0]
53
+ sections[section] = rec
54
+
55
+ def verify(raw: str) -> tuple[bool, str]:
56
+ match = _STANDARD_RE.search(raw or "")
57
+ if not match:
58
+ return False, ""
59
+ section = match.group(0).split("(")[0]
60
+ rec = sections.get(section)
61
+ if not rec:
62
+ return False, ""
63
+ return True, rec.get("heading") or ""
64
+
65
+ return verify
66
+
67
+
68
+ def build_bm25(corpus_path: Path):
69
+ from rank_bm25 import BM25Okapi
70
+
71
+ token_re = re.compile(r"[a-zA-Z][a-zA-Z\-]+|\d+")
72
+ stopwords = frozenset(
73
+ "the a an and or but of in on for to with at by from as is are be been being "
74
+ "this that these those it its which who whom whose what when where why how".split()
75
+ )
76
+
77
+ def tok(text: str) -> list[str]:
78
+ return [t.lower() for t in token_re.findall(text or "") if t.lower() not in stopwords]
79
+
80
+ records: list[dict] = []
81
+ tokens: list[list[str]] = []
82
+ with corpus_path.open("r", encoding="utf-8") as fh:
83
+ for line in fh:
84
+ rec = json.loads(line)
85
+ records.append(rec)
86
+ tokens.append(tok(f"{rec.get('heading', '')}\n{rec.get('text', '')}"))
87
+ bm25 = BM25Okapi(tokens)
88
+
89
+ def search(query: str, k: int = RAG_K):
90
+ query_tokens = tok(query)
91
+ if not query_tokens:
92
+ return []
93
+ scores = bm25.get_scores(query_tokens)
94
+ import numpy as np
95
+
96
+ top = np.argpartition(scores, -k)[-k:]
97
+ order = sorted(top, key=lambda i: scores[i], reverse=True)
98
+ hits = []
99
+ for i in order[:k]:
100
+ rec = records[i]
101
+ hits.append({
102
+ "section": rec.get("citation") or "",
103
+ "heading": rec.get("heading") or "",
104
+ "bm25": float(scores[i]),
105
+ })
106
+ return hits
107
+
108
+ return search
109
+
110
+
111
+ PROMPT = """You are an OSHA-trained construction-safety classifier.
112
+
113
+ Output STRICT JSON only with this shape (no prose, no markdown):
114
+ {{"hazards":[{{"code_event":{{"id":"<OIICS event id>","title":"<short>"}},
115
+ "code_source":{{"id":"<OIICS source id>","title":"<short>"}},
116
+ "code_nature":{{"id":"<OIICS nature id>","title":"<short>"}},
117
+ "code_body":{{"id":"<OIICS body id>","title":"<short>"}},
118
+ "severity":"low|moderate|high"}}],
119
+ "citations":[{{"standard":"<1926.X>","section_heading":"<heading>"}}]}}
120
+
121
+ OIICS code IDs are short numeric strings (1-4 digits). Use "OTHER" only when
122
+ no specific code applies. Cite 0-3 OSHA 1926 sections from the candidate list
123
+ below if any apply.
124
+
125
+ Retrieved OSHA 1926 sections (BM25-ranked candidates):
126
+ {candidates}
127
+
128
+ Incident narrative:
129
+ \"\"\"{narrative}\"\"\"
130
+
131
+ JSON:"""
132
+
133
+
134
+ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
135
+
136
+
137
+ def parse_json(raw: str) -> dict:
138
+ if not raw:
139
+ return {}
140
+ match = _JSON_RE.search(raw)
141
+ if not match:
142
+ return {}
143
+ snippet = match.group(0)
144
+ try:
145
+ return json.loads(snippet)
146
+ except json.JSONDecodeError:
147
+ last = snippet.rfind("}")
148
+ if last != -1:
149
+ try:
150
+ return json.loads(snippet[: last + 1])
151
+ except json.JSONDecodeError:
152
+ pass
153
+ return {}
154
+
155
+
156
+ _PIPELINE = None
157
+
158
+
159
+ def get_pipeline():
160
+ global _PIPELINE
161
+ if _PIPELINE is not None:
162
+ return _PIPELINE
163
+ corpus = ensure_corpus()
164
+ verify = build_verifier(corpus)
165
+ search = build_bm25(corpus)
166
+
167
+ print(f"Loading base model: {BASE_MODEL}")
168
+ import torch
169
+ from peft import PeftModel
170
+ from transformers import AutoModelForCausalLM, AutoTokenizer
171
+
172
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
173
+ base = AutoModelForCausalLM.from_pretrained(
174
+ BASE_MODEL, torch_dtype=torch.float32, trust_remote_code=True
175
+ )
176
+ print(f"Loading LoRA adapter: {ADAPTER_REPO}")
177
+ model = PeftModel.from_pretrained(base, ADAPTER_REPO)
178
+
179
+ _PIPELINE = {
180
+ "tokenizer": tokenizer,
181
+ "model": model,
182
+ "search": search,
183
+ "verify": verify,
184
+ "torch": torch,
185
+ }
186
+ return _PIPELINE
187
+
188
+
189
+ def format_candidates(hits) -> str:
190
+ if not hits:
191
+ return "(no high-confidence candidates)"
192
+ return "\n".join(f"- {h['section']}: {h['heading'][:80]}" for h in hits)
193
+
194
+
195
+ def predict(narrative: str):
196
+ if not (narrative or "").strip():
197
+ return "{}", "(paste an incident narrative first)", "—"
198
+ t0 = time.time()
199
+ pipe = get_pipeline()
200
+ hits = pipe["search"](narrative, k=RAG_K)
201
+ prompt = PROMPT.format(candidates=format_candidates(hits), narrative=narrative[:1800])
202
+
203
+ messages = [{"role": "user", "content": prompt}]
204
+ chat = pipe["tokenizer"].apply_chat_template(
205
+ messages, add_generation_prompt=True, tokenize=False
206
+ )
207
+ inputs = pipe["tokenizer"](chat, return_tensors="pt")
208
+
209
+ with pipe["torch"].no_grad():
210
+ out = pipe["model"].generate(
211
+ **inputs,
212
+ max_new_tokens=MAX_NEW_TOKENS,
213
+ do_sample=False,
214
+ pad_token_id=pipe["tokenizer"].eos_token_id,
215
+ )
216
+ generated = out[0][inputs["input_ids"].shape[1]:]
217
+ raw = pipe["tokenizer"].decode(generated, skip_special_tokens=True)
218
+ parsed = parse_json(raw)
219
+
220
+ if parsed and "citations" in parsed:
221
+ for c in parsed["citations"]:
222
+ is_valid, heading = pipe["verify"](c.get("standard", ""))
223
+ c["verified"] = is_valid
224
+ if heading and not c.get("section_heading"):
225
+ c["section_heading"] = heading
226
+
227
+ rag_view = "\n".join(f"- {h['section']}: {h['heading'][:80]}" for h in hits)
228
+ return json.dumps(parsed, indent=2), rag_view, f"{time.time() - t0:.2f}s"
229
+
230
+
231
+ EXAMPLES = [
232
+ "Worker fell from second-story scaffold platform while installing siding, sustained multiple fractures.",
233
+ "Employee's hand was caught between two pieces of trench shoring equipment causing partial amputation of two fingers.",
234
+ "Electrician contacted overhead power line while operating boom lift on a commercial roofing project.",
235
+ ]
236
+
237
+
238
+ with gr.Blocks(title="Construction Code-Citation") as demo:
239
+ gr.Markdown("# Construction Code-Citation Model")
240
+ gr.Markdown(
241
+ "Qwen 2.5 1.5B fine-tuned on OSHA Severe Injury Reports for the "
242
+ "[AutoScientist Challenge](https://adaptionlabs.ai/auto-scientist) "
243
+ "\"All Other Domains\" category. Given a construction-site incident "
244
+ "narrative, returns strict JSON with OIICS hazard codes plus verified "
245
+ "OSHA 29 CFR 1926 citations. First request downloads the base model "
246
+ "(~3 GB, one-time)."
247
+ )
248
+ with gr.Row():
249
+ with gr.Column():
250
+ narrative = gr.Textbox(
251
+ label="Incident narrative",
252
+ lines=5,
253
+ placeholder="Describe the construction-site incident...",
254
+ )
255
+ submit = gr.Button("Classify", variant="primary")
256
+ gr.Examples(EXAMPLES, inputs=narrative)
257
+ with gr.Column():
258
+ output_json = gr.Code(label="Hazards + Citations (JSON)", language="json")
259
+ rag_view = gr.Textbox(label="OSHA 1926 RAG candidates (BM25)", lines=6)
260
+ elapsed = gr.Textbox(label="Latency", lines=1)
261
+
262
+ submit.click(predict, inputs=[narrative], outputs=[output_json, rag_view, elapsed])
263
+
264
+ gr.Markdown(
265
+ "**Artifacts:** "
266
+ "[Dataset](https://huggingface.co/datasets/rigidhat/construction-code-corpus-v1) · "
267
+ "[Model](https://huggingface.co/rigidhat/qwen-2.5-construction-codecite-v1) · "
268
+ "[Source](https://github.com/snakezilla/construction-code-llm)"
269
+ )
270
+
271
+
272
+ if __name__ == "__main__":
273
+ demo.launch()
osha_1926_corpus.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ transformers>=4.46
3
+ peft>=0.13
4
+ torch>=2.4
5
+ huggingface_hub>=0.25
6
+ rank-bm25>=0.2.2
7
+ numpy