Mark Cuizon commited on
Commit
cf264e0
·
1 Parent(s): d68f8af

fix: structured OCR follows raw tables/text for soil and leaf

Browse files

Prefer header-mapped Document AI/Excel tables over shifted positional text parses. Sliding-window alignment fixes leaf N/P column shifts and soil leading junk. Flatten nested leaf Dry Matter structures; preserve SP Lab sample IDs.

Files changed (1) hide show
  1. modules/upload.py +399 -174
modules/upload.py CHANGED
@@ -536,76 +536,37 @@ def show_ocr_preview(file, file_type: str, container_type: str) -> None:
536
  samples_data = {}
537
 
538
  for sample in samples:
539
- sample_id = sample.get('sample_id', sample.get('Sample No.', sample.get('Lab No.', '')))
540
- if not sample_id:
541
  continue
542
-
543
- # Ensure sample_id is a string
544
- sample_id = str(sample_id) if sample_id is not None else ''
545
-
546
- # For leaf data, use 'L' or 'P' prefix format
547
  if container_type == 'leaf':
548
- # Keep original format if it starts with P (SP Lab format)
549
- if sample_id.startswith('P') and '/' in sample_id:
550
- pass # Keep as is (e.g., P220/25)
551
  elif sample_id.startswith(('L', 'P')) and sample_id[1:].isdigit():
552
- sample_num = int(sample_id[1:])
553
- sample_id = f'L{sample_num:03d}'
554
  elif sample_id.isdigit():
555
  sample_id = f'L{int(sample_id):03d}'
556
- else:
557
- # Try to extract number from sample_id
558
- num_match = re.search(r'(\d+)', sample_id)
559
- if num_match:
560
- sample_id = f'L{int(num_match.group(1)):03d}'
561
- else:
562
- continue
563
  else:
564
- # For soil data, use 'S' prefix
565
- if sample_id.startswith('S') and sample_id[1:].isdigit():
566
- sample_num = int(sample_id[1:])
567
- sample_id = f'S{sample_num:03d}'
568
  elif sample_id.isdigit():
569
  sample_id = f'S{int(sample_id):03d}'
570
- else:
571
- # Try to extract number from sample_id
572
- num_match = re.search(r'(\d+)', sample_id)
573
- if num_match:
574
- sample_id = f'S{int(num_match.group(1)):03d}'
575
- else:
576
- continue
577
-
578
- # Extract sample data (exclude metadata keys)
579
- sample_data = {}
580
- for key, value in sample.items():
581
- if key not in ['sample_id', 'Sample No.', 'Lab No.', 'sample_no', 'lab_no'] and value is not None:
582
- try:
583
- # Try to convert to float if numeric
584
- if isinstance(value, (int, float)):
585
- sample_data[key] = float(value)
586
- elif isinstance(value, str):
587
- # Try to parse numeric strings
588
- cleaned = re.sub(r'[^\d\.\-]', '', value)
589
- if cleaned:
590
- sample_data[key] = float(cleaned)
591
- else:
592
- sample_data[key] = value
593
- else:
594
- sample_data[key] = value
595
- except (ValueError, TypeError):
596
- sample_data[key] = value
597
-
598
- if sample_data:
599
- samples_data[sample_id] = sample_data
600
 
601
- # Determine container key
602
  if container_type == 'soil':
603
- if any('SP' in str(sid).upper() or 'LAB' in str(sid).upper() for sid in samples_data.keys()):
604
  container_key = "SP_Lab_Test_Report"
605
  else:
606
  container_key = "Farm_Soil_Test_Data"
607
  else:
608
- if any('P' in str(sid) and '/' in str(sid) for sid in samples_data.keys()):
609
  container_key = "SP_Lab_Test_Report"
610
  else:
611
  container_key = "Farm_Leaf_Test_Data"
@@ -1348,42 +1309,177 @@ def _parse_numeric_token(token: str):
1348
  return None
1349
 
1350
 
1351
- def _align_soil_numbers_to_params(numbers: list, params: list, sample_id: str = '') -> dict:
1352
- """Map a number list onto soil/leaf params.
1353
-
1354
- SP Lab OCR often injects leading junk before pH (lab serial, year, page no.).
1355
- For soil, pH is first and must fall in a realistic range (~2.5–9.5).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1356
  """
1357
  if not numbers or not params:
1358
  return {}
1359
 
1360
  nums = list(numbers)
1361
- # Drop numbers that are clearly the lab/year digits already in the sample ID
1362
  if sample_id:
1363
  id_digits = re.findall(r'\d+', sample_id)
1364
- while nums and any(abs(nums[0] - float(d)) < 1e-9 for d in id_digits if float(d) > 9):
1365
- # Only strip large ID parts (e.g. 218), not small year-like 25 if it could be Avail P
1366
  if nums[0] >= 10:
1367
  nums = nums[1:]
1368
  else:
1369
  break
1370
 
1371
- # Soil: first param is pH — skip leading values outside soil pH range
1372
- if params and params[0] == 'pH':
1373
- start = 0
1374
- while start < len(nums) and not (2.5 <= nums[start] <= 9.5):
1375
- start += 1
1376
- if start < len(nums):
1377
- nums = nums[start:]
1378
- # If still no plausible pH, keep original (better partial than empty)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1379
 
1380
  sample_data = {}
1381
  for i, param in enumerate(params):
1382
- if i < len(nums):
1383
- sample_data[param] = nums[i]
1384
  return sample_data
1385
 
1386
 
 
 
 
 
 
1387
  def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: list) -> dict:
1388
  """
1389
  Parse SP Lab samples (e.g. S218/25 or P220/25) from real OCR text.
@@ -1540,6 +1636,8 @@ def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: lis
1540
  else:
1541
  return transposed
1542
 
 
 
1543
  def _parse_between_ids(source_text: str) -> dict:
1544
  """Split source on sample IDs and map numbers after each ID to params."""
1545
  found = {}
@@ -1554,7 +1652,7 @@ def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: lis
1554
  tokens = re.split(r'\s+', chunk.strip()) if chunk.strip() else []
1555
  numbers = _tokens_to_numbers(tokens)
1556
  if len(numbers) >= min_needed:
1557
- sample_data = _align_soil_numbers_to_params(numbers, params, sample_id)
1558
  if sample_data:
1559
  found[sample_id] = sample_data
1560
  return found
@@ -1729,17 +1827,10 @@ def format_raw_text_as_structured_json(raw_text: str, container_type: str) -> di
1729
  for match in matches[:12]:
1730
  sample_num, sample_values = match
1731
  sample_id = f"S{sample_num.zfill(3)}"
1732
- numbers = re.findall(r'(\d+\.?\d*)', sample_values)
1733
 
1734
  if len(numbers) >= 3:
1735
- sample_data = {}
1736
- for i, param in enumerate(soil_params):
1737
- if i < len(numbers):
1738
- try:
1739
- sample_data[param] = float(numbers[i])
1740
- except (ValueError, TypeError):
1741
- sample_data[param] = 0.0
1742
-
1743
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1744
  samples_data[sample_id] = sample_data
1745
 
@@ -1751,36 +1842,23 @@ def format_raw_text_as_structured_json(raw_text: str, container_type: str) -> di
1751
  for match in matches[:12]:
1752
  sample_num, sample_values = match
1753
  sample_id = f"S{sample_num.zfill(3)}"
1754
- numbers = re.findall(r'(\d+\.?\d*)', sample_values)
1755
 
1756
  if len(numbers) >= 3:
1757
- sample_data = {}
1758
- for i, param in enumerate(soil_params):
1759
- if i < len(numbers):
1760
- try:
1761
- sample_data[param] = float(numbers[i])
1762
- except (ValueError, TypeError):
1763
- sample_data[param] = 0.0
1764
-
1765
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1766
  samples_data[sample_id] = sample_data
1767
 
1768
  # Strategy 4: Chunk all numbers into groups of 9 (farm tabular dump only)
1769
  if not samples_data and not is_sp_soil:
1770
- all_numbers = re.findall(r'(\d+\.?\d*)', all_text)
1771
  if len(all_numbers) >= 27:
1772
  num_samples = min(12, len(all_numbers) // 9)
1773
  for i in range(num_samples):
1774
  sample_id = f"S{i+1:03d}"
1775
- sample_data = {}
1776
  start_idx = i * 9
1777
- for j, param in enumerate(soil_params):
1778
- if start_idx + j < len(all_numbers):
1779
- try:
1780
- sample_data[param] = float(all_numbers[start_idx + j])
1781
- except (ValueError, TypeError):
1782
- sample_data[param] = 0.0
1783
-
1784
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1785
  samples_data[sample_id] = sample_data
1786
 
@@ -1847,36 +1925,23 @@ def format_raw_text_as_structured_json(raw_text: str, container_type: str) -> di
1847
  break
1848
  sample_num, sample_values = match
1849
  sample_id = f"L{str(sample_num).zfill(3)}"
1850
- numbers = re.findall(r'(\d+\.?\d*)', sample_values)
1851
 
1852
  if len(numbers) >= 5:
1853
- sample_data = {}
1854
- for i, param in enumerate(leaf_params):
1855
- if i < len(numbers):
1856
- try:
1857
- sample_data[param] = float(numbers[i])
1858
- except (ValueError, TypeError):
1859
- sample_data[param] = 0.0
1860
-
1861
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1862
  samples_data[sample_id] = sample_data
1863
  processed_count += 1
1864
 
1865
  # Tabular number chunking for farm leaf only
1866
  if not samples_data and not is_sp_leaf:
1867
- all_numbers = re.findall(r'(\d+\.?\d*)', all_text)
1868
  if len(all_numbers) >= 40:
1869
  for i in range(min(12, len(all_numbers) // 8)):
1870
  sample_id = f"L{i+1:03d}"
1871
- sample_data = {}
1872
  start_idx = i * 8
1873
- for j, param in enumerate(leaf_params):
1874
- if start_idx + j < len(all_numbers):
1875
- try:
1876
- sample_data[param] = float(all_numbers[start_idx + j])
1877
- except (ValueError, TypeError):
1878
- sample_data[param] = 0.0
1879
-
1880
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1881
  samples_data[sample_id] = sample_data
1882
 
@@ -1933,6 +1998,129 @@ def format_raw_text_as_structured_json(raw_text: str, container_type: str) -> di
1933
  return {"Farm_Leaf_Test_Data": {}}
1934
 
1935
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1936
  def _structured_from_ocr_tables(ocr_result: dict, container_type: str) -> dict:
1937
  """Build structured samples from Document AI / OCR table objects when present."""
1938
  if not ocr_result:
@@ -1940,101 +2128,138 @@ def _structured_from_ocr_tables(ocr_result: dict, container_type: str) -> dict:
1940
  tables = ocr_result.get('tables') or []
1941
  samples_data = {}
1942
  for table in tables:
1943
- if str(table.get('type', '')).lower() != container_type.lower():
1944
- continue
1945
- for sample in table.get('samples') or []:
1946
- if not isinstance(sample, dict):
1947
  continue
1948
- sample_id = sample.get('sample_id') or sample.get('Sample ID') or sample.get('Lab No.')
1949
- data = sample.get('data') if isinstance(sample.get('data'), dict) else None
1950
- if data is None:
1951
- data = {
1952
- k: v for k, v in sample.items()
1953
- if k not in ('sample_id', 'Sample ID', 'Lab No.', 'sample_no', 'lab_no', 'data')
1954
- and v is not None
1955
- }
1956
- if sample_id and data:
1957
- samples_data[str(sample_id)] = data
1958
  if not samples_data:
1959
- # extraction_details SP_Lab_Test_Report path
1960
  details = (ocr_result.get('raw_data') or {}).get('extraction_details') or {}
1961
- for key in ('SP_Lab_Test_Report', 'Farm_Soil_Test_Data', 'Farm_3_Soil_Test_Data', 'Farm_Leaf_Test_Data'):
1962
  block = details.get(key)
1963
- if isinstance(block, dict) and block:
1964
- samples_data = block
1965
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1966
  if not samples_data:
1967
  return {}
1968
- # Normalize key
1969
  ids = list(samples_data.keys())
1970
- is_sp = any('/' in str(i) for i in ids)
1971
- if is_sp or 'SP LAB' in str(ocr_result.get('raw_data', {}).get('text', '')).upper():
 
 
1972
  return {"SP_Lab_Test_Report": samples_data}
1973
  if container_type.lower() == 'soil':
1974
  return {"Farm_Soil_Test_Data": samples_data}
1975
  return {"Farm_Leaf_Test_Data": samples_data}
1976
 
1977
 
1978
- def _prefer_text_structured(text_struct: dict, table_struct: dict, raw_text: str, container_type: str) -> dict:
1979
- """Prefer raw-text parse; merge table data only when text is missing samples/params.
1980
 
1981
- Always re-apply pH from raw text for soil SP Lab samples so structured matches Raw OCR.
 
1982
  """
1983
  text_key = next(iter(text_struct), None) if text_struct else None
1984
  table_key = next(iter(table_struct), None) if table_struct else None
1985
  text_samples = (text_struct or {}).get(text_key or '', {}) or {}
1986
  table_samples = (table_struct or {}).get(table_key or '', {}) or {}
1987
 
1988
- if not text_samples and table_samples:
1989
- chosen_key = table_key or text_key or 'SP_Lab_Test_Report'
1990
- merged = dict(table_samples)
 
 
 
 
1991
  elif text_samples and not table_samples:
1992
- chosen_key = text_key or 'SP_Lab_Test_Report'
1993
- merged = dict(text_samples)
1994
- elif text_samples and table_samples:
1995
- # Prefer text for samples it has; fill gaps from tables
1996
- chosen_key = text_key if text_key else table_key
1997
- merged = dict(table_samples)
1998
- merged.update(text_samples) # text wins on overlap
 
 
 
 
 
 
1999
  else:
2000
  return text_struct or table_struct or {}
2001
 
2002
- # Force pH (and full row) consistency from raw OCR for SP Lab soil
2003
- if container_type.lower() == 'soil' and raw_text:
2004
- soil_params = [
2005
- 'pH', 'N (%)', 'Org. C (%)', 'Total P (mg/kg)', 'Avail P (mg/kg)',
2006
- 'Exch. K (meq%)', 'Exch. Ca (meq%)', 'Exch. Mg (meq%)', 'CEC (meq%)',
2007
- ]
2008
- from_text = _extract_sp_lab_samples_from_text(raw_text, 'S', soil_params)
2009
- for sid, data in from_text.items():
2010
- if sid not in merged:
2011
- merged[sid] = data
2012
- else:
2013
- # Text values win — especially pH
2014
- merged[sid] = {**merged[sid], **data}
 
 
 
 
 
 
 
2015
 
2016
  return {chosen_key: merged}
2017
 
2018
 
2019
  def _show_raw_text_as_json(raw_text: str, container_type: str, ocr_result: dict = None) -> None:
2020
- """Display raw extracted text data in structured JSON format.
2021
 
2022
- Structured OCR Data is always derived from Raw OCR Text (never hardcoded samples).
 
2023
  """
2024
- # Primary: parse real OCR text
2025
  structured_data = format_raw_text_as_structured_json(raw_text, container_type)
2026
 
2027
- # Secondary: Document AI tables (may help fill gaps; text wins on conflict)
2028
  try:
2029
  table_struct = _structured_from_ocr_tables(ocr_result or {}, container_type)
2030
  if table_struct:
2031
- structured_data = _prefer_text_structured(
2032
  structured_data, table_struct, raw_text, container_type
2033
  )
2034
  except Exception:
2035
  pass
2036
 
2037
- # Raw Extracted Text Data - display as structured JSON
 
 
 
 
 
 
 
 
2038
  st.markdown(f"### 📝 {t('upload_raw_text', 'Raw Extracted Text Data')}")
2039
  st.markdown(f"#### 📊 {t('upload_structured_data', 'Structured OCR Data (JSON Format)')}")
2040
  st.markdown(f"**{t('upload_ai_analysis_note', 'This data will be used by the AI for analysis. Each sample ID contains its parameter values:')}**")
 
536
  samples_data = {}
537
 
538
  for sample in samples:
539
+ sid, sample_data = _flatten_ocr_sample(sample, container_type)
540
+ if not sid or not sample_data:
541
  continue
542
+ sample_id = sid
543
+
544
+ # Preserve SP Lab IDs (S218/25, P220/25); normalize farm IDs only
 
 
545
  if container_type == 'leaf':
546
+ if re.match(r'^P\d{1,4}/\d{2}$', sample_id, re.I):
547
+ sample_id = sample_id[0].upper() + sample_id[1:]
 
548
  elif sample_id.startswith(('L', 'P')) and sample_id[1:].isdigit():
549
+ sample_id = f'L{int(sample_id[1:]):03d}'
 
550
  elif sample_id.isdigit():
551
  sample_id = f'L{int(sample_id):03d}'
 
 
 
 
 
 
 
552
  else:
553
+ if re.match(r'^S\d{1,4}/\d{2}$', sample_id, re.I):
554
+ sample_id = sample_id[0].upper() + sample_id[1:]
555
+ elif sample_id.startswith('S') and sample_id[1:].isdigit():
556
+ sample_id = f'S{int(sample_id[1:]):03d}'
557
  elif sample_id.isdigit():
558
  sample_id = f'S{int(sample_id):03d}'
559
+
560
+ samples_data[sample_id] = sample_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
 
562
+ # Determine container key (SP Lab if any ID has / or SP/LAB markers)
563
  if container_type == 'soil':
564
+ if any('/' in str(sid) or 'SP' in str(sid).upper() or 'LAB' in str(sid).upper() for sid in samples_data.keys()):
565
  container_key = "SP_Lab_Test_Report"
566
  else:
567
  container_key = "Farm_Soil_Test_Data"
568
  else:
569
+ if any(('/' in str(sid) and str(sid).upper().startswith('P')) for sid in samples_data.keys()):
570
  container_key = "SP_Lab_Test_Report"
571
  else:
572
  container_key = "Farm_Leaf_Test_Data"
 
1309
  return None
1310
 
1311
 
1312
+ def _score_soil_window(window: list) -> float:
1313
+ """Higher = more realistic oil-palm soil lab row (pH, N, OrgC, P, cations, CEC)."""
1314
+ if len(window) < 5:
1315
+ return -100.0
1316
+ score = 0.0
1317
+ ph = window[0]
1318
+ # pH must look like pH
1319
+ if 3.0 <= ph <= 7.5:
1320
+ score += 5.0
1321
+ elif 2.5 <= ph <= 9.0:
1322
+ score += 2.0
1323
+ else:
1324
+ score -= 8.0
1325
+ # N (%) typically 0.03–0.40
1326
+ if len(window) > 1:
1327
+ n = window[1]
1328
+ if 0.02 <= n <= 0.5:
1329
+ score += 3.0
1330
+ elif n > 2:
1331
+ score -= 4.0
1332
+ # Org C (%) typically 0.2–5
1333
+ if len(window) > 2:
1334
+ oc = window[2]
1335
+ if 0.1 <= oc <= 6.0:
1336
+ score += 2.0
1337
+ elif oc > 20:
1338
+ score -= 3.0
1339
+ # Total P mg/kg often 5–400
1340
+ if len(window) > 3:
1341
+ tp = window[3]
1342
+ if 1 <= tp <= 800:
1343
+ score += 2.0
1344
+ elif 0 < tp < 1:
1345
+ score -= 2.0
1346
+ # Avail P
1347
+ if len(window) > 4:
1348
+ ap = window[4]
1349
+ if 0 <= ap <= 80:
1350
+ score += 1.5
1351
+ # Exch cations small
1352
+ for idx in (5, 6, 7):
1353
+ if len(window) > idx and 0 <= window[idx] <= 15:
1354
+ score += 0.5
1355
+ # CEC usually > exch cations and 1–40
1356
+ if len(window) > 8:
1357
+ cec = window[8]
1358
+ if 1 <= cec <= 50:
1359
+ score += 2.0
1360
+ if len(window) > 5 and cec >= window[5]:
1361
+ score += 0.5
1362
+ return score
1363
+
1364
+
1365
+ def _score_leaf_window(window: list) -> float:
1366
+ """Higher = more realistic oil-palm leaf lab row (N,P,K,Mg,Ca,B,Cu,Zn)."""
1367
+ if len(window) < 5:
1368
+ return -100.0
1369
+ score = 0.0
1370
+ n = window[0]
1371
+ # Leaf N % typically ~1.8–3.2
1372
+ if 1.6 <= n <= 3.5:
1373
+ score += 6.0
1374
+ elif 1.2 <= n <= 4.0:
1375
+ score += 3.0
1376
+ else:
1377
+ score -= 8.0
1378
+ # P % typically 0.08–0.25
1379
+ if len(window) > 1:
1380
+ p = window[1]
1381
+ if 0.08 <= p <= 0.30:
1382
+ score += 4.0
1383
+ elif p > 1.0:
1384
+ score -= 5.0 # shifted N sitting in P slot
1385
+ # K % typically 0.4–1.5
1386
+ if len(window) > 2:
1387
+ k = window[2]
1388
+ if 0.35 <= k <= 2.0:
1389
+ score += 2.0
1390
+ elif k > 5:
1391
+ score -= 2.0
1392
+ # Mg, Ca %
1393
+ if len(window) > 3 and 0.05 <= window[3] <= 0.8:
1394
+ score += 1.0
1395
+ if len(window) > 4 and 0.2 <= window[4] <= 1.5:
1396
+ score += 1.0
1397
+ # B mg/kg typically 8–30; Cu 1–10; Zn 5–30
1398
+ if len(window) > 5:
1399
+ b = window[5]
1400
+ if 5 <= b <= 50:
1401
+ score += 3.0
1402
+ elif b < 1:
1403
+ score -= 3.0 # Ca sitting in B slot
1404
+ if len(window) > 6 and 0 <= window[6] <= 15:
1405
+ score += 1.0
1406
+ if len(window) > 7 and 3 <= window[7] <= 40:
1407
+ score += 1.0
1408
+ return score
1409
+
1410
+
1411
+ def _align_numbers_to_params(numbers: list, params: list, sample_id: str = '', kind: str = 'soil') -> dict:
1412
+ """Map OCR number sequence onto params by sliding-window fitness (no hardcoded samples).
1413
+
1414
+ Handles leading junk (lab serial, row index, year) that otherwise shifts all columns —
1415
+ e.g. leaf N=2.13 becoming P because a leading 1.0 was treated as N.
1416
  """
1417
  if not numbers or not params:
1418
  return {}
1419
 
1420
  nums = list(numbers)
1421
+ # Drop large digits that repeat the sample lab number (e.g. 218 from S218/25)
1422
  if sample_id:
1423
  id_digits = re.findall(r'\d+', sample_id)
1424
+ while nums and any(abs(nums[0] - float(d)) < 1e-9 for d in id_digits if float(d) >= 10):
 
1425
  if nums[0] >= 10:
1426
  nums = nums[1:]
1427
  else:
1428
  break
1429
 
1430
+ n_params = len(params)
1431
+ scorer = _score_soil_window if kind == 'soil' else _score_leaf_window
1432
+
1433
+ if len(nums) < 3:
1434
+ return {params[i]: nums[i] for i in range(len(nums))}
1435
+
1436
+ best_score = -1e9
1437
+ best_window = nums[:n_params]
1438
+
1439
+ # Try every alignment window (extra leading/trailing OCR numbers)
1440
+ max_start = max(0, len(nums) - n_params)
1441
+ for start in range(0, max_start + 1):
1442
+ window = nums[start:start + n_params]
1443
+ if len(window) < min(5, n_params):
1444
+ continue
1445
+ # pad short windows
1446
+ while len(window) < n_params:
1447
+ window = window # no pad for scoring; scorer handles short
1448
+ break
1449
+ s = scorer(window)
1450
+ # prefer earlier windows slightly when scores tie
1451
+ s -= start * 0.01
1452
+ if s > best_score:
1453
+ best_score = s
1454
+ best_window = window
1455
+
1456
+ # If best score is still terrible, fall back to first plausible anchor
1457
+ if kind == 'soil' and best_score < 0:
1458
+ for start, val in enumerate(nums):
1459
+ if 2.5 <= val <= 9.5:
1460
+ best_window = nums[start:start + n_params]
1461
+ break
1462
+ elif kind == 'leaf' and best_score < 0:
1463
+ for start, val in enumerate(nums):
1464
+ if 1.5 <= val <= 3.5:
1465
+ # next should look like P if available
1466
+ if start + 1 < len(nums) and nums[start + 1] > 1.0:
1467
+ continue
1468
+ best_window = nums[start:start + n_params]
1469
+ break
1470
 
1471
  sample_data = {}
1472
  for i, param in enumerate(params):
1473
+ if i < len(best_window):
1474
+ sample_data[param] = best_window[i]
1475
  return sample_data
1476
 
1477
 
1478
+ def _align_soil_numbers_to_params(numbers: list, params: list, sample_id: str = '') -> dict:
1479
+ """Backward-compatible soil aligner."""
1480
+ return _align_numbers_to_params(numbers, params, sample_id, kind='soil')
1481
+
1482
+
1483
  def _extract_sp_lab_samples_from_text(text: str, sample_prefix: str, params: list) -> dict:
1484
  """
1485
  Parse SP Lab samples (e.g. S218/25 or P220/25) from real OCR text.
 
1636
  else:
1637
  return transposed
1638
 
1639
+ kind = 'soil' if is_soil else 'leaf'
1640
+
1641
  def _parse_between_ids(source_text: str) -> dict:
1642
  """Split source on sample IDs and map numbers after each ID to params."""
1643
  found = {}
 
1652
  tokens = re.split(r'\s+', chunk.strip()) if chunk.strip() else []
1653
  numbers = _tokens_to_numbers(tokens)
1654
  if len(numbers) >= min_needed:
1655
+ sample_data = _align_numbers_to_params(numbers, params, sample_id, kind=kind)
1656
  if sample_data:
1657
  found[sample_id] = sample_data
1658
  return found
 
1827
  for match in matches[:12]:
1828
  sample_num, sample_values = match
1829
  sample_id = f"S{sample_num.zfill(3)}"
1830
+ numbers = [float(x) for x in re.findall(r'(\d+\.?\d*)', sample_values)]
1831
 
1832
  if len(numbers) >= 3:
1833
+ sample_data = _align_numbers_to_params(numbers, soil_params, sample_id, kind='soil')
 
 
 
 
 
 
 
1834
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1835
  samples_data[sample_id] = sample_data
1836
 
 
1842
  for match in matches[:12]:
1843
  sample_num, sample_values = match
1844
  sample_id = f"S{sample_num.zfill(3)}"
1845
+ numbers = [float(x) for x in re.findall(r'(\d+\.?\d*)', sample_values)]
1846
 
1847
  if len(numbers) >= 3:
1848
+ sample_data = _align_numbers_to_params(numbers, soil_params, sample_id, kind='soil')
 
 
 
 
 
 
 
1849
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1850
  samples_data[sample_id] = sample_data
1851
 
1852
  # Strategy 4: Chunk all numbers into groups of 9 (farm tabular dump only)
1853
  if not samples_data and not is_sp_soil:
1854
+ all_numbers = [float(x) for x in re.findall(r'(\d+\.?\d*)', all_text)]
1855
  if len(all_numbers) >= 27:
1856
  num_samples = min(12, len(all_numbers) // 9)
1857
  for i in range(num_samples):
1858
  sample_id = f"S{i+1:03d}"
 
1859
  start_idx = i * 9
1860
+ chunk = all_numbers[start_idx:start_idx + 9]
1861
+ sample_data = _align_numbers_to_params(chunk, soil_params, sample_id, kind='soil')
 
 
 
 
 
1862
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1863
  samples_data[sample_id] = sample_data
1864
 
 
1925
  break
1926
  sample_num, sample_values = match
1927
  sample_id = f"L{str(sample_num).zfill(3)}"
1928
+ numbers = [float(x) for x in re.findall(r'(\d+\.?\d*)', sample_values)]
1929
 
1930
  if len(numbers) >= 5:
1931
+ sample_data = _align_numbers_to_params(numbers, leaf_params, sample_id, kind='leaf')
 
 
 
 
 
 
 
1932
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1933
  samples_data[sample_id] = sample_data
1934
  processed_count += 1
1935
 
1936
  # Tabular number chunking for farm leaf only
1937
  if not samples_data and not is_sp_leaf:
1938
+ all_numbers = [float(x) for x in re.findall(r'(\d+\.?\d*)', all_text)]
1939
  if len(all_numbers) >= 40:
1940
  for i in range(min(12, len(all_numbers) // 8)):
1941
  sample_id = f"L{i+1:03d}"
 
1942
  start_idx = i * 8
1943
+ chunk = all_numbers[start_idx:start_idx + 8]
1944
+ sample_data = _align_numbers_to_params(chunk, leaf_params, sample_id, kind='leaf')
 
 
 
 
 
1945
  if sample_data and any(v != 0.0 for v in sample_data.values()):
1946
  samples_data[sample_id] = sample_data
1947
 
 
1998
  return {"Farm_Leaf_Test_Data": {}}
1999
 
2000
 
2001
+ def _flatten_ocr_sample(sample: dict, container_type: str) -> tuple:
2002
+ """Return (sample_id, flat_param_dict) from a Document AI / Excel sample object."""
2003
+ if not isinstance(sample, dict):
2004
+ return None, {}
2005
+
2006
+ sample_id = (
2007
+ sample.get('sample_id')
2008
+ or sample.get('Sample ID')
2009
+ or sample.get('Sample No.')
2010
+ or sample.get('Lab No.')
2011
+ or sample.get('lab_no')
2012
+ )
2013
+
2014
+ # Nested soil: {sample_id, data: {...}}
2015
+ data = sample.get('data') if isinstance(sample.get('data'), dict) else None
2016
+
2017
+ # Nested leaf Document AI shape
2018
+ dry = sample.get('% Dry Matter') if isinstance(sample.get('% Dry Matter'), dict) else None
2019
+ mgkg = sample.get('mg/kg Dry Matter') if isinstance(sample.get('mg/kg Dry Matter'), dict) else None
2020
+
2021
+ flat = {}
2022
+ if data:
2023
+ for k, v in data.items():
2024
+ if v is None or isinstance(v, dict):
2025
+ continue
2026
+ num = _parse_numeric_token(v) if not isinstance(v, (int, float)) else float(v)
2027
+ flat[k] = num if num is not None else v
2028
+ elif dry or mgkg:
2029
+ # Flatten leaf nested structure into standard keys
2030
+ key_map_dry = {
2031
+ 'N': 'N (%)', 'P': 'P (%)', 'K': 'K (%)', 'MG': 'Mg (%)', 'Mg': 'Mg (%)',
2032
+ 'CA': 'Ca (%)', 'Ca': 'Ca (%)',
2033
+ }
2034
+ key_map_mg = {
2035
+ 'B': 'B (mg/kg)', 'CU': 'Cu (mg/kg)', 'Cu': 'Cu (mg/kg)',
2036
+ 'ZN': 'Zn (mg/kg)', 'Zn': 'Zn (mg/kg)',
2037
+ }
2038
+ if dry:
2039
+ for k, v in dry.items():
2040
+ out_k = key_map_dry.get(str(k).upper() if len(str(k)) <= 2 else str(k), key_map_dry.get(str(k), None))
2041
+ if not out_k:
2042
+ # try direct
2043
+ ku = str(k).upper()
2044
+ out_k = {'N': 'N (%)', 'P': 'P (%)', 'K': 'K (%)', 'MG': 'Mg (%)', 'CA': 'Ca (%)'}.get(ku)
2045
+ if out_k:
2046
+ num = _parse_numeric_token(v) if not isinstance(v, (int, float)) else float(v)
2047
+ if num is not None:
2048
+ flat[out_k] = num
2049
+ if mgkg:
2050
+ for k, v in mgkg.items():
2051
+ ku = str(k).upper()
2052
+ out_k = {'B': 'B (mg/kg)', 'CU': 'Cu (mg/kg)', 'ZN': 'Zn (mg/kg)'}.get(ku)
2053
+ if out_k:
2054
+ num = _parse_numeric_token(v) if not isinstance(v, (int, float)) else float(v)
2055
+ if num is not None:
2056
+ flat[out_k] = num
2057
+ else:
2058
+ skip = {
2059
+ 'sample_id', 'Sample ID', 'Sample No.', 'Lab No.', 'sample_no', 'lab_no',
2060
+ 'data', '% Dry Matter', 'mg/kg Dry Matter',
2061
+ }
2062
+ for k, v in sample.items():
2063
+ if k in skip or v is None or isinstance(v, dict):
2064
+ continue
2065
+ num = _parse_numeric_token(v) if not isinstance(v, (int, float)) else float(v)
2066
+ flat[k] = num if num is not None else v
2067
+
2068
+ # Normalize common soil param aliases to compact keys
2069
+ if container_type.lower() == 'soil' and flat:
2070
+ alias = {
2071
+ 'Nitrogen (%)': 'N (%)',
2072
+ 'Organic Carbon (%)': 'Org. C (%)',
2073
+ 'Available P (mg/kg)': 'Avail P (mg/kg)',
2074
+ 'C.E.C (meq%)': 'CEC (meq%)',
2075
+ 'CEC (meq/100 g)': 'CEC (meq%)',
2076
+ }
2077
+ flat = {alias.get(k, k): v for k, v in flat.items()}
2078
+
2079
+ return (str(sample_id) if sample_id is not None else None), flat
2080
+
2081
+
2082
+ def _score_structured_samples(samples: dict, container_type: str) -> float:
2083
+ """Score how realistic a structured sample dict is (detect shifted columns)."""
2084
+ if not samples:
2085
+ return -1e9
2086
+ total = 0.0
2087
+ count = 0
2088
+ for data in samples.values():
2089
+ if not isinstance(data, dict) or not data:
2090
+ continue
2091
+ if container_type.lower() == 'soil':
2092
+ order = [
2093
+ 'pH', 'N (%)', 'Org. C (%)', 'Total P (mg/kg)', 'Avail P (mg/kg)',
2094
+ 'Exch. K (meq%)', 'Exch. Ca (meq%)', 'Exch. Mg (meq%)', 'CEC (meq%)',
2095
+ ]
2096
+ # also accept expanded aliases already normalized
2097
+ window = []
2098
+ for p in order:
2099
+ v = data.get(p)
2100
+ if isinstance(v, (int, float)):
2101
+ window.append(float(v))
2102
+ else:
2103
+ break
2104
+ if len(window) >= 5:
2105
+ total += _score_soil_window(window)
2106
+ count += 1
2107
+ else:
2108
+ order = ['N (%)', 'P (%)', 'K (%)', 'Mg (%)', 'Ca (%)', 'B (mg/kg)', 'Cu (mg/kg)', 'Zn (mg/kg)']
2109
+ window = []
2110
+ for p in order:
2111
+ v = data.get(p)
2112
+ if isinstance(v, (int, float)):
2113
+ window.append(float(v))
2114
+ else:
2115
+ break
2116
+ if len(window) >= 5:
2117
+ total += _score_leaf_window(window)
2118
+ count += 1
2119
+ if count == 0:
2120
+ return -1e9
2121
+ return total / count
2122
+
2123
+
2124
  def _structured_from_ocr_tables(ocr_result: dict, container_type: str) -> dict:
2125
  """Build structured samples from Document AI / OCR table objects when present."""
2126
  if not ocr_result:
 
2128
  tables = ocr_result.get('tables') or []
2129
  samples_data = {}
2130
  for table in tables:
2131
+ ttype = str(table.get('type', '')).lower()
2132
+ if ttype not in (container_type.lower(), 'unknown', ''):
2133
+ # still try unknown tables if empty so far
2134
+ if ttype not in ('soil', 'leaf') and samples_data:
2135
  continue
2136
+ if ttype in ('soil', 'leaf') and ttype != container_type.lower():
2137
+ continue
2138
+ for sample in table.get('samples') or []:
2139
+ sid, flat = _flatten_ocr_sample(sample, container_type)
2140
+ if sid and flat:
2141
+ samples_data[sid] = flat
2142
+
 
 
 
2143
  if not samples_data:
 
2144
  details = (ocr_result.get('raw_data') or {}).get('extraction_details') or {}
2145
+ for key in ('SP_Lab_Test_Report', 'Farm_Soil_Test_Data', 'Farm_3_Soil_Test_Data', 'Farm_Leaf_Test_Data', 'leaf_data'):
2146
  block = details.get(key)
2147
+ if isinstance(block, dict) and block.get('samples'):
2148
+ for sample in block['samples']:
2149
+ sid, flat = _flatten_ocr_sample(sample, container_type)
2150
+ if sid and flat:
2151
+ samples_data[sid] = flat
2152
+ elif isinstance(block, dict) and block:
2153
+ # map of sample_id -> data
2154
+ for sid, data in block.items():
2155
+ if sid in ('total_samples', 'sample_ids', 'parameters', 'report_type', 'samples'):
2156
+ continue
2157
+ if isinstance(data, dict):
2158
+ _, flat = _flatten_ocr_sample({'sample_id': sid, **data} if 'data' not in data else {'sample_id': sid, 'data': data.get('data', data)}, container_type)
2159
+ if not flat and isinstance(data, dict):
2160
+ flat = {k: v for k, v in data.items() if not isinstance(v, dict)}
2161
+ if flat:
2162
+ samples_data[str(sid)] = flat
2163
+
2164
  if not samples_data:
2165
  return {}
2166
+
2167
  ids = list(samples_data.keys())
2168
+ is_sp = any('/' in str(i) for i in ids) or 'SP LAB' in str(
2169
+ (ocr_result.get('raw_data') or {}).get('text', '')
2170
+ ).upper()
2171
+ if is_sp:
2172
  return {"SP_Lab_Test_Report": samples_data}
2173
  if container_type.lower() == 'soil':
2174
  return {"Farm_Soil_Test_Data": samples_data}
2175
  return {"Farm_Leaf_Test_Data": samples_data}
2176
 
2177
 
2178
+ def _merge_structured_sources(text_struct: dict, table_struct: dict, raw_text: str, container_type: str) -> dict:
2179
+ """Pick the best structured source so output matches real OCR (tables or text).
2180
 
2181
+ Document AI header-mapped tables usually beat positional text parsing.
2182
+ Bad text parses (shifted columns) must not overwrite good tables.
2183
  """
2184
  text_key = next(iter(text_struct), None) if text_struct else None
2185
  table_key = next(iter(table_struct), None) if table_struct else None
2186
  text_samples = (text_struct or {}).get(text_key or '', {}) or {}
2187
  table_samples = (table_struct or {}).get(table_key or '', {}) or {}
2188
 
2189
+ text_score = _score_structured_samples(text_samples, container_type)
2190
+ table_score = _score_structured_samples(table_samples, container_type)
2191
+
2192
+ # Prefer higher score; if close, prefer more samples
2193
+ prefer_table = False
2194
+ if table_samples and not text_samples:
2195
+ prefer_table = True
2196
  elif text_samples and not table_samples:
2197
+ prefer_table = False
2198
+ elif table_samples and text_samples:
2199
+ if table_score > text_score + 0.5:
2200
+ prefer_table = True
2201
+ elif text_score > table_score + 0.5:
2202
+ prefer_table = False
2203
+ else:
2204
+ # tie-breaker: more complete params / more samples
2205
+ def completeness(samples):
2206
+ if not samples:
2207
+ return 0
2208
+ return sum(len(v) for v in samples.values() if isinstance(v, dict)) / max(len(samples), 1)
2209
+ prefer_table = completeness(table_samples) >= completeness(text_samples)
2210
  else:
2211
  return text_struct or table_struct or {}
2212
 
2213
+ if prefer_table:
2214
+ chosen_key = table_key or text_key or 'SP_Lab_Test_Report'
2215
+ merged = dict(table_samples)
2216
+ # Fill missing samples from text if text score is ok
2217
+ if text_score > 0:
2218
+ for sid, data in text_samples.items():
2219
+ if sid not in merged:
2220
+ merged[sid] = data
2221
+ else:
2222
+ chosen_key = text_key or table_key or 'SP_Lab_Test_Report'
2223
+ merged = dict(text_samples)
2224
+ if table_score > 0:
2225
+ for sid, data in table_samples.items():
2226
+ if sid not in merged:
2227
+ merged[sid] = data
2228
+ else:
2229
+ # fill missing keys from table
2230
+ for k, v in data.items():
2231
+ if k not in merged[sid] or merged[sid][k] in (None, '', 0):
2232
+ merged[sid][k] = v
2233
 
2234
  return {chosen_key: merged}
2235
 
2236
 
2237
  def _show_raw_text_as_json(raw_text: str, container_type: str, ocr_result: dict = None) -> None:
2238
+ """Display structured OCR data that faithfully follows the uploaded document.
2239
 
2240
+ Priority: Document AI / Excel tables (header-mapped) when realistic, else raw-text
2241
+ parse with sliding-window alignment. Never uses hardcoded sample templates.
2242
  """
 
2243
  structured_data = format_raw_text_as_structured_json(raw_text, container_type)
2244
 
 
2245
  try:
2246
  table_struct = _structured_from_ocr_tables(ocr_result or {}, container_type)
2247
  if table_struct:
2248
+ structured_data = _merge_structured_sources(
2249
  structured_data, table_struct, raw_text, container_type
2250
  )
2251
  except Exception:
2252
  pass
2253
 
2254
+ # Final pass: for SP Lab IDs, re-parse raw text and keep the higher-scoring version
2255
+ try:
2256
+ text_only = format_raw_text_as_structured_json(raw_text, container_type)
2257
+ structured_data = _merge_structured_sources(
2258
+ text_only, structured_data, raw_text, container_type
2259
+ )
2260
+ except Exception:
2261
+ pass
2262
+
2263
  st.markdown(f"### 📝 {t('upload_raw_text', 'Raw Extracted Text Data')}")
2264
  st.markdown(f"#### 📊 {t('upload_structured_data', 'Structured OCR Data (JSON Format)')}")
2265
  st.markdown(f"**{t('upload_ai_analysis_note', 'This data will be used by the AI for analysis. Each sample ID contains its parameter values:')}**")