Spaces:
Sleeping
Sleeping
Mark Cuizon commited on
Commit ·
cf264e0
1
Parent(s): d68f8af
fix: structured OCR follows raw tables/text for soil and leaf
Browse filesPrefer 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.
- 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 |
-
|
| 540 |
-
if not
|
| 541 |
continue
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
# For leaf data, use 'L' or 'P' prefix format
|
| 547 |
if container_type == 'leaf':
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
pass # Keep as is (e.g., P220/25)
|
| 551 |
elif sample_id.startswith(('L', 'P')) and sample_id[1:].isdigit():
|
| 552 |
-
|
| 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 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
sample_id = f'S{
|
| 568 |
elif sample_id.isdigit():
|
| 569 |
sample_id = f'S{int(sample_id):03d}'
|
| 570 |
-
|
| 571 |
-
|
| 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('
|
| 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
|
| 1352 |
-
"""
|
| 1353 |
-
|
| 1354 |
-
|
| 1355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
"""
|
| 1357 |
if not numbers or not params:
|
| 1358 |
return {}
|
| 1359 |
|
| 1360 |
nums = list(numbers)
|
| 1361 |
-
# Drop
|
| 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) >
|
| 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 |
-
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
|
| 1377 |
-
|
| 1378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1379 |
|
| 1380 |
sample_data = {}
|
| 1381 |
for i, param in enumerate(params):
|
| 1382 |
-
if i < len(
|
| 1383 |
-
sample_data[param] =
|
| 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 =
|
| 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 |
-
|
| 1778 |
-
|
| 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 |
-
|
| 1874 |
-
|
| 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 |
-
|
| 1944 |
-
|
| 1945 |
-
|
| 1946 |
-
if not
|
| 1947 |
continue
|
| 1948 |
-
|
| 1949 |
-
|
| 1950 |
-
|
| 1951 |
-
|
| 1952 |
-
|
| 1953 |
-
|
| 1954 |
-
|
| 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 |
-
|
| 1965 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1966 |
if not samples_data:
|
| 1967 |
return {}
|
| 1968 |
-
|
| 1969 |
ids = list(samples_data.keys())
|
| 1970 |
-
is_sp = any('/' in str(i) for i in ids)
|
| 1971 |
-
|
|
|
|
|
|
|
| 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
|
| 1979 |
-
"""
|
| 1980 |
|
| 1981 |
-
|
|
|
|
| 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 |
-
|
| 1989 |
-
|
| 1990 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1991 |
elif text_samples and not table_samples:
|
| 1992 |
-
|
| 1993 |
-
|
| 1994 |
-
|
| 1995 |
-
|
| 1996 |
-
|
| 1997 |
-
|
| 1998 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1999 |
else:
|
| 2000 |
return text_struct or table_struct or {}
|
| 2001 |
|
| 2002 |
-
|
| 2003 |
-
|
| 2004 |
-
|
| 2005 |
-
|
| 2006 |
-
|
| 2007 |
-
|
| 2008 |
-
|
| 2009 |
-
|
| 2010 |
-
|
| 2011 |
-
|
| 2012 |
-
|
| 2013 |
-
|
| 2014 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2021 |
|
| 2022 |
-
|
|
|
|
| 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 =
|
| 2032 |
structured_data, table_struct, raw_text, container_type
|
| 2033 |
)
|
| 2034 |
except Exception:
|
| 2035 |
pass
|
| 2036 |
|
| 2037 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:')}**")
|