Deploy: SCU_v1 default, Sankey, filter reconciliation, nested-expander fixes, deps
Browse filesSlim deploy on top of the Space's main. Adds the Streamlit feature work and,
critically, the previously-missing runtime deps (stqdm, tqdm, psutil,
transformers, psycopg[binary]) plus the vendored pipeline/ scripts. Fixes the
nested-expander crashes (SCU normalization section + Cramér's pair-comparison).
Excludes frontend_backup/ and the 119 MB cluster-viz HTML (storage limit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .gitignore +9 -6
- .python-version +1 -0
- analyzing.py +145 -95
- api/main.py +45 -0
- api/services/analysis_service.py +59 -0
- api/services/parsing_service.py +100 -1
- config.py +16 -0
- frontend/src/api/client.ts +20 -0
- frontend/src/components/analysis/AnalysisPage.tsx +48 -16
- frontend/src/components/analysis/CramersVExplorer.tsx +64 -11
- frontend/src/components/parsing/ParsingPage.tsx +190 -17
- frontend/src/types/index.ts +24 -0
- parsing.py +149 -36
- pipeline/README.md +43 -0
- pipeline/add_source_agency.py +343 -0
- pipeline/analyze_reports.py +332 -0
- pipeline/audit.py +339 -0
- pipeline/concat_pages.py +223 -0
- pipeline/destamp_pages.py +144 -0
- pipeline/download_uap_pdfs.py +196 -0
- pipeline/extract_reports.py +593 -0
- pipeline/find_missing_concat.py +120 -0
- pipeline/find_ocr_targets.py +175 -0
- pipeline/join_reports.py +124 -0
- pipeline/map_yaml_to_reports.py +222 -0
- pipeline/page_coverage.py +156 -0
- pipeline/pdf_to_reports.py +457 -0
- pipeline/reconcile.py +615 -0
- pipeline/reorganize.py +273 -0
- pipeline/restructure_pages.py +209 -0
- pipeline/run_ocr.py +407 -0
- pipeline/split_pages.py +137 -0
- pipeline/stamp_pages.py +211 -0
- preprocessing.py +66 -33
- pyproject.toml +1 -0
- rag_search.py +62 -0
- requirements.txt +24 -0
- utils/data_processing.py +27 -2
- uv.lock +58 -0
.gitignore
CHANGED
|
@@ -175,14 +175,17 @@ frontend2/dist/
|
|
| 175 |
# Large source PDFs (kept local, too big for git / HF Space)
|
| 176 |
UAP_PDFs/
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
# Windows "downloaded from internet" metadata sidecar files
|
| 179 |
*Zone.Identifier
|
| 180 |
|
| 181 |
-
#
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
# Superseded frontend backup (kept local, not deployed)
|
| 185 |
frontend_backup/
|
| 186 |
-
|
| 187 |
-
# Large generated cluster-viz HTML (regenerated at runtime)
|
| 188 |
frontend/uap_clusters_llm.html
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
# Large source PDFs (kept local, too big for git / HF Space)
|
| 176 |
UAP_PDFs/
|
| 177 |
|
| 178 |
+
# Document Preprocessing working dir (PDF corpus + intermediates) — data, not code.
|
| 179 |
+
# The pipeline *scripts* live in pipeline/ and ARE tracked; the data tree is not.
|
| 180 |
+
pipeline_data/
|
| 181 |
+
|
| 182 |
# Windows "downloaded from internet" metadata sidecar files
|
| 183 |
*Zone.Identifier
|
| 184 |
|
| 185 |
+
# Superseded frontend backup + large generated cluster-viz HTML (kept local,
|
| 186 |
+
# excluded from the HF Space deploy to stay within its storage limit).
|
|
|
|
|
|
|
| 187 |
frontend_backup/
|
|
|
|
|
|
|
| 188 |
frontend/uap_clusters_llm.html
|
| 189 |
+
|
| 190 |
+
# Scratch notebooks
|
| 191 |
+
Untitled.ipynb
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12.3
|
analyzing.py
CHANGED
|
@@ -43,35 +43,7 @@ def load_data(file_path, key='df'):
|
|
| 43 |
return pd.read_hdf(file_path, key=key)
|
| 44 |
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
if question == "":
|
| 49 |
-
question = "Summarize the following data in relevant bullet points"
|
| 50 |
-
|
| 51 |
-
import pathlib
|
| 52 |
-
import textwrap
|
| 53 |
-
|
| 54 |
-
import google.generativeai as genai
|
| 55 |
-
|
| 56 |
-
from IPython.display import display
|
| 57 |
-
from IPython.display import Markdown
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def to_markdown(text):
|
| 61 |
-
text = text.replace('•', ' *')
|
| 62 |
-
return Markdown(textwrap.indent(text, '> ', predicate=lambda _: True))
|
| 63 |
-
|
| 64 |
-
# selected_data is a list
|
| 65 |
-
# remove empty
|
| 66 |
-
|
| 67 |
-
filtered = [str(x) for x in selected_data if str(x) != '' and x is not None]
|
| 68 |
-
# make a string
|
| 69 |
-
context = '\n'.join(filtered)
|
| 70 |
-
|
| 71 |
-
genai.configure(api_key=gemini_key)
|
| 72 |
-
query_model = genai.GenerativeModel('models/gemini-3.1-pro-preview')
|
| 73 |
-
response = query_model.generate_content([f"{question}\n Answer based on this context: {context}\n\n"])
|
| 74 |
-
return(response.text)
|
| 75 |
|
| 76 |
def plot_treemap(df, column, top_n=32):
|
| 77 |
# Get the value counts and the top N labels
|
|
@@ -988,19 +960,26 @@ def render_cramers_v_explorer(df):
|
|
| 988 |
st.markdown("#### Pass 1 — auto-selected categoricals")
|
| 989 |
clicked1 = _interactive_cv_heatmap(cv1, "Cramér's V — Pass 1", "cv_hm1")
|
| 990 |
pairs1, n_excl1 = _cramers_pairs_table(cv1, exclude_trivial=exclude_trivial)
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
st.caption(f":grey[Filtered {n_excl1} trivial pair(s) "
|
| 994 |
-
"(V≈0 null/constant or V≈1 duplicate).]")
|
| 995 |
if not pairs1.empty:
|
| 996 |
-
st.caption("Strongest associations (pass 1):")
|
| 997 |
-
st.dataframe(pairs1.head(25), hide_index=True, use_container_width=True)
|
| 998 |
st.download_button(
|
| 999 |
"⬇️ Download pass-1 pairs (CSV)", pairs1.to_csv(index=False),
|
| 1000 |
"cramers_v_pass1.csv", "text/csv", key="cv_dl_pass1",
|
| 1001 |
)
|
| 1002 |
-
|
| 1003 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1004 |
|
| 1005 |
# ── Pass 2 — refine ────────────────────────────────────────────────
|
| 1006 |
st.divider()
|
|
@@ -1047,19 +1026,136 @@ def render_cramers_v_explorer(df):
|
|
| 1047 |
if cv2 is not None:
|
| 1048 |
clicked2 = _interactive_cv_heatmap(cv2, "Cramér's V — Pass 2 (refined)", "cv_hm2")
|
| 1049 |
pairs2, n_excl2 = _cramers_pairs_table(cv2, exclude_trivial=exclude_trivial)
|
| 1050 |
-
|
| 1051 |
-
if n_excl2:
|
| 1052 |
-
st.caption(f":grey[Filtered {n_excl2} trivial pair(s) "
|
| 1053 |
-
"(V≈0 null/constant or V≈1 duplicate).]")
|
| 1054 |
if not pairs2.empty:
|
| 1055 |
-
st.dataframe(pairs2.head(40), hide_index=True,
|
| 1056 |
-
use_container_width=True)
|
| 1057 |
st.download_button(
|
| 1058 |
"⬇️ Download pass-2 pairs (CSV)", pairs2.to_csv(index=False),
|
| 1059 |
"cramers_v_pass2.csv", "text/csv", key="cv_dl_pass2",
|
| 1060 |
)
|
| 1061 |
-
|
| 1062 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1063 |
|
| 1064 |
|
| 1065 |
def analyze_and_predict(data, analyzers, col_names, clusters):
|
|
@@ -1154,17 +1250,7 @@ else:
|
|
| 1154 |
parsed_responses = filter_dataframe(parsed)
|
| 1155 |
st.session_state['parsed_responses'] = parsed_responses
|
| 1156 |
st.dataframe(parsed_responses)
|
| 1157 |
-
|
| 1158 |
-
with col1:
|
| 1159 |
-
col_parsed = st.selectbox("Which column do you want to query?", st.session_state['parsed_responses'].columns)
|
| 1160 |
-
with col2:
|
| 1161 |
-
GEMINI_KEY = st.text_input('Gemini API Key', value=GEMINI_KEY, type='password', help="Enter your Gemini API key")
|
| 1162 |
-
|
| 1163 |
-
if col_parsed and GEMINI_KEY:
|
| 1164 |
-
selected_column_data = st.session_state['parsed_responses'][col_parsed].tolist()
|
| 1165 |
-
question = st.text_input("Ask a question or leave empty for summarization")
|
| 1166 |
-
if st.button("Generate Query") and selected_column_data:
|
| 1167 |
-
st.write(gemini_query(question, selected_column_data, GEMINI_KEY))
|
| 1168 |
st.session_state['stage'] = 1
|
| 1169 |
|
| 1170 |
# Add enhanced visualization toggle
|
|
@@ -1191,6 +1277,8 @@ enable_tfidf_clusters = st.toggle(
|
|
| 1191 |
if st.session_state['stage'] > 0 and st.session_state.get('parsed_responses') is not None:
|
| 1192 |
with st.expander("🎲 Categorical Association Explorer (Cramér's V)", expanded=False):
|
| 1193 |
render_cramers_v_explorer(st.session_state['parsed_responses'])
|
|
|
|
|
|
|
| 1194 |
|
| 1195 |
if st.session_state['stage'] > 0 :
|
| 1196 |
# ── High-correlation shortcut ──────────────────────────────────────────────
|
|
@@ -1332,45 +1420,7 @@ if st.session_state['stage'] > 0 :
|
|
| 1332 |
st.session_state['analysis_complete'] = True
|
| 1333 |
|
| 1334 |
|
| 1335 |
-
#
|
| 1336 |
-
# if st.session_state['new_data'] is not None:
|
| 1337 |
-
# parsed2 = st.session_state.get('dataset', pd.DataFrame())
|
| 1338 |
-
# parsed2 = filter_dataframe(parsed2)
|
| 1339 |
-
# col1, col2 = st.columns(2)
|
| 1340 |
-
# st.dataframe(parsed2)
|
| 1341 |
-
# with col1:
|
| 1342 |
-
# col_parsed2 = st.selectbox("Which columns do you want to query?", parsed2.columns)
|
| 1343 |
-
# with col2:
|
| 1344 |
-
# GEMINI_KEY = st.text_input('Gemini APIs Key', GEMINI_KEY, type='password', help="Enter your Gemini API key")
|
| 1345 |
-
# if col_parsed and GEMINI_KEY:
|
| 1346 |
-
# selected_column_data2 = parsed2[col_parsed2].tolist()
|
| 1347 |
-
# question2 = st.text_input("Ask a questions or leave empty for summarization")
|
| 1348 |
-
# if st.button("Generate Query") and selected_column_data2:
|
| 1349 |
-
# with st.status(f"Generating Query", expanded=True) as status:
|
| 1350 |
-
# gemini_answer = gemini_query(question2, selected_column_data2, GEMINI_KEY)
|
| 1351 |
-
# st.write(gemini_answer)
|
| 1352 |
-
# st.session_state['gemini_answer'] = gemini_answer
|
| 1353 |
-
|
| 1354 |
-
if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
|
| 1355 |
-
ticked_analysis = st.checkbox('Query Processed Data')
|
| 1356 |
-
if ticked_analysis:
|
| 1357 |
-
if st.session_state['new_data'] is not None:
|
| 1358 |
-
parsed2 = st.session_state.get('dataset', pd.DataFrame()).copy()
|
| 1359 |
-
parsed2 = filter_dataframe(parsed2)
|
| 1360 |
-
col1, col2 = st.columns(2)
|
| 1361 |
-
st.dataframe(parsed2)
|
| 1362 |
-
with col1:
|
| 1363 |
-
col_parsed2 = st.selectbox("Which columns do you want to query?", parsed2.columns)
|
| 1364 |
-
with col2:
|
| 1365 |
-
GEMINI_KEY = st.text_input('Gemini APIs Key', value=GEMINI_KEY, type='password', help="Enter your Gemini API key")
|
| 1366 |
-
if col_parsed2 and GEMINI_KEY:
|
| 1367 |
-
selected_column_data2 = parsed2[col_parsed2].tolist()
|
| 1368 |
-
question2 = st.text_input("Ask a questions or leave empty for summarization")
|
| 1369 |
-
if st.button("Generate Queries") and selected_column_data2:
|
| 1370 |
-
with st.status(f"Generating Query", expanded=True) as status:
|
| 1371 |
-
gemini_answer = gemini_query(question2, selected_column_data2, GEMINI_KEY)
|
| 1372 |
-
st.write(gemini_answer)
|
| 1373 |
-
st.session_state['gemini_answer'] = gemini_answer
|
| 1374 |
|
| 1375 |
# Enhanced visualization section
|
| 1376 |
if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
|
|
|
|
| 43 |
return pd.read_hdf(file_path, key=key)
|
| 44 |
|
| 45 |
|
| 46 |
+
# Gemini Q&A moved to rag_search.py (the RAG search page) — see gemini_query there.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
def plot_treemap(df, column, top_n=32):
|
| 49 |
# Get the value counts and the top N labels
|
|
|
|
| 960 |
st.markdown("#### Pass 1 — auto-selected categoricals")
|
| 961 |
clicked1 = _interactive_cv_heatmap(cv1, "Cramér's V — Pass 1", "cv_hm1")
|
| 962 |
pairs1, n_excl1 = _cramers_pairs_table(cv1, exclude_trivial=exclude_trivial)
|
| 963 |
+
# Keep only the download button in the main flow; tuck the pair
|
| 964 |
+
# comparison drill-down and the ranked table behind a dropdown.
|
|
|
|
|
|
|
| 965 |
if not pairs1.empty:
|
|
|
|
|
|
|
| 966 |
st.download_button(
|
| 967 |
"⬇️ Download pass-1 pairs (CSV)", pairs1.to_csv(index=False),
|
| 968 |
"cramers_v_pass1.csv", "text/csv", key="cv_dl_pass1",
|
| 969 |
)
|
| 970 |
+
# Popover, not an expander: this whole explorer is rendered inside the
|
| 971 |
+
# "Categorical Association Explorer" expander, and Streamlit forbids
|
| 972 |
+
# nesting an expander inside another expander.
|
| 973 |
+
with st.popover("🔎 Pair comparison & ranked table", use_container_width=True):
|
| 974 |
+
_pair_drilldown(df, cv1, pairs1, "cv_dd1", clicked1, drop_missing)
|
| 975 |
+
if n_excl1:
|
| 976 |
+
st.caption(f":grey[Filtered {n_excl1} trivial pair(s) "
|
| 977 |
+
"(V≈0 null/constant or V≈1 duplicate).]")
|
| 978 |
+
if not pairs1.empty:
|
| 979 |
+
st.caption("Strongest associations (pass 1):")
|
| 980 |
+
st.dataframe(pairs1.head(25), hide_index=True, use_container_width=True)
|
| 981 |
+
else:
|
| 982 |
+
st.info("No non-trivial associations found in pass 1.")
|
| 983 |
|
| 984 |
# ── Pass 2 — refine ────────────────────────────────────────────────
|
| 985 |
st.divider()
|
|
|
|
| 1026 |
if cv2 is not None:
|
| 1027 |
clicked2 = _interactive_cv_heatmap(cv2, "Cramér's V — Pass 2 (refined)", "cv_hm2")
|
| 1028 |
pairs2, n_excl2 = _cramers_pairs_table(cv2, exclude_trivial=exclude_trivial)
|
| 1029 |
+
# Same as pass 1: only the download button stays in the main flow.
|
|
|
|
|
|
|
|
|
|
| 1030 |
if not pairs2.empty:
|
|
|
|
|
|
|
| 1031 |
st.download_button(
|
| 1032 |
"⬇️ Download pass-2 pairs (CSV)", pairs2.to_csv(index=False),
|
| 1033 |
"cramers_v_pass2.csv", "text/csv", key="cv_dl_pass2",
|
| 1034 |
)
|
| 1035 |
+
with st.popover("🔎 Pair comparison & ranked table (refined)", use_container_width=True):
|
| 1036 |
+
_pair_drilldown(df, cv2, pairs2, "cv_dd2", clicked2, drop_missing)
|
| 1037 |
+
if n_excl2:
|
| 1038 |
+
st.caption(f":grey[Filtered {n_excl2} trivial pair(s) "
|
| 1039 |
+
"(V≈0 null/constant or V≈1 duplicate).]")
|
| 1040 |
+
if not pairs2.empty:
|
| 1041 |
+
st.dataframe(pairs2.head(40), hide_index=True,
|
| 1042 |
+
use_container_width=True)
|
| 1043 |
+
else:
|
| 1044 |
+
st.info("No non-trivial associations in the refined set.")
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
def render_categorical_flow_sankey(df):
|
| 1048 |
+
"""All-with-all categorical flow Sankey for the Statistical Analysis section.
|
| 1049 |
+
|
| 1050 |
+
Unlike the cross-DB matcher in rag_search.py (which pairs records by
|
| 1051 |
+
similarity), this links every value of each chosen column to every
|
| 1052 |
+
co-occurring value of the next column, with link width = the number of rows
|
| 1053 |
+
sharing that combination (a chained crosstab). No pairwise matching — just
|
| 1054 |
+
all-with-all co-occurrence counts.
|
| 1055 |
+
"""
|
| 1056 |
+
st.markdown("### 🌊 Categorical Flow (Sankey)")
|
| 1057 |
+
st.caption(
|
| 1058 |
+
"Pick two or more categorical columns as ordered levels (left → right). "
|
| 1059 |
+
"Every value is linked to every co-occurring value of the next level, "
|
| 1060 |
+
"with link width = the number of rows sharing that combination — no "
|
| 1061 |
+
"pairwise matching, just all-with-all co-occurrence counts."
|
| 1062 |
+
)
|
| 1063 |
+
|
| 1064 |
+
n = len(df)
|
| 1065 |
+
if n == 0:
|
| 1066 |
+
st.info("No rows to chart.")
|
| 1067 |
+
return
|
| 1068 |
+
|
| 1069 |
+
def _safe_nunique(s):
|
| 1070 |
+
# Columns of dicts/lists are unhashable; fall back to string form.
|
| 1071 |
+
try:
|
| 1072 |
+
return s.nunique(dropna=False)
|
| 1073 |
+
except TypeError:
|
| 1074 |
+
return s.astype(str).nunique(dropna=False)
|
| 1075 |
+
|
| 1076 |
+
cat_like = [
|
| 1077 |
+
c for c in df.columns
|
| 1078 |
+
if 1 < _safe_nunique(df[c]) <= max(50, int(n * 0.5))
|
| 1079 |
+
]
|
| 1080 |
+
if len(cat_like) < 2:
|
| 1081 |
+
st.info("Need at least two categorical-like columns for a flow diagram.")
|
| 1082 |
+
return
|
| 1083 |
+
|
| 1084 |
+
sc1, sc2 = st.columns([3, 1])
|
| 1085 |
+
with sc1:
|
| 1086 |
+
cols = st.multiselect(
|
| 1087 |
+
"Levels (left → right, in order)",
|
| 1088 |
+
options=list(df.columns),
|
| 1089 |
+
default=cat_like[:3],
|
| 1090 |
+
key="sankey_flow_cols",
|
| 1091 |
+
)
|
| 1092 |
+
with sc2:
|
| 1093 |
+
top_n = int(st.number_input(
|
| 1094 |
+
"Top-N / level", min_value=2, max_value=50, value=12, step=1,
|
| 1095 |
+
key="sankey_flow_topn",
|
| 1096 |
+
help="Keep only the most frequent N values per level; the rest are "
|
| 1097 |
+
"lumped into “Other” so the diagram stays readable.",
|
| 1098 |
+
))
|
| 1099 |
+
|
| 1100 |
+
if len(cols) < 2:
|
| 1101 |
+
st.info("Select at least two columns.")
|
| 1102 |
+
return
|
| 1103 |
+
|
| 1104 |
+
# Normalise: string-cast, fill missing, cap to top-N per level (+ “Other”).
|
| 1105 |
+
work = pd.DataFrame(index=df.index)
|
| 1106 |
+
for c in cols:
|
| 1107 |
+
s = df[c].astype(str).where(df[c].notna(), "(missing)")
|
| 1108 |
+
keep = s.value_counts().head(top_n).index
|
| 1109 |
+
work[c] = s.where(s.isin(keep), "Other")
|
| 1110 |
+
|
| 1111 |
+
# Namespace nodes by (level, value) so identical values in different levels
|
| 1112 |
+
# never merge into one node.
|
| 1113 |
+
PALETTE = ["#f97316", "#22c55e", "#3b82f6", "#a855f7", "#ec4899",
|
| 1114 |
+
"#14b8a6", "#eab308", "#ef4444"]
|
| 1115 |
+
index, node_label, node_color = {}, [], []
|
| 1116 |
+
L = len(cols)
|
| 1117 |
+
max_per_level = 1
|
| 1118 |
+
for level, c in enumerate(cols):
|
| 1119 |
+
vc = work[c].value_counts()
|
| 1120 |
+
max_per_level = max(max_per_level, len(vc))
|
| 1121 |
+
for v in vc.index:
|
| 1122 |
+
key = (level, v)
|
| 1123 |
+
if key not in index:
|
| 1124 |
+
index[key] = len(node_label)
|
| 1125 |
+
node_label.append(str(v))
|
| 1126 |
+
node_color.append(PALETTE[level % len(PALETTE)])
|
| 1127 |
+
|
| 1128 |
+
# Links: co-occurrence counts between consecutive levels (the "all with all").
|
| 1129 |
+
srcs, tgts, vals = [], [], []
|
| 1130 |
+
for level in range(L - 1):
|
| 1131 |
+
a, b = cols[level], cols[level + 1]
|
| 1132 |
+
counts = work.groupby([a, b]).size().reset_index(name="value")
|
| 1133 |
+
for _, row in counts.iterrows():
|
| 1134 |
+
srcs.append(index[(level, row[a])])
|
| 1135 |
+
tgts.append(index[(level + 1, row[b])])
|
| 1136 |
+
vals.append(int(row["value"]))
|
| 1137 |
+
|
| 1138 |
+
def _rgba(hex_color, alpha=0.35):
|
| 1139 |
+
h = hex_color.lstrip("#")
|
| 1140 |
+
return f"rgba({int(h[0:2], 16)},{int(h[2:4], 16)},{int(h[4:6], 16)},{alpha})"
|
| 1141 |
+
|
| 1142 |
+
fig = go.Figure(go.Sankey(
|
| 1143 |
+
arrangement="snap",
|
| 1144 |
+
node=dict(label=node_label, color=node_color, pad=14, thickness=18),
|
| 1145 |
+
link=dict(source=srcs, target=tgts, value=vals,
|
| 1146 |
+
color=[_rgba(node_color[s]) for s in srcs]),
|
| 1147 |
+
))
|
| 1148 |
+
fig.update_layout(
|
| 1149 |
+
template="plotly_dark",
|
| 1150 |
+
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
| 1151 |
+
font_size=12, height=max(420, 60 + 24 * max_per_level),
|
| 1152 |
+
margin=dict(l=10, r=10, t=20, b=10),
|
| 1153 |
+
)
|
| 1154 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 1155 |
+
st.caption(
|
| 1156 |
+
f"{' → '.join(cols)} · {len(node_label)} nodes · {len(srcs)} links "
|
| 1157 |
+
"(all-with-all co-occurrence, no matching)."
|
| 1158 |
+
)
|
| 1159 |
|
| 1160 |
|
| 1161 |
def analyze_and_predict(data, analyzers, col_names, clusters):
|
|
|
|
| 1250 |
parsed_responses = filter_dataframe(parsed)
|
| 1251 |
st.session_state['parsed_responses'] = parsed_responses
|
| 1252 |
st.dataframe(parsed_responses)
|
| 1253 |
+
# Gemini Q&A over a column moved to the RAG search page (rag_search.py).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
st.session_state['stage'] = 1
|
| 1255 |
|
| 1256 |
# Add enhanced visualization toggle
|
|
|
|
| 1277 |
if st.session_state['stage'] > 0 and st.session_state.get('parsed_responses') is not None:
|
| 1278 |
with st.expander("🎲 Categorical Association Explorer (Cramér's V)", expanded=False):
|
| 1279 |
render_cramers_v_explorer(st.session_state['parsed_responses'])
|
| 1280 |
+
with st.expander("🌊 Categorical Flow (Sankey)", expanded=False):
|
| 1281 |
+
render_categorical_flow_sankey(st.session_state['parsed_responses'])
|
| 1282 |
|
| 1283 |
if st.session_state['stage'] > 0 :
|
| 1284 |
# ── High-correlation shortcut ──────────────────────────────────────────────
|
|
|
|
| 1420 |
st.session_state['analysis_complete'] = True
|
| 1421 |
|
| 1422 |
|
| 1423 |
+
# Gemini Q&A over processed/analyzed data moved to the RAG search page (rag_search.py).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1424 |
|
| 1425 |
# Enhanced visualization section
|
| 1426 |
if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
|
api/main.py
CHANGED
|
@@ -121,6 +121,13 @@ class SchemaMergeRequest(BaseModel):
|
|
| 121 |
custom_fields: dict | None = None
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
class ParseEstimateRequest(BaseModel):
|
| 125 |
columns: list[str]
|
| 126 |
format_json: str # merged schema template (JSON string)
|
|
@@ -173,6 +180,11 @@ class ColumnGroupsRequest(BaseModel):
|
|
| 173 |
source: str = "dataset" # "dataset" | "parsed"
|
| 174 |
high_threshold: int = 30
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
# ---------------------------------------------------------------------------
|
| 177 |
# Helpers
|
| 178 |
# ---------------------------------------------------------------------------
|
|
@@ -668,6 +680,27 @@ def parse_schema_merge(req: SchemaMergeRequest):
|
|
| 668 |
raise HTTPException(status_code=500, detail=str(e))
|
| 669 |
|
| 670 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
@app.post("/api/parse/upload")
|
| 672 |
async def parse_upload(file: UploadFile = File(...), state: SessionState = Depends(get_session)):
|
| 673 |
"""Upload a raw report dataset to be fed through the LLM extractor."""
|
|
@@ -875,6 +908,18 @@ def analysis_column_groups(req: ColumnGroupsRequest, state: SessionState = Depen
|
|
| 875 |
raise HTTPException(status_code=500, detail=f"Column grouping failed: {e}")
|
| 876 |
|
| 877 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
@app.post("/api/analysis/cramers-v")
|
| 879 |
def analysis_cramers_v(req: CramersVRequest, state: SessionState = Depends(get_session)):
|
| 880 |
df = _assoc_source(state, req.source)
|
|
|
|
| 121 |
custom_fields: dict | None = None
|
| 122 |
|
| 123 |
|
| 124 |
+
class SchemaCoverageRequest(BaseModel):
|
| 125 |
+
labels: list[str]
|
| 126 |
+
custom_fields: dict | None = None
|
| 127 |
+
# Dataset columns to diff against; defaults to the uploaded parse source.
|
| 128 |
+
columns: list[str] | None = None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
class ParseEstimateRequest(BaseModel):
|
| 132 |
columns: list[str]
|
| 133 |
format_json: str # merged schema template (JSON string)
|
|
|
|
| 180 |
source: str = "dataset" # "dataset" | "parsed"
|
| 181 |
high_threshold: int = 30
|
| 182 |
|
| 183 |
+
|
| 184 |
+
class XgboostRequest(BaseModel):
|
| 185 |
+
columns: list[str]
|
| 186 |
+
source: str = "dataset" # "dataset" | "parsed"
|
| 187 |
+
|
| 188 |
# ---------------------------------------------------------------------------
|
| 189 |
# Helpers
|
| 190 |
# ---------------------------------------------------------------------------
|
|
|
|
| 680 |
raise HTTPException(status_code=500, detail=str(e))
|
| 681 |
|
| 682 |
|
| 683 |
+
@app.post("/api/parse/schema-coverage")
|
| 684 |
+
def parse_schema_coverage(req: SchemaCoverageRequest, state: SessionState = Depends(get_session)):
|
| 685 |
+
"""Diff the merged schema's leaf fields against the dataset columns (🟢/🔴
|
| 686 |
+
coverage) and return per-mode extraction schemas (all / missing / database)."""
|
| 687 |
+
cols = req.columns
|
| 688 |
+
if cols is None:
|
| 689 |
+
if state.parse_source_df is None:
|
| 690 |
+
raise HTTPException(
|
| 691 |
+
status_code=400,
|
| 692 |
+
detail="No raw dataset uploaded and no columns provided.",
|
| 693 |
+
)
|
| 694 |
+
cols = list(state.parse_source_df.columns)
|
| 695 |
+
try:
|
| 696 |
+
return parsing_service.schema_coverage_report(req.labels, cols, req.custom_fields)
|
| 697 |
+
except ValueError as e:
|
| 698 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 699 |
+
except Exception as e:
|
| 700 |
+
logger.error(traceback.format_exc())
|
| 701 |
+
raise HTTPException(status_code=500, detail=f"Coverage diff failed: {e}")
|
| 702 |
+
|
| 703 |
+
|
| 704 |
@app.post("/api/parse/upload")
|
| 705 |
async def parse_upload(file: UploadFile = File(...), state: SessionState = Depends(get_session)):
|
| 706 |
"""Upload a raw report dataset to be fed through the LLM extractor."""
|
|
|
|
| 908 |
raise HTTPException(status_code=500, detail=f"Column grouping failed: {e}")
|
| 909 |
|
| 910 |
|
| 911 |
+
@app.post("/api/analysis/xgboost")
|
| 912 |
+
def analysis_xgboost(req: XgboostRequest, state: SessionState = Depends(get_session)):
|
| 913 |
+
"""XGBoost feature importance computed directly on the selected categorical
|
| 914 |
+
columns (no cluster pipeline) — fed by the Cramér's V explorer selection."""
|
| 915 |
+
df = _assoc_source(state, req.source)
|
| 916 |
+
try:
|
| 917 |
+
return analysis_service.xgboost_importance(df, req.columns)
|
| 918 |
+
except Exception as e:
|
| 919 |
+
logger.error(traceback.format_exc())
|
| 920 |
+
raise HTTPException(status_code=500, detail=f"XGBoost feature importance failed: {e}")
|
| 921 |
+
|
| 922 |
+
|
| 923 |
@app.post("/api/analysis/cramers-v")
|
| 924 |
def analysis_cramers_v(req: CramersVRequest, state: SessionState = Depends(get_session)):
|
| 925 |
df = _assoc_source(state, req.source)
|
api/services/analysis_service.py
CHANGED
|
@@ -226,3 +226,62 @@ def contingency(df: pd.DataFrame, c1: str, c2: str, drop_missing: bool = False,
|
|
| 226 |
"v": round(v, 3),
|
| 227 |
"n": int(len(a)),
|
| 228 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
"v": round(v, 3),
|
| 227 |
"n": int(len(a)),
|
| 228 |
}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ── XGBoost feature importance on raw categorical columns ───────────────────
|
| 232 |
+
# Cap on a target column's class count — XGBoost multi:softmax with hundreds of
|
| 233 |
+
# classes is slow and the importances are meaningless. The explorer only feeds
|
| 234 |
+
# binary/low/medium-cardinality columns, so this is just a safety net.
|
| 235 |
+
_XGB_MAX_TARGET_CLASSES = 50
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def xgboost_importance(df: pd.DataFrame, columns: list[str], *,
|
| 239 |
+
test_size: float = 0.2, random_state: int = 42) -> dict:
|
| 240 |
+
"""Per-column XGBoost feature importance computed *directly* on the selected
|
| 241 |
+
raw categorical columns — predict each column from the others and report the
|
| 242 |
+
gain-based importance of every other column, plus the test accuracy.
|
| 243 |
+
|
| 244 |
+
This mirrors ``analyzing.py``'s ``analyze_and_predict`` loop but runs on the
|
| 245 |
+
raw values (the same set used by the Cramér's V explorer) instead of cluster
|
| 246 |
+
labels, so feature importance is available without the embedding/cluster
|
| 247 |
+
pipeline. Returns ``{results: {col: {feature_importance, accuracy}}, ...}``.
|
| 248 |
+
"""
|
| 249 |
+
from sklearn.model_selection import train_test_split
|
| 250 |
+
from uap_analyzer import train_xgboost
|
| 251 |
+
|
| 252 |
+
cols = [c for c in columns if c in df.columns]
|
| 253 |
+
if len(cols) < 2:
|
| 254 |
+
return {
|
| 255 |
+
"results": {}, "columns": cols, "skipped": {},
|
| 256 |
+
"message": "Select at least two categorical columns for feature importance.",
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
# Coalesce missingness the same way Cramér's V does, then category-encode.
|
| 260 |
+
new_data = pd.DataFrame({c: _coalesce(df[c]) for c in cols}).astype("category")
|
| 261 |
+
data_nums = new_data.apply(lambda s: s.cat.codes)
|
| 262 |
+
|
| 263 |
+
results: dict[str, dict] = {}
|
| 264 |
+
skipped: dict[str, str] = {}
|
| 265 |
+
for col in cols:
|
| 266 |
+
n_classes = len(new_data[col].cat.categories)
|
| 267 |
+
if n_classes < 2:
|
| 268 |
+
skipped[col] = "constant column (one class)"
|
| 269 |
+
continue
|
| 270 |
+
if n_classes > _XGB_MAX_TARGET_CLASSES:
|
| 271 |
+
skipped[col] = f"too many classes ({n_classes}) to predict"
|
| 272 |
+
continue
|
| 273 |
+
try:
|
| 274 |
+
x = data_nums.drop(columns=[col])
|
| 275 |
+
y = data_nums[col]
|
| 276 |
+
x_train, x_test, y_train, y_test = train_test_split(
|
| 277 |
+
x, y, test_size=test_size, random_state=random_state,
|
| 278 |
+
)
|
| 279 |
+
bst, accuracy, _ = train_xgboost(x_train, y_train, x_test, y_test, n_classes)
|
| 280 |
+
# Gain-based importance; only features used in a split appear.
|
| 281 |
+
imp = {k: float(v) for k, v in bst.get_score(importance_type="gain").items()}
|
| 282 |
+
imp = dict(sorted(imp.items(), key=lambda kv: kv[1], reverse=True))
|
| 283 |
+
results[col] = {"feature_importance": imp, "accuracy": round(float(accuracy), 3)}
|
| 284 |
+
except Exception as e: # noqa: BLE001 — one bad target shouldn't sink the rest
|
| 285 |
+
skipped[col] = str(e)
|
| 286 |
+
|
| 287 |
+
return {"results": results, "columns": cols, "skipped": skipped}
|
api/services/parsing_service.py
CHANGED
|
@@ -12,6 +12,7 @@ importing this module never drags in torch or validates secrets.
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import json
|
|
|
|
| 15 |
from typing import Any, Callable
|
| 16 |
|
| 17 |
|
|
@@ -19,6 +20,7 @@ from typing import Any, Callable
|
|
| 19 |
# Rebuilt directly from config.py (no Streamlit dependency) so it stays in
|
| 20 |
# sync with parsing.py's SCHEMA_FORMATS / SCHEMA_FORMAT_GROUPS mapping.
|
| 21 |
_SCHEMA_SPECS: list[tuple[str, str]] = [
|
|
|
|
| 22 |
("Default UAP Format", "FORMAT_LONG"),
|
| 23 |
("SCU Spreadsheet", "FORMAT_LONG_XLSX"),
|
| 24 |
("SCU_v2", "FORMAT_SCU_V2"),
|
|
@@ -43,7 +45,7 @@ _SCHEMA_SPECS: list[tuple[str, str]] = [
|
|
| 43 |
]
|
| 44 |
|
| 45 |
SCHEMA_FORMAT_GROUPS: dict[str, list[str]] = {
|
| 46 |
-
"Canonical & SCU": ["Default UAP Format", "SCU Spreadsheet", "SCU_v2", "SCU_v3"],
|
| 47 |
"Government & official archives": ["Blue Book (USAF)", "UK National Archives"],
|
| 48 |
"European national databases": [
|
| 49 |
"COBEPS — PAN Notifications (BE)", "COBEPS — COB 2021 (BE)",
|
|
@@ -145,6 +147,103 @@ def merge_schema(labels: list[str], custom_fields: dict | None = None) -> dict:
|
|
| 145 |
}
|
| 146 |
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
# ── Cost estimation ────────────────────────────────────────────────────────
|
| 149 |
def estimate(descriptions: list[str], schema_json: str, model: str,
|
| 150 |
use_cache: bool = True, use_batch: bool = False) -> dict:
|
|
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import json
|
| 15 |
+
import re
|
| 16 |
from typing import Any, Callable
|
| 17 |
|
| 18 |
|
|
|
|
| 20 |
# Rebuilt directly from config.py (no Streamlit dependency) so it stays in
|
| 21 |
# sync with parsing.py's SCHEMA_FORMATS / SCHEMA_FORMAT_GROUPS mapping.
|
| 22 |
_SCHEMA_SPECS: list[tuple[str, str]] = [
|
| 23 |
+
("SCU_v1", "FORMAT_SCU_V1"),
|
| 24 |
("Default UAP Format", "FORMAT_LONG"),
|
| 25 |
("SCU Spreadsheet", "FORMAT_LONG_XLSX"),
|
| 26 |
("SCU_v2", "FORMAT_SCU_V2"),
|
|
|
|
| 45 |
]
|
| 46 |
|
| 47 |
SCHEMA_FORMAT_GROUPS: dict[str, list[str]] = {
|
| 48 |
+
"Canonical & SCU": ["SCU_v1", "Default UAP Format", "SCU Spreadsheet", "SCU_v2", "SCU_v3"],
|
| 49 |
"Government & official archives": ["Blue Book (USAF)", "UK National Archives"],
|
| 50 |
"European national databases": [
|
| 51 |
"COBEPS — PAN Notifications (BE)", "COBEPS — COB 2021 (BE)",
|
|
|
|
| 147 |
}
|
| 148 |
|
| 149 |
|
| 150 |
+
# ── Schema ↔ dataset coverage diff ─────────────────────────────────────────
|
| 151 |
+
def _norm_token(s: Any) -> str:
|
| 152 |
+
"""Normalize a name for fuzzy matching: lowercase, alphanumerics only."""
|
| 153 |
+
return re.sub(r"[^a-z0-9]", "", str(s).lower())
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _leaf(path: str) -> str:
|
| 157 |
+
"""Last dotted segment of a (possibly nested) field/column name."""
|
| 158 |
+
return str(path).split(".")[-1]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def schema_coverage(merged: dict, dataset_columns: list[str]) -> dict:
|
| 162 |
+
"""Compare the merged schema's leaf fields against ``dataset_columns``.
|
| 163 |
+
|
| 164 |
+
A schema field counts as *present* when some dataset column shares its
|
| 165 |
+
normalized leaf name (last dotted segment, case/punctuation-insensitive) —
|
| 166 |
+
e.g. schema ``sightingDetails.objectDescription.shape`` matches a dataset
|
| 167 |
+
column ``shape`` or ``object.shape``. Returns per-field present flags, the
|
| 168 |
+
dataset columns that matched, and the dataset-only (unmatched) columns.
|
| 169 |
+
"""
|
| 170 |
+
flat = _flatten_dotted(merged)
|
| 171 |
+
col_by_token: dict[str, str] = {}
|
| 172 |
+
for c in dataset_columns:
|
| 173 |
+
col_by_token.setdefault(_norm_token(_leaf(c)), c)
|
| 174 |
+
|
| 175 |
+
coverage = []
|
| 176 |
+
matched_cols: set[str] = set()
|
| 177 |
+
for path in flat:
|
| 178 |
+
match = col_by_token.get(_norm_token(_leaf(path)))
|
| 179 |
+
if match is not None:
|
| 180 |
+
matched_cols.add(match)
|
| 181 |
+
coverage.append({
|
| 182 |
+
"path": path,
|
| 183 |
+
"leaf": _leaf(path),
|
| 184 |
+
"present": match is not None,
|
| 185 |
+
"matched_column": match,
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
schema_tokens = {_norm_token(_leaf(p)) for p in flat}
|
| 189 |
+
db_only = [c for c in dataset_columns if _norm_token(_leaf(c)) not in schema_tokens]
|
| 190 |
+
n_present = sum(1 for c in coverage if c["present"])
|
| 191 |
+
return {
|
| 192 |
+
"coverage": coverage,
|
| 193 |
+
"summary": {
|
| 194 |
+
"present": n_present,
|
| 195 |
+
"missing": len(coverage) - n_present,
|
| 196 |
+
"total": len(coverage),
|
| 197 |
+
"db_only": len(db_only),
|
| 198 |
+
},
|
| 199 |
+
"matched_columns": sorted(matched_cols),
|
| 200 |
+
"db_only_columns": db_only,
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def prune_schema_to_paths(merged: dict, paths: list[str]) -> dict:
|
| 205 |
+
"""Rebuild a nested schema dict holding only the given dotted leaf paths,
|
| 206 |
+
preserving each leaf's original description/value and the original nesting
|
| 207 |
+
(so a FORMAT_LONG ``sightingDetails`` wrapper is kept)."""
|
| 208 |
+
flat = _flatten_dotted(merged)
|
| 209 |
+
keep = set(paths)
|
| 210 |
+
out: dict = {}
|
| 211 |
+
for path, val in flat.items():
|
| 212 |
+
if path not in keep:
|
| 213 |
+
continue
|
| 214 |
+
parts = path.split(".")
|
| 215 |
+
node = out
|
| 216 |
+
for p in parts[:-1]:
|
| 217 |
+
node = node.setdefault(p, {})
|
| 218 |
+
node[parts[-1]] = val
|
| 219 |
+
return out
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def schema_coverage_report(labels: list[str], dataset_columns: list[str],
|
| 223 |
+
custom_fields: dict | None = None) -> dict:
|
| 224 |
+
"""Coverage diff plus ready-to-use extraction-schema variants for the three
|
| 225 |
+
modes the parsing UI offers:
|
| 226 |
+
|
| 227 |
+
- ``all`` → the full merged schema (extract every field).
|
| 228 |
+
- ``missing`` → schema pruned to fields absent from the dataset (🔴 only).
|
| 229 |
+
- ``database`` → empty schema; keep the dataset columns as-is (no extraction).
|
| 230 |
+
"""
|
| 231 |
+
merged = merge_schema(labels, custom_fields)["schema"]
|
| 232 |
+
cov = schema_coverage(merged, dataset_columns)
|
| 233 |
+
|
| 234 |
+
missing_paths = [c["path"] for c in cov["coverage"] if not c["present"]]
|
| 235 |
+
all_paths = [c["path"] for c in cov["coverage"]]
|
| 236 |
+
missing_schema = prune_schema_to_paths(merged, missing_paths)
|
| 237 |
+
|
| 238 |
+
variants = {
|
| 239 |
+
"all": {"schema_json": json.dumps(merged, indent=2), "n_fields": len(all_paths)},
|
| 240 |
+
"missing": {"schema_json": json.dumps(missing_schema, indent=2),
|
| 241 |
+
"n_fields": len(missing_paths)},
|
| 242 |
+
"database": {"schema_json": "{}", "n_fields": 0},
|
| 243 |
+
}
|
| 244 |
+
return {**cov, "variants": variants}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
# ── Cost estimation ────────────────────────────────────────────────────────
|
| 248 |
def estimate(descriptions: list[str], schema_json: str, model: str,
|
| 249 |
use_cache: bool = True, use_batch: bool = False) -> dict:
|
config.py
CHANGED
|
@@ -761,6 +761,22 @@ FORMAT_SCU_V3 = {
|
|
| 761 |
}
|
| 762 |
}
|
| 763 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
# ---------------------------------------------------------------------------
|
| 765 |
# Merged compact schema — used by the Markdown-folder ingestion agent.
|
| 766 |
# Covers the essential fields from both FORMAT_LONG (sightingDetails) and
|
|
|
|
| 761 |
}
|
| 762 |
}
|
| 763 |
|
| 764 |
+
# ---------------------------------------------------------------------------
|
| 765 |
+
# FORMAT_SCU_V1 — "SCU v1" parsing schema (the default extraction format)
|
| 766 |
+
#
|
| 767 |
+
# The same field set as FORMAT_SCU_V3, minus two blocks:
|
| 768 |
+
# • sightingDetails — the verbose nested narrative group
|
| 769 |
+
# • manifest — the all-null downstream-join placeholder columns the
|
| 770 |
+
# parser must always leave null
|
| 771 |
+
# Everything else (source, assessment, anomaly, date_time, location, witness,
|
| 772 |
+
# investigation, craft, performance, military, effects, engagement_flags,
|
| 773 |
+
# engagement_type, case_text) is inherited unchanged. Derived from
|
| 774 |
+
# FORMAT_SCU_V3 so it never drifts out of sync; _deep_merge treats schemas as
|
| 775 |
+
# read-only, so the shared nested references are safe.
|
| 776 |
+
# ---------------------------------------------------------------------------
|
| 777 |
+
_SCU_V1_DROP = ("sightingDetails", "manifest")
|
| 778 |
+
FORMAT_SCU_V1 = {k: v for k, v in FORMAT_SCU_V3.items() if k not in _SCU_V1_DROP}
|
| 779 |
+
|
| 780 |
# ---------------------------------------------------------------------------
|
| 781 |
# Merged compact schema — used by the Markdown-folder ingestion agent.
|
| 782 |
# Covers the essential fields from both FORMAT_LONG (sightingDetails) and
|
frontend/src/api/client.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
| 4 |
DashboardSummary,
|
| 5 |
SchemaListResponse,
|
| 6 |
SchemaMergeResponse,
|
|
|
|
| 7 |
ParseUploadResponse,
|
| 8 |
CostEstimate,
|
| 9 |
ParseRunResponse,
|
|
@@ -14,6 +15,7 @@ import type {
|
|
| 14 |
CramersVResponse,
|
| 15 |
ContingencyResponse,
|
| 16 |
ColumnGroupsResponse,
|
|
|
|
| 17 |
} from '../types';
|
| 18 |
|
| 19 |
// API origin is configurable for split deployments (e.g. frontend on Vercel,
|
|
@@ -120,6 +122,17 @@ export const api = {
|
|
| 120 |
});
|
| 121 |
},
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
uploadParseFile(file: File): Promise<ParseUploadResponse> {
|
| 124 |
const form = new FormData();
|
| 125 |
form.append('file', file);
|
|
@@ -227,6 +240,13 @@ export const api = {
|
|
| 227 |
});
|
| 228 |
},
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
queryGemini(question: string, columns: string[], geminiKey: string): Promise<{ status: string; response: string; context_rows_used?: number; columns_used?: string[] }> {
|
| 231 |
return request('/query/gemini', {
|
| 232 |
method: 'POST',
|
|
|
|
| 4 |
DashboardSummary,
|
| 5 |
SchemaListResponse,
|
| 6 |
SchemaMergeResponse,
|
| 7 |
+
SchemaCoverageResponse,
|
| 8 |
ParseUploadResponse,
|
| 9 |
CostEstimate,
|
| 10 |
ParseRunResponse,
|
|
|
|
| 15 |
CramersVResponse,
|
| 16 |
ContingencyResponse,
|
| 17 |
ColumnGroupsResponse,
|
| 18 |
+
XgboostImportanceResponse,
|
| 19 |
} from '../types';
|
| 20 |
|
| 21 |
// API origin is configurable for split deployments (e.g. frontend on Vercel,
|
|
|
|
| 122 |
});
|
| 123 |
},
|
| 124 |
|
| 125 |
+
schemaCoverage(
|
| 126 |
+
labels: string[],
|
| 127 |
+
columns: string[],
|
| 128 |
+
customFields?: Record<string, unknown>
|
| 129 |
+
): Promise<SchemaCoverageResponse> {
|
| 130 |
+
return request('/parse/schema-coverage', {
|
| 131 |
+
method: 'POST',
|
| 132 |
+
body: JSON.stringify({ labels, columns, custom_fields: customFields ?? null }),
|
| 133 |
+
});
|
| 134 |
+
},
|
| 135 |
+
|
| 136 |
uploadParseFile(file: File): Promise<ParseUploadResponse> {
|
| 137 |
const form = new FormData();
|
| 138 |
form.append('file', file);
|
|
|
|
| 240 |
});
|
| 241 |
},
|
| 242 |
|
| 243 |
+
xgboostImportance(columns: string[], source = 'dataset'): Promise<XgboostImportanceResponse> {
|
| 244 |
+
return request('/analysis/xgboost', {
|
| 245 |
+
method: 'POST',
|
| 246 |
+
body: JSON.stringify({ columns, source }),
|
| 247 |
+
});
|
| 248 |
+
},
|
| 249 |
+
|
| 250 |
queryGemini(question: string, columns: string[], geminiKey: string): Promise<{ status: string; response: string; context_rows_used?: number; columns_used?: string[] }> {
|
| 251 |
return request('/query/gemini', {
|
| 252 |
method: 'POST',
|
frontend/src/components/analysis/AnalysisPage.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { CorrelationHeatmap } from './CorrelationHeatmap';
|
|
| 9 |
import { XGBoostResults } from './XGBoostResults';
|
| 10 |
import { DistributionChart } from './DistributionChart';
|
| 11 |
import { CramersVExplorer } from './CramersVExplorer';
|
| 12 |
-
import type { AnalysisResponse } from '../../types';
|
| 13 |
|
| 14 |
type TabId = 'clusters' | 'correlation' | 'xgboost' | 'distribution' | 'association';
|
| 15 |
|
|
@@ -18,6 +18,13 @@ export function AnalysisPage() {
|
|
| 18 |
const [selected, setSelected] = useState<string[]>([]);
|
| 19 |
const [error, setError] = useState<string | null>(null);
|
| 20 |
const [activeTab, setActiveTab] = useState<TabId>('clusters');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
// Cluster pipeline tuning (mirrors analyzing.py controls)
|
| 23 |
const [showParams, setShowParams] = useState(false);
|
|
@@ -213,11 +220,48 @@ export function AnalysisPage() {
|
|
| 213 |
))}
|
| 214 |
</div>
|
| 215 |
|
| 216 |
-
{/* Association explorer — independent of the cluster pipeline
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
{/* Result-dependent tabs */}
|
| 220 |
-
{activeTab !== 'association' && !results && (
|
| 221 |
<Panel title="Run the analysis pipeline">
|
| 222 |
<p className="text-sm text-text-muted">
|
| 223 |
Select columns above and click <span className="text-accent">Run Analysis</span> to
|
|
@@ -257,18 +301,6 @@ export function AnalysisPage() {
|
|
| 257 |
</Panel>
|
| 258 |
)}
|
| 259 |
|
| 260 |
-
{/* XGBoost */}
|
| 261 |
-
{activeTab === 'xgboost' && results.xgboost && Object.keys(results.xgboost).length > 0 && (
|
| 262 |
-
<XGBoostResults results={results.xgboost} />
|
| 263 |
-
)}
|
| 264 |
-
{activeTab === 'xgboost' && (!results.xgboost || Object.keys(results.xgboost).length === 0) && (
|
| 265 |
-
<Panel title="Feature Importance">
|
| 266 |
-
<p className="text-sm text-text-muted">
|
| 267 |
-
Select at least 2 columns to run feature importance analysis.
|
| 268 |
-
</p>
|
| 269 |
-
</Panel>
|
| 270 |
-
)}
|
| 271 |
-
|
| 272 |
{/* Distribution */}
|
| 273 |
{activeTab === 'distribution' && (
|
| 274 |
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
|
|
|
| 9 |
import { XGBoostResults } from './XGBoostResults';
|
| 10 |
import { DistributionChart } from './DistributionChart';
|
| 11 |
import { CramersVExplorer } from './CramersVExplorer';
|
| 12 |
+
import type { AnalysisResponse, XGBoostResult } from '../../types';
|
| 13 |
|
| 14 |
type TabId = 'clusters' | 'correlation' | 'xgboost' | 'distribution' | 'association';
|
| 15 |
|
|
|
|
| 18 |
const [selected, setSelected] = useState<string[]>([]);
|
| 19 |
const [error, setError] = useState<string | null>(null);
|
| 20 |
const [activeTab, setActiveTab] = useState<TabId>('clusters');
|
| 21 |
+
// XGBoost feature importance handed over from the Cramér's V explorer.
|
| 22 |
+
const [assocXgboost, setAssocXgboost] = useState<Record<string, XGBoostResult> | null>(null);
|
| 23 |
+
|
| 24 |
+
const handleAssocXgboost = (r: Record<string, XGBoostResult>) => {
|
| 25 |
+
setAssocXgboost(r);
|
| 26 |
+
setActiveTab('xgboost');
|
| 27 |
+
};
|
| 28 |
|
| 29 |
// Cluster pipeline tuning (mirrors analyzing.py controls)
|
| 30 |
const [showParams, setShowParams] = useState(false);
|
|
|
|
| 220 |
))}
|
| 221 |
</div>
|
| 222 |
|
| 223 |
+
{/* Association explorer — independent of the cluster pipeline. Its XGBoost
|
| 224 |
+
run is handed to the Feature Importance tab via handleAssocXgboost. */}
|
| 225 |
+
{activeTab === 'association' && (
|
| 226 |
+
<CramersVExplorer source="dataset" onXgboost={handleAssocXgboost} />
|
| 227 |
+
)}
|
| 228 |
+
|
| 229 |
+
{/* Feature importance — independent of the cluster pipeline: it shows the
|
| 230 |
+
explorer-driven results when present, otherwise the pipeline's. */}
|
| 231 |
+
{activeTab === 'xgboost' && (() => {
|
| 232 |
+
const xgb =
|
| 233 |
+
assocXgboost && Object.keys(assocXgboost).length > 0
|
| 234 |
+
? assocXgboost
|
| 235 |
+
: results?.xgboost ?? null;
|
| 236 |
+
if (xgb && Object.keys(xgb).length > 0) {
|
| 237 |
+
return (
|
| 238 |
+
<div className="space-y-3">
|
| 239 |
+
{assocXgboost && Object.keys(assocXgboost).length > 0 && (
|
| 240 |
+
<div className="flex items-center gap-2 rounded-md border border-purple/30 bg-purple/10 px-4 py-2.5 text-xs text-text-secondary">
|
| 241 |
+
<Network className="h-4 w-4 text-purple" />
|
| 242 |
+
Computed directly from your Cramér's V column selection — each column predicted
|
| 243 |
+
from the others.
|
| 244 |
+
</div>
|
| 245 |
+
)}
|
| 246 |
+
<XGBoostResults results={xgb} />
|
| 247 |
+
</div>
|
| 248 |
+
);
|
| 249 |
+
}
|
| 250 |
+
return (
|
| 251 |
+
<Panel title="Feature Importance">
|
| 252 |
+
<p className="text-sm text-text-muted">
|
| 253 |
+
No feature-importance results yet. Run the cluster pipeline above, or open the{' '}
|
| 254 |
+
<button onClick={() => setActiveTab('association')} className="text-accent hover:underline">
|
| 255 |
+
Cramér's V Explorer
|
| 256 |
+
</button>
|
| 257 |
+
, select columns, and click <span className="text-accent">Feature Importance →</span>.
|
| 258 |
+
</p>
|
| 259 |
+
</Panel>
|
| 260 |
+
);
|
| 261 |
+
})()}
|
| 262 |
|
| 263 |
{/* Result-dependent tabs */}
|
| 264 |
+
{activeTab !== 'association' && activeTab !== 'xgboost' && !results && (
|
| 265 |
<Panel title="Run the analysis pipeline">
|
| 266 |
<p className="text-sm text-text-muted">
|
| 267 |
Select columns above and click <span className="text-accent">Run Analysis</span> to
|
|
|
|
| 301 |
</Panel>
|
| 302 |
)}
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
{/* Distribution */}
|
| 305 |
{activeTab === 'distribution' && (
|
| 306 |
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
frontend/src/components/analysis/CramersVExplorer.tsx
CHANGED
|
@@ -1,16 +1,21 @@
|
|
| 1 |
import { useState, useEffect, useMemo } from 'react';
|
| 2 |
import Plot from 'react-plotly.js';
|
| 3 |
-
import { Play, AlertTriangle, Grid3x3, Layers } from 'lucide-react';
|
| 4 |
import { api } from '../../api/client';
|
| 5 |
import { Panel } from '../common/Panel';
|
| 6 |
import { LoadingSpinner } from '../common/LoadingSpinner';
|
| 7 |
-
import
|
|
|
|
| 8 |
|
| 9 |
interface Props {
|
| 10 |
source: 'dataset' | 'parsed';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
-
export function CramersVExplorer({ source }: Props) {
|
| 14 |
const [report, setReport] = useState<CramersVResponse | null>(null);
|
| 15 |
const [contingency, setContingency] = useState<ContingencyResponse | null>(null);
|
| 16 |
const [pair, setPair] = useState<{ a: string; b: string } | null>(null);
|
|
@@ -22,6 +27,10 @@ export function CramersVExplorer({ source }: Props) {
|
|
| 22 |
const [loading, setLoading] = useState(false);
|
| 23 |
const [error, setError] = useState<string | null>(null);
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
// Eligible categorical columns, grouped by their dotted parent (e.g. craft.*).
|
| 26 |
const [groups, setGroups] = useState<ColumnGroup[]>([]);
|
| 27 |
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
@@ -101,6 +110,29 @@ export function CramersVExplorer({ source }: Props) {
|
|
| 101 |
}
|
| 102 |
};
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
const loadContingency = async (a: string, b: string) => {
|
| 105 |
setPair({ a, b });
|
| 106 |
try {
|
|
@@ -122,14 +154,25 @@ export function CramersVExplorer({ source }: Props) {
|
|
| 122 |
title="Categorical Association Explorer (Cramér's V)"
|
| 123 |
subtitle="Pairwise association across the selected categorical columns"
|
| 124 |
actions={
|
| 125 |
-
<
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
>
|
| 135 |
<div className="flex flex-wrap items-center gap-4">
|
|
@@ -268,6 +311,16 @@ export function CramersVExplorer({ source }: Props) {
|
|
| 268 |
)}
|
| 269 |
|
| 270 |
{loading && <LoadingSpinner text="Computing Cramér's V matrix..." />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
{report && report.labels.length < 2 && (
|
| 273 |
<Panel title="Not enough categorical columns">
|
|
|
|
| 1 |
import { useState, useEffect, useMemo } from 'react';
|
| 2 |
import Plot from 'react-plotly.js';
|
| 3 |
+
import { Play, AlertTriangle, Grid3x3, Layers, BarChart3 } from 'lucide-react';
|
| 4 |
import { api } from '../../api/client';
|
| 5 |
import { Panel } from '../common/Panel';
|
| 6 |
import { LoadingSpinner } from '../common/LoadingSpinner';
|
| 7 |
+
import { XGBoostResults } from './XGBoostResults';
|
| 8 |
+
import type { CramersVResponse, ContingencyResponse, ColumnGroup, XGBoostResult } from '../../types';
|
| 9 |
|
| 10 |
interface Props {
|
| 11 |
source: 'dataset' | 'parsed';
|
| 12 |
+
// When provided, XGBoost feature importance computed from the selected columns
|
| 13 |
+
// is handed off to the parent (e.g. the Feature Importance tab) instead of
|
| 14 |
+
// rendering inline.
|
| 15 |
+
onXgboost?: (results: Record<string, XGBoostResult>) => void;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export function CramersVExplorer({ source, onXgboost }: Props) {
|
| 19 |
const [report, setReport] = useState<CramersVResponse | null>(null);
|
| 20 |
const [contingency, setContingency] = useState<ContingencyResponse | null>(null);
|
| 21 |
const [pair, setPair] = useState<{ a: string; b: string } | null>(null);
|
|
|
|
| 27 |
const [loading, setLoading] = useState(false);
|
| 28 |
const [error, setError] = useState<string | null>(null);
|
| 29 |
|
| 30 |
+
// XGBoost feature importance run directly on the selected columns.
|
| 31 |
+
const [xgbLoading, setXgbLoading] = useState(false);
|
| 32 |
+
const [localXgb, setLocalXgb] = useState<Record<string, XGBoostResult> | null>(null);
|
| 33 |
+
|
| 34 |
// Eligible categorical columns, grouped by their dotted parent (e.g. craft.*).
|
| 35 |
const [groups, setGroups] = useState<ColumnGroup[]>([]);
|
| 36 |
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
| 110 |
}
|
| 111 |
};
|
| 112 |
|
| 113 |
+
const runXgboost = async () => {
|
| 114 |
+
const cols = orderedEligible.filter((c) => selected.has(c));
|
| 115 |
+
if (cols.length < 2) {
|
| 116 |
+
setError('Select at least two columns to run feature importance.');
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
setXgbLoading(true);
|
| 120 |
+
setError(null);
|
| 121 |
+
try {
|
| 122 |
+
const res = await api.xgboostImportance(cols, source);
|
| 123 |
+
if (!Object.keys(res.results).length) {
|
| 124 |
+
setError(res.message || 'No feature-importance results (need ≥2 non-constant columns).');
|
| 125 |
+
return;
|
| 126 |
+
}
|
| 127 |
+
if (onXgboost) onXgboost(res.results);
|
| 128 |
+
else setLocalXgb(res.results);
|
| 129 |
+
} catch (e) {
|
| 130 |
+
setError(e instanceof Error ? e.message : 'Feature importance failed');
|
| 131 |
+
} finally {
|
| 132 |
+
setXgbLoading(false);
|
| 133 |
+
}
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
const loadContingency = async (a: string, b: string) => {
|
| 137 |
setPair({ a, b });
|
| 138 |
try {
|
|
|
|
| 154 |
title="Categorical Association Explorer (Cramér's V)"
|
| 155 |
subtitle="Pairwise association across the selected categorical columns"
|
| 156 |
actions={
|
| 157 |
+
<div className="flex items-center gap-2">
|
| 158 |
+
<button
|
| 159 |
+
onClick={runXgboost}
|
| 160 |
+
disabled={xgbLoading || selected.size < 2}
|
| 161 |
+
title="Train XGBoost on the selected columns and send the result to Feature Importance"
|
| 162 |
+
className="flex items-center gap-1.5 rounded-md border border-border bg-raised px-3 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent hover:text-accent disabled:opacity-50"
|
| 163 |
+
>
|
| 164 |
+
<BarChart3 className="h-3.5 w-3.5" />
|
| 165 |
+
{xgbLoading ? 'Training…' : 'Feature Importance →'}
|
| 166 |
+
</button>
|
| 167 |
+
<button
|
| 168 |
+
onClick={run}
|
| 169 |
+
disabled={loading}
|
| 170 |
+
className="flex items-center gap-2 rounded-md bg-accent-dim px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent disabled:opacity-50"
|
| 171 |
+
>
|
| 172 |
+
<Play className="h-3.5 w-3.5" />
|
| 173 |
+
{loading ? 'Computing…' : 'Compute'}
|
| 174 |
+
</button>
|
| 175 |
+
</div>
|
| 176 |
}
|
| 177 |
>
|
| 178 |
<div className="flex flex-wrap items-center gap-4">
|
|
|
|
| 311 |
)}
|
| 312 |
|
| 313 |
{loading && <LoadingSpinner text="Computing Cramér's V matrix..." />}
|
| 314 |
+
{xgbLoading && <LoadingSpinner text="Training XGBoost on the selected columns..." />}
|
| 315 |
+
|
| 316 |
+
{localXgb && (
|
| 317 |
+
<Panel
|
| 318 |
+
title="Feature Importance (XGBoost)"
|
| 319 |
+
subtitle="Each selected column predicted from the others — gain-based importance"
|
| 320 |
+
>
|
| 321 |
+
<XGBoostResults results={localXgb} />
|
| 322 |
+
</Panel>
|
| 323 |
+
)}
|
| 324 |
|
| 325 |
{report && report.labels.length < 2 && (
|
| 326 |
<Panel title="Not enough categorical columns">
|
frontend/src/components/parsing/ParsingPage.tsx
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
| 9 |
Key,
|
| 10 |
Layers,
|
| 11 |
ShieldCheck,
|
|
|
|
|
|
|
|
|
|
| 12 |
} from 'lucide-react';
|
| 13 |
import { api } from '../../api/client';
|
| 14 |
import { useStore } from '../../store/useStore';
|
|
@@ -20,8 +23,16 @@ import type {
|
|
| 20 |
CostEstimate,
|
| 21 |
ParseRunResponse,
|
| 22 |
DataResponse,
|
|
|
|
|
|
|
| 23 |
} from '../../types';
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
export function ParsingPage() {
|
| 26 |
const {
|
| 27 |
openaiKey,
|
|
@@ -37,6 +48,10 @@ export function ParsingPage() {
|
|
| 37 |
const [mergedFormat, setMergedFormat] = useState('');
|
| 38 |
const [fieldCount, setFieldCount] = useState(0);
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const [source, setSource] = useState<DataResponse | null>(null);
|
| 41 |
const [sourceColumns, setSourceColumns] = useState<string[]>([]);
|
| 42 |
const [textColumns, setTextColumns] = useState<string[]>([]);
|
|
@@ -71,21 +86,41 @@ export function ParsingPage() {
|
|
| 71 |
if (list.length && !list.includes(model)) setModel(list[0]);
|
| 72 |
}, [provider, schemas]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 73 |
|
| 74 |
-
// Re-merge schemas
|
|
|
|
|
|
|
|
|
|
| 75 |
useEffect(() => {
|
| 76 |
if (selectedSchemas.length === 0) {
|
| 77 |
setMergedFormat('');
|
| 78 |
setFieldCount(0);
|
|
|
|
| 79 |
return;
|
| 80 |
}
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
const apiKey = provider === 'openai' ? openaiKey : deepseekKey;
|
| 91 |
const setApiKey = provider === 'openai' ? setOpenaiKey : setDeepseekKey;
|
|
@@ -95,6 +130,13 @@ export function ParsingPage() {
|
|
| 95 |
: schemas.models.deepseek
|
| 96 |
: [];
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 99 |
const file = e.target.files?.[0];
|
| 100 |
if (!file) return;
|
|
@@ -119,7 +161,7 @@ export function ParsingPage() {
|
|
| 119 |
list.includes(v) ? list.filter((x) => x !== v) : [...list, v];
|
| 120 |
|
| 121 |
const runEstimate = async () => {
|
| 122 |
-
if (!textColumns.length || !
|
| 123 |
setError(null);
|
| 124 |
try {
|
| 125 |
const est = await api.estimateParse(textColumns, mergedFormat, model);
|
|
@@ -130,8 +172,8 @@ export function ParsingPage() {
|
|
| 130 |
};
|
| 131 |
|
| 132 |
const runParse = async () => {
|
| 133 |
-
if (!textColumns.length || !
|
| 134 |
-
setError('Select at least one text column and
|
| 135 |
return;
|
| 136 |
}
|
| 137 |
if (!apiKey) {
|
|
@@ -195,6 +237,130 @@ export function ParsingPage() {
|
|
| 195 |
</Panel>
|
| 196 |
)}
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
{source && (
|
| 199 |
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
| 200 |
{/* Left: column + schema config */}
|
|
@@ -330,7 +496,7 @@ export function ParsingPage() {
|
|
| 330 |
actions={
|
| 331 |
<button
|
| 332 |
onClick={runEstimate}
|
| 333 |
-
disabled={!textColumns.length || !
|
| 334 |
className="flex items-center gap-1.5 rounded-md border border-border bg-raised px-3 py-1 text-xs text-text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
|
| 335 |
>
|
| 336 |
<DollarSign className="h-3.5 w-3.5" /> Estimate Cost
|
|
@@ -364,15 +530,22 @@ export function ParsingPage() {
|
|
| 364 |
<Panel title="4 · Run Extraction">
|
| 365 |
<button
|
| 366 |
onClick={runParse}
|
| 367 |
-
disabled={running || !textColumns.length || !
|
| 368 |
className="flex w-full items-center justify-center gap-2 rounded-md bg-accent-dim px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent disabled:opacity-50"
|
| 369 |
>
|
| 370 |
<Play className="h-4 w-4" />
|
| 371 |
{running ? 'Parsing…' : 'Parse Dataset'}
|
| 372 |
</button>
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
</Panel>
|
| 377 |
|
| 378 |
{result && (
|
|
|
|
| 9 |
Key,
|
| 10 |
Layers,
|
| 11 |
ShieldCheck,
|
| 12 |
+
GitCompare,
|
| 13 |
+
Database,
|
| 14 |
+
Plus,
|
| 15 |
} from 'lucide-react';
|
| 16 |
import { api } from '../../api/client';
|
| 17 |
import { useStore } from '../../store/useStore';
|
|
|
|
| 23 |
CostEstimate,
|
| 24 |
ParseRunResponse,
|
| 25 |
DataResponse,
|
| 26 |
+
SchemaCoverageResponse,
|
| 27 |
+
CoverageMode,
|
| 28 |
} from '../../types';
|
| 29 |
|
| 30 |
+
const COVERAGE_MODES: { id: CoverageMode; label: string; hint: string }[] = [
|
| 31 |
+
{ id: 'missing', label: 'Add missing only', hint: 'Extract only schema fields the dataset is missing (🔴)' },
|
| 32 |
+
{ id: 'all', label: 'All fields', hint: 'Extract every schema field, present or not' },
|
| 33 |
+
{ id: 'database', label: 'Database only', hint: 'No extraction — keep the uploaded columns as-is' },
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
export function ParsingPage() {
|
| 37 |
const {
|
| 38 |
openaiKey,
|
|
|
|
| 48 |
const [mergedFormat, setMergedFormat] = useState('');
|
| 49 |
const [fieldCount, setFieldCount] = useState(0);
|
| 50 |
|
| 51 |
+
// Schema ↔ dataset coverage diff + extraction mode (missing / all / database)
|
| 52 |
+
const [coverage, setCoverage] = useState<SchemaCoverageResponse | null>(null);
|
| 53 |
+
const [coverageMode, setCoverageMode] = useState<CoverageMode>('all');
|
| 54 |
+
|
| 55 |
const [source, setSource] = useState<DataResponse | null>(null);
|
| 56 |
const [sourceColumns, setSourceColumns] = useState<string[]>([]);
|
| 57 |
const [textColumns, setTextColumns] = useState<string[]>([]);
|
|
|
|
| 86 |
if (list.length && !list.includes(model)) setModel(list[0]);
|
| 87 |
}, [provider, schemas]); // eslint-disable-line react-hooks/exhaustive-deps
|
| 88 |
|
| 89 |
+
// Re-merge schemas / recompute the coverage diff whenever the schema
|
| 90 |
+
// selection or the uploaded dataset columns change. With a dataset present we
|
| 91 |
+
// fetch the diff (which also carries the per-mode extraction schemas); the
|
| 92 |
+
// effective mergedFormat is then derived from the active mode below.
|
| 93 |
useEffect(() => {
|
| 94 |
if (selectedSchemas.length === 0) {
|
| 95 |
setMergedFormat('');
|
| 96 |
setFieldCount(0);
|
| 97 |
+
setCoverage(null);
|
| 98 |
return;
|
| 99 |
}
|
| 100 |
+
if (sourceColumns.length) {
|
| 101 |
+
api
|
| 102 |
+
.schemaCoverage(selectedSchemas, sourceColumns)
|
| 103 |
+
.then(setCoverage)
|
| 104 |
+
.catch((e) => setError(e instanceof Error ? e.message : 'Coverage diff failed'));
|
| 105 |
+
} else {
|
| 106 |
+
setCoverage(null);
|
| 107 |
+
api
|
| 108 |
+
.mergeSchema(selectedSchemas)
|
| 109 |
+
.then((m) => {
|
| 110 |
+
setMergedFormat(m.schema_json);
|
| 111 |
+
setFieldCount(m.fields.length);
|
| 112 |
+
})
|
| 113 |
+
.catch((e) => setError(e instanceof Error ? e.message : 'Schema merge failed'));
|
| 114 |
+
}
|
| 115 |
+
}, [selectedSchemas, sourceColumns]);
|
| 116 |
+
|
| 117 |
+
// Derive the effective extraction schema from the chosen coverage mode.
|
| 118 |
+
useEffect(() => {
|
| 119 |
+
if (!coverage) return;
|
| 120 |
+
const variant = coverage.variants[coverageMode];
|
| 121 |
+
setMergedFormat(variant.schema_json);
|
| 122 |
+
setFieldCount(variant.n_fields);
|
| 123 |
+
}, [coverage, coverageMode]);
|
| 124 |
|
| 125 |
const apiKey = provider === 'openai' ? openaiKey : deepseekKey;
|
| 126 |
const setApiKey = provider === 'openai' ? setOpenaiKey : setDeepseekKey;
|
|
|
|
| 130 |
: schemas.models.deepseek
|
| 131 |
: [];
|
| 132 |
|
| 133 |
+
// "Database only" mode yields an empty schema ({}), so there is nothing to
|
| 134 |
+
// estimate or extract — the uploaded columns are kept as-is.
|
| 135 |
+
const extractionReady = !!mergedFormat && mergedFormat.trim() !== '{}';
|
| 136 |
+
|
| 137 |
+
const addToKeep = (cols: string[]) =>
|
| 138 |
+
setKeepColumns((prev) => Array.from(new Set([...prev, ...cols])));
|
| 139 |
+
|
| 140 |
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 141 |
const file = e.target.files?.[0];
|
| 142 |
if (!file) return;
|
|
|
|
| 161 |
list.includes(v) ? list.filter((x) => x !== v) : [...list, v];
|
| 162 |
|
| 163 |
const runEstimate = async () => {
|
| 164 |
+
if (!textColumns.length || !extractionReady) return;
|
| 165 |
setError(null);
|
| 166 |
try {
|
| 167 |
const est = await api.estimateParse(textColumns, mergedFormat, model);
|
|
|
|
| 172 |
};
|
| 173 |
|
| 174 |
const runParse = async () => {
|
| 175 |
+
if (!textColumns.length || !extractionReady) {
|
| 176 |
+
setError('Select at least one text column and a schema with fields to extract.');
|
| 177 |
return;
|
| 178 |
}
|
| 179 |
if (!apiKey) {
|
|
|
|
| 237 |
</Panel>
|
| 238 |
)}
|
| 239 |
|
| 240 |
+
{source && coverage && (
|
| 241 |
+
<Panel
|
| 242 |
+
title="Schema ↔ Dataset Coverage"
|
| 243 |
+
subtitle="How many merged-schema fields the uploaded dataset already provides"
|
| 244 |
+
actions={
|
| 245 |
+
<div className="flex items-center gap-3 text-[11px]">
|
| 246 |
+
<span className="flex items-center gap-1 text-success">
|
| 247 |
+
<span className="h-2 w-2 rounded-full bg-success" /> {coverage.summary.present} present
|
| 248 |
+
</span>
|
| 249 |
+
<span className="flex items-center gap-1 text-danger">
|
| 250 |
+
<span className="h-2 w-2 rounded-full bg-danger" /> {coverage.summary.missing} missing
|
| 251 |
+
</span>
|
| 252 |
+
<span className="flex items-center gap-1 text-text-muted">
|
| 253 |
+
<Database className="h-3 w-3" /> {coverage.summary.db_only} DB-only
|
| 254 |
+
</span>
|
| 255 |
+
</div>
|
| 256 |
+
}
|
| 257 |
+
>
|
| 258 |
+
{/* Extraction-mode selector */}
|
| 259 |
+
<div className="mb-2 flex flex-wrap gap-1.5">
|
| 260 |
+
{COVERAGE_MODES.map((m) => {
|
| 261 |
+
const active = coverageMode === m.id;
|
| 262 |
+
const n = coverage.variants[m.id].n_fields;
|
| 263 |
+
const Icon = m.id === 'missing' ? Plus : m.id === 'all' ? Layers : Database;
|
| 264 |
+
return (
|
| 265 |
+
<button
|
| 266 |
+
key={m.id}
|
| 267 |
+
onClick={() => setCoverageMode(m.id)}
|
| 268 |
+
title={m.hint}
|
| 269 |
+
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
|
| 270 |
+
active
|
| 271 |
+
? 'border-accent bg-accent-dim/30 text-accent-bright'
|
| 272 |
+
: 'border-border bg-raised text-text-secondary hover:border-border-bright'
|
| 273 |
+
}`}
|
| 274 |
+
>
|
| 275 |
+
<Icon className="h-3.5 w-3.5" />
|
| 276 |
+
{m.label}
|
| 277 |
+
{m.id !== 'database' && <span className="text-text-muted">({n})</span>}
|
| 278 |
+
</button>
|
| 279 |
+
);
|
| 280 |
+
})}
|
| 281 |
+
</div>
|
| 282 |
+
<p className="mb-3 flex items-center gap-1.5 text-[11px] text-text-muted">
|
| 283 |
+
<GitCompare className="h-3.5 w-3.5" />
|
| 284 |
+
{COVERAGE_MODES.find((m) => m.id === coverageMode)?.hint}
|
| 285 |
+
</p>
|
| 286 |
+
|
| 287 |
+
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
| 288 |
+
{/* Schema fields (🟢 present / 🔴 missing) */}
|
| 289 |
+
<div>
|
| 290 |
+
<p className="mb-1.5 text-[11px] font-semibold text-text-secondary">Schema fields</p>
|
| 291 |
+
<div className="max-h-56 overflow-y-auto rounded border border-border/40 bg-deep/40 p-2">
|
| 292 |
+
<div className="flex flex-wrap gap-1.5">
|
| 293 |
+
{coverage.coverage.map((f) => {
|
| 294 |
+
// Which fields get extracted in the active mode.
|
| 295 |
+
const extracted =
|
| 296 |
+
coverageMode === 'all' ? true : coverageMode === 'missing' ? !f.present : false;
|
| 297 |
+
return (
|
| 298 |
+
<span
|
| 299 |
+
key={f.path}
|
| 300 |
+
title={`${f.path}${f.matched_column ? ` ← ${f.matched_column}` : ''}`}
|
| 301 |
+
className={`flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] ${
|
| 302 |
+
f.present
|
| 303 |
+
? 'border-success/40 bg-success/10 text-success'
|
| 304 |
+
: 'border-danger/40 bg-danger/10 text-danger'
|
| 305 |
+
} ${extracted ? '' : 'opacity-40'}`}
|
| 306 |
+
>
|
| 307 |
+
{f.present ? '🟢' : '🔴'} {f.leaf}
|
| 308 |
+
</span>
|
| 309 |
+
);
|
| 310 |
+
})}
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
<p className="mt-1 text-[10px] text-text-muted">
|
| 314 |
+
Dimmed chips aren’t extracted in this mode · matched by field (leaf) name.
|
| 315 |
+
</p>
|
| 316 |
+
</div>
|
| 317 |
+
|
| 318 |
+
{/* Database-only columns (in dataset, not in schema) */}
|
| 319 |
+
<div>
|
| 320 |
+
<div className="mb-1.5 flex items-center justify-between">
|
| 321 |
+
<p className="text-[11px] font-semibold text-text-secondary">
|
| 322 |
+
Database-only columns ({coverage.db_only_columns.length})
|
| 323 |
+
</p>
|
| 324 |
+
{coverage.db_only_columns.length > 0 && (
|
| 325 |
+
<button
|
| 326 |
+
onClick={() => addToKeep(coverage.db_only_columns)}
|
| 327 |
+
className="flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] text-text-secondary hover:border-accent hover:text-accent"
|
| 328 |
+
title="Append these to the carry-through columns"
|
| 329 |
+
>
|
| 330 |
+
<Plus className="h-3 w-3" /> Carry through
|
| 331 |
+
</button>
|
| 332 |
+
)}
|
| 333 |
+
</div>
|
| 334 |
+
<div className="max-h-56 overflow-y-auto rounded border border-border/40 bg-deep/40 p-2">
|
| 335 |
+
{coverage.db_only_columns.length ? (
|
| 336 |
+
<div className="flex flex-wrap gap-1.5">
|
| 337 |
+
{coverage.db_only_columns.map((c) => (
|
| 338 |
+
<span
|
| 339 |
+
key={c}
|
| 340 |
+
className="rounded border border-border bg-raised px-2 py-0.5 text-[11px] text-text-secondary"
|
| 341 |
+
>
|
| 342 |
+
{c}
|
| 343 |
+
</span>
|
| 344 |
+
))}
|
| 345 |
+
</div>
|
| 346 |
+
) : (
|
| 347 |
+
<p className="text-[11px] text-text-muted">Every dataset column maps to a schema field.</p>
|
| 348 |
+
)}
|
| 349 |
+
</div>
|
| 350 |
+
{coverage.matched_columns.length > 0 && (
|
| 351 |
+
<button
|
| 352 |
+
onClick={() => addToKeep(coverage.matched_columns)}
|
| 353 |
+
className="mt-1.5 flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[10px] text-text-secondary hover:border-accent hover:text-accent"
|
| 354 |
+
title="Append the dataset columns that already cover schema fields to carry-through"
|
| 355 |
+
>
|
| 356 |
+
<Plus className="h-3 w-3" /> Carry through {coverage.matched_columns.length} matched column(s)
|
| 357 |
+
</button>
|
| 358 |
+
)}
|
| 359 |
+
</div>
|
| 360 |
+
</div>
|
| 361 |
+
</Panel>
|
| 362 |
+
)}
|
| 363 |
+
|
| 364 |
{source && (
|
| 365 |
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
| 366 |
{/* Left: column + schema config */}
|
|
|
|
| 496 |
actions={
|
| 497 |
<button
|
| 498 |
onClick={runEstimate}
|
| 499 |
+
disabled={!textColumns.length || !extractionReady}
|
| 500 |
className="flex items-center gap-1.5 rounded-md border border-border bg-raised px-3 py-1 text-xs text-text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
|
| 501 |
>
|
| 502 |
<DollarSign className="h-3.5 w-3.5" /> Estimate Cost
|
|
|
|
| 530 |
<Panel title="4 · Run Extraction">
|
| 531 |
<button
|
| 532 |
onClick={runParse}
|
| 533 |
+
disabled={running || !textColumns.length || !extractionReady || !apiKey}
|
| 534 |
className="flex w-full items-center justify-center gap-2 rounded-md bg-accent-dim px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent disabled:opacity-50"
|
| 535 |
>
|
| 536 |
<Play className="h-4 w-4" />
|
| 537 |
{running ? 'Parsing…' : 'Parse Dataset'}
|
| 538 |
</button>
|
| 539 |
+
{!extractionReady && coverageMode === 'database' ? (
|
| 540 |
+
<p className="mt-2 text-[11px] text-warning">
|
| 541 |
+
Database-only mode: no extraction needed — your uploaded columns are kept as-is.
|
| 542 |
+
Switch to “Add missing only” or “All fields” to run the LLM.
|
| 543 |
+
</p>
|
| 544 |
+
) : (
|
| 545 |
+
<p className="mt-2 text-[11px] text-text-muted">
|
| 546 |
+
{textColumns.length} text column(s), {fieldCount} field(s) to extract ({coverageMode}).
|
| 547 |
+
</p>
|
| 548 |
+
)}
|
| 549 |
</Panel>
|
| 550 |
|
| 551 |
{result && (
|
frontend/src/types/index.ts
CHANGED
|
@@ -129,6 +129,23 @@ export interface ParseUploadResponse {
|
|
| 129 |
total_rows: number;
|
| 130 |
}
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
export interface CostEstimate {
|
| 133 |
total_usd: number;
|
| 134 |
cost_input_usd: number;
|
|
@@ -227,3 +244,10 @@ export interface ContingencyResponse {
|
|
| 227 |
v: number;
|
| 228 |
n: number;
|
| 229 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
total_rows: number;
|
| 130 |
}
|
| 131 |
|
| 132 |
+
export type CoverageMode = 'all' | 'missing' | 'database';
|
| 133 |
+
|
| 134 |
+
export interface SchemaCoverageField {
|
| 135 |
+
path: string;
|
| 136 |
+
leaf: string;
|
| 137 |
+
present: boolean;
|
| 138 |
+
matched_column: string | null;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export interface SchemaCoverageResponse {
|
| 142 |
+
coverage: SchemaCoverageField[];
|
| 143 |
+
summary: { present: number; missing: number; total: number; db_only: number };
|
| 144 |
+
matched_columns: string[];
|
| 145 |
+
db_only_columns: string[];
|
| 146 |
+
variants: Record<CoverageMode, { schema_json: string; n_fields: number }>;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
export interface CostEstimate {
|
| 150 |
total_usd: number;
|
| 151 |
cost_input_usd: number;
|
|
|
|
| 244 |
v: number;
|
| 245 |
n: number;
|
| 246 |
}
|
| 247 |
+
|
| 248 |
+
export interface XgboostImportanceResponse {
|
| 249 |
+
results: Record<string, XGBoostResult>;
|
| 250 |
+
columns: string[];
|
| 251 |
+
skipped: Record<string, string>;
|
| 252 |
+
message?: string;
|
| 253 |
+
}
|
parsing.py
CHANGED
|
@@ -24,7 +24,7 @@ import openai
|
|
| 24 |
from openai import OpenAI
|
| 25 |
import os
|
| 26 |
import json
|
| 27 |
-
|
| 28 |
import plotly.graph_objects as go
|
| 29 |
|
| 30 |
# st.set_option('deprecation.showPyplotGlobalUse', False)
|
|
@@ -389,8 +389,20 @@ def attach_kept_columns(df):
|
|
| 389 |
return out
|
| 390 |
|
| 391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
def convert_cached_data_to_df(parsed_responses):
|
| 393 |
-
if not parsed_responses:
|
| 394 |
st.warning("No cached data available. Please parse the dataset first.")
|
| 395 |
return
|
| 396 |
try:
|
|
@@ -428,7 +440,7 @@ def render_scu_normalization(parsed_responses):
|
|
| 428 |
then surfaces the SCU five-criterion eligibility funnel and download buttons.
|
| 429 |
Toggled on/off via the global "Apply SCU normalization" switch in app.py.
|
| 430 |
"""
|
| 431 |
-
if not parsed_responses:
|
| 432 |
st.info("No parsed data to normalize yet.")
|
| 433 |
return
|
| 434 |
try:
|
|
@@ -762,6 +774,17 @@ def convert_df(df):
|
|
| 762 |
|
| 763 |
|
| 764 |
def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
"""
|
| 766 |
Adds a UI on top of a dataframe to let viewers filter columns
|
| 767 |
|
|
@@ -824,24 +847,47 @@ def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 824 |
st.pyplot(plot_treemap(df_, column))
|
| 825 |
|
| 826 |
elif is_numeric_dtype(df_[column]):
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
|
| 840 |
-
|
| 841 |
-
|
| 842 |
|
| 843 |
-
|
| 844 |
-
|
| 845 |
|
| 846 |
elif is_object_dtype(df_[column]):
|
| 847 |
_orig_col = df_[column].copy()
|
|
@@ -945,7 +991,7 @@ def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 945 |
|
| 946 |
|
| 947 |
from config import (
|
| 948 |
-
FORMAT_LONG, FORMAT_LONG_XLSX, FORMAT_SCU_V2, FORMAT_SCU_V3, FORMAT_MERGED, FORMAT_UFOSETI_RU,
|
| 949 |
FORMAT_NUFORC, FORMAT_BLUE_BOOK, FORMAT_UK_NATIONAL_ARCHIVES,
|
| 950 |
FORMAT_COBEPS_NOTIFICATIONS_PAN, FORMAT_COBEPS_COB_2021,
|
| 951 |
FORMAT_GEP, FORMAT_UPDB_NICAP, FORMAT_OVNIBASE, FORMAT_UFOSETI,
|
|
@@ -964,6 +1010,7 @@ DEEPSEEK_KEY = st.secrets.get("DEEPSEEK_KEY", "")
|
|
| 964 |
# Both the format selector and the schema↔dataset comparison read from here so
|
| 965 |
# the two never drift apart.
|
| 966 |
SCHEMA_FORMATS = {
|
|
|
|
| 967 |
"Default UAP Format": FORMAT_LONG,
|
| 968 |
"SCU Spreadsheet": FORMAT_LONG_XLSX,
|
| 969 |
"SCU_v2": FORMAT_SCU_V2,
|
|
@@ -993,7 +1040,7 @@ SCHEMA_FORMATS = {
|
|
| 993 |
# the schema picker presents them grouped so the right standard is easy to find.
|
| 994 |
SCHEMA_FORMAT_GROUPS = {
|
| 995 |
"Canonical & SCU": [
|
| 996 |
-
"Default UAP Format", "SCU Spreadsheet", "SCU_v2", "SCU_v3",
|
| 997 |
],
|
| 998 |
"Government & official archives": [
|
| 999 |
"Blue Book (USAF)", "UK National Archives",
|
|
@@ -1019,6 +1066,11 @@ SCHEMA_FORMAT_GROUPS = {
|
|
| 1019 |
# Short provenance blurb per schema (drawn from the config.py header comments)
|
| 1020 |
# so the picker explains where each dataset/standard comes from at a glance.
|
| 1021 |
SCHEMA_FORMAT_ORIGINS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1022 |
"Default UAP Format":
|
| 1023 |
"The project's canonical nested narrative schema (`sightingDetails`). "
|
| 1024 |
"General-purpose GPT extraction template covering location, craft, "
|
|
@@ -1128,7 +1180,7 @@ def _current_dataset_columns() -> pd.DataFrame:
|
|
| 1128 |
df = st.session_state.get('parsed_responses_df')
|
| 1129 |
if isinstance(df, pd.DataFrame) and not df.empty:
|
| 1130 |
return df
|
| 1131 |
-
pr =
|
| 1132 |
if pr:
|
| 1133 |
try:
|
| 1134 |
return pd.json_normalize(list(pr.values()))
|
|
@@ -1208,6 +1260,49 @@ def render_schema_dataset_diff(schema_dict: dict, df: pd.DataFrame,
|
|
| 1208 |
"schema_dataset_diff.csv", "text/csv", key="dl_schema_dataset_diff",
|
| 1209 |
)
|
| 1210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
return {
|
| 1212 |
"matched": [s_map[k] for k in matched_k],
|
| 1213 |
"missing": [s_map[k] for k in missing_k],
|
|
@@ -1405,7 +1500,7 @@ if parsed_table_restore is not None:
|
|
| 1405 |
st.error(f"Could not read CSV/XLSX: {e}")
|
| 1406 |
|
| 1407 |
# ── JSON-only export controls (shown when JSON loaded but no CSV uploaded) ─────
|
| 1408 |
-
if json_restore is not None and unparsed is None and
|
| 1409 |
st.divider()
|
| 1410 |
st.markdown("**Loaded from JSON — export options**")
|
| 1411 |
|
|
@@ -1641,7 +1736,7 @@ if False:
|
|
| 1641 |
st.error("No files were processed successfully.")
|
| 1642 |
|
| 1643 |
# ── Inline export controls (available after processing) ───────────────
|
| 1644 |
-
if
|
| 1645 |
_pr = st.session_state['parsed_responses']
|
| 1646 |
_json_dl = json.dumps(_pr, indent=2)
|
| 1647 |
st.download_button(
|
|
@@ -1838,6 +1933,21 @@ if unparsed is not None:
|
|
| 1838 |
_merged_dict = _deep_merge(_merged_dict, _custom_extra)
|
| 1839 |
active_format = json.dumps(_merged_dict, indent=2)
|
| 1840 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1841 |
st.markdown("### Preview / Edit JSON Format")
|
| 1842 |
_tab_tree, _tab_fields, _tab_raw = st.tabs(["🌳 Tree", "📋 Fields", "✏️ Raw"])
|
| 1843 |
|
|
@@ -2222,7 +2332,7 @@ if unparsed is not None:
|
|
| 2222 |
st.error(f"An error occurred: {str(e)}")
|
| 2223 |
|
| 2224 |
# ── Download / export ──────────────────────────────────────────────────────
|
| 2225 |
-
if
|
| 2226 |
json_str = download_json(st.session_state['parsed_responses'])
|
| 2227 |
st.download_button(
|
| 2228 |
label="Download Parsed Data as JSON",
|
|
@@ -2248,7 +2358,7 @@ if unparsed is not None:
|
|
| 2248 |
|
| 2249 |
# Allow loading a previously saved parsed_responses.json when there is
|
| 2250 |
# no live session (e.g. user reloaded the page after parsing).
|
| 2251 |
-
if
|
| 2252 |
st.info(
|
| 2253 |
"No parsed data in session. Upload a previously saved "
|
| 2254 |
"`parsed_responses.json` **or** an SCU-format `.csv` to continue."
|
|
@@ -2277,7 +2387,7 @@ if unparsed is not None:
|
|
| 2277 |
st.session_state['_scu_csv_df'] = pd.read_csv(csv_upload)
|
| 2278 |
st.success(f"Loaded CSV: {len(st.session_state['_scu_csv_df'])} rows.")
|
| 2279 |
|
| 2280 |
-
if
|
| 2281 |
col_t, col_n = st.columns(2)
|
| 2282 |
xlsx_template = col_t.file_uploader(
|
| 2283 |
"Upload template spreadsheet (.xlsx)",
|
|
@@ -2439,15 +2549,18 @@ with st.expander("🧬 Compute Embeddings → HDF5", expanded=False):
|
|
| 2439 |
# Markdown ingestion) and expose the SCU v2 normalized CSV + audit report.
|
| 2440 |
if st.session_state.get('scu_normalize_enabled'):
|
| 2441 |
st.divider()
|
| 2442 |
-
if
|
| 2443 |
-
|
| 2444 |
-
|
| 2445 |
-
|
| 2446 |
-
|
| 2447 |
-
|
| 2448 |
-
|
| 2449 |
-
|
| 2450 |
-
|
|
|
|
|
|
|
|
|
|
| 2451 |
else:
|
| 2452 |
st.caption(
|
| 2453 |
"🧹 SCU Normalization is enabled — parse a dataset or load a "
|
|
|
|
| 24 |
from openai import OpenAI
|
| 25 |
import os
|
| 26 |
import json
|
| 27 |
+
from utils.data_processing import DataProcessor
|
| 28 |
import plotly.graph_objects as go
|
| 29 |
|
| 30 |
# st.set_option('deprecation.showPyplotGlobalUse', False)
|
|
|
|
| 389 |
return out
|
| 390 |
|
| 391 |
|
| 392 |
+
def _parsed_responses_dict():
|
| 393 |
+
"""The parsing dict ({description: parsed_json}) from session state, or None.
|
| 394 |
+
|
| 395 |
+
Other pages (analyzing.py, map.py, …) store a *DataFrame* under the shared
|
| 396 |
+
'parsed_responses' session key. Returning None for any non-dict value keeps
|
| 397 |
+
parsing.py's dict-only paths from raising "The truth value of a DataFrame is
|
| 398 |
+
ambiguous" when the user navigates back here after using another page.
|
| 399 |
+
"""
|
| 400 |
+
pr = st.session_state.get('parsed_responses')
|
| 401 |
+
return pr if isinstance(pr, dict) and pr else None
|
| 402 |
+
|
| 403 |
+
|
| 404 |
def convert_cached_data_to_df(parsed_responses):
|
| 405 |
+
if not isinstance(parsed_responses, dict) or not parsed_responses:
|
| 406 |
st.warning("No cached data available. Please parse the dataset first.")
|
| 407 |
return
|
| 408 |
try:
|
|
|
|
| 440 |
then surfaces the SCU five-criterion eligibility funnel and download buttons.
|
| 441 |
Toggled on/off via the global "Apply SCU normalization" switch in app.py.
|
| 442 |
"""
|
| 443 |
+
if not isinstance(parsed_responses, dict) or not parsed_responses:
|
| 444 |
st.info("No parsed data to normalize yet.")
|
| 445 |
return
|
| 446 |
try:
|
|
|
|
| 774 |
|
| 775 |
|
| 776 |
def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
| 777 |
+
"""Shared filtering UI — delegates to the DataProcessor so the parsing and
|
| 778 |
+
analysis pages use one and the same filter system (mirrors
|
| 779 |
+
``analyzing.py``'s ``filter_dataframe``). Binary 0/1 columns get a value
|
| 780 |
+
picker; other numerics get range/percentile/std-dev modes.
|
| 781 |
+
"""
|
| 782 |
+
return DataProcessor.filter_dataframe_enhanced(
|
| 783 |
+
df, enable_quick_filters=False, enable_advanced_filters=True
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
def filter_dataframe_legacy(df: pd.DataFrame) -> pd.DataFrame:
|
| 788 |
"""
|
| 789 |
Adds a UI on top of a dataframe to let viewers filter columns
|
| 790 |
|
|
|
|
| 847 |
st.pyplot(plot_treemap(df_, column))
|
| 848 |
|
| 849 |
elif is_numeric_dtype(df_[column]):
|
| 850 |
+
_nonnull = df_[column].dropna()
|
| 851 |
+
_uniq = set(_nonnull.unique())
|
| 852 |
+
# Binary boolean columns (values ⊆ {0, 1}) — a slider is meaningless
|
| 853 |
+
# here, so offer a value picker (0/1) instead of a min–max range.
|
| 854 |
+
if _uniq and _uniq.issubset({0, 1}):
|
| 855 |
+
_opts = sorted(_uniq)
|
| 856 |
+
user_bin_input = right.multiselect(
|
| 857 |
+
f"Values for {column}",
|
| 858 |
+
_opts,
|
| 859 |
+
default=_opts,
|
| 860 |
+
format_func=lambda v: f"{int(v)} — {'true' if int(v) == 1 else 'false'}",
|
| 861 |
+
key=f"filt_bin_{column}",
|
| 862 |
+
)
|
| 863 |
+
df_ = df_[df_[column].isin(user_bin_input)]
|
| 864 |
+
filtered_columns.append(column)
|
| 865 |
+
|
| 866 |
+
with st.status(f"Binary Distribution: {column}", expanded=False):
|
| 867 |
+
_vc = df_[column].value_counts().sort_index()
|
| 868 |
+
_vc.index = _vc.index.map(
|
| 869 |
+
lambda v: f"{int(v)} ({'true' if int(v) == 1 else 'false'})"
|
| 870 |
+
)
|
| 871 |
+
st.bar_chart(_vc)
|
| 872 |
+
else:
|
| 873 |
+
_min = float(df_[column].min())
|
| 874 |
+
_max = float(df_[column].max())
|
| 875 |
+
step = (_max - _min) / 100
|
| 876 |
+
user_num_input = right.slider(
|
| 877 |
+
f"Values for {column}",
|
| 878 |
+
min_value=_min,
|
| 879 |
+
max_value=_max,
|
| 880 |
+
value=(_min, _max),
|
| 881 |
+
step=step,
|
| 882 |
+
)
|
| 883 |
+
df_ = df_[df_[column].between(*user_num_input)]
|
| 884 |
+
filtered_columns.append(column)
|
| 885 |
|
| 886 |
+
# Chart_GPT = ChartGPT(df_, title_font, body_font, title_size,
|
| 887 |
+
# colors, interpretation, extract_docx, img_path)
|
| 888 |
|
| 889 |
+
with st.status(f"Numerical Distribution: {column}", expanded=False) as stat_:
|
| 890 |
+
st.pyplot(plot_hist(df_, column, bins=int(round(len(df_[column].unique())-1)/2)))
|
| 891 |
|
| 892 |
elif is_object_dtype(df_[column]):
|
| 893 |
_orig_col = df_[column].copy()
|
|
|
|
| 991 |
|
| 992 |
|
| 993 |
from config import (
|
| 994 |
+
FORMAT_LONG, FORMAT_LONG_XLSX, FORMAT_SCU_V1, FORMAT_SCU_V2, FORMAT_SCU_V3, FORMAT_MERGED, FORMAT_UFOSETI_RU,
|
| 995 |
FORMAT_NUFORC, FORMAT_BLUE_BOOK, FORMAT_UK_NATIONAL_ARCHIVES,
|
| 996 |
FORMAT_COBEPS_NOTIFICATIONS_PAN, FORMAT_COBEPS_COB_2021,
|
| 997 |
FORMAT_GEP, FORMAT_UPDB_NICAP, FORMAT_OVNIBASE, FORMAT_UFOSETI,
|
|
|
|
| 1010 |
# Both the format selector and the schema↔dataset comparison read from here so
|
| 1011 |
# the two never drift apart.
|
| 1012 |
SCHEMA_FORMATS = {
|
| 1013 |
+
"SCU_v1": FORMAT_SCU_V1,
|
| 1014 |
"Default UAP Format": FORMAT_LONG,
|
| 1015 |
"SCU Spreadsheet": FORMAT_LONG_XLSX,
|
| 1016 |
"SCU_v2": FORMAT_SCU_V2,
|
|
|
|
| 1040 |
# the schema picker presents them grouped so the right standard is easy to find.
|
| 1041 |
SCHEMA_FORMAT_GROUPS = {
|
| 1042 |
"Canonical & SCU": [
|
| 1043 |
+
"SCU_v1", "Default UAP Format", "SCU Spreadsheet", "SCU_v2", "SCU_v3",
|
| 1044 |
],
|
| 1045 |
"Government & official archives": [
|
| 1046 |
"Blue Book (USAF)", "UK National Archives",
|
|
|
|
| 1066 |
# Short provenance blurb per schema (drawn from the config.py header comments)
|
| 1067 |
# so the picker explains where each dataset/standard comes from at a glance.
|
| 1068 |
SCHEMA_FORMAT_ORIGINS = {
|
| 1069 |
+
"SCU_v1":
|
| 1070 |
+
"The default extraction schema — the full SCU_v3 field set minus the "
|
| 1071 |
+
"verbose `sightingDetails` narrative block and the all-null `manifest` "
|
| 1072 |
+
"join placeholders. Keeps source, assessment, anomaly, location, "
|
| 1073 |
+
"witness, craft, performance, military, effects and engagement fields.",
|
| 1074 |
"Default UAP Format":
|
| 1075 |
"The project's canonical nested narrative schema (`sightingDetails`). "
|
| 1076 |
"General-purpose GPT extraction template covering location, craft, "
|
|
|
|
| 1180 |
df = st.session_state.get('parsed_responses_df')
|
| 1181 |
if isinstance(df, pd.DataFrame) and not df.empty:
|
| 1182 |
return df
|
| 1183 |
+
pr = _parsed_responses_dict()
|
| 1184 |
if pr:
|
| 1185 |
try:
|
| 1186 |
return pd.json_normalize(list(pr.values()))
|
|
|
|
| 1260 |
"schema_dataset_diff.csv", "text/csv", key="dl_schema_dataset_diff",
|
| 1261 |
)
|
| 1262 |
|
| 1263 |
+
# ── Send a field subset to the extraction JSON editor ─────────────────────
|
| 1264 |
+
# Each button builds a pruned JSON schema from one side of the diff and
|
| 1265 |
+
# stashes it in session_state; the "UAP Feature Extraction" JSON editor
|
| 1266 |
+
# (rendered later in the same page) picks it up and fills its text area.
|
| 1267 |
+
flat_schema = _flatten_dotted(schema_dict) # {dotted schema path: description}
|
| 1268 |
+
src_payload = dict(flat_schema) # every schema field
|
| 1269 |
+
both_payload = {s_map[k]: flat_schema.get(s_map[k], "string") for k in matched_k} # ✓ in both
|
| 1270 |
+
miss_payload = {s_map[k]: flat_schema.get(s_map[k], "string") for k in missing_k} # 🔴 missing
|
| 1271 |
+
dest_payload = {d_map[k]: flat_schema.get(s_map[k], "string") for k in matched_k} # dataset columns
|
| 1272 |
+
dest_payload.update({d_map[k]: "string — in dataset, not defined by the schema"
|
| 1273 |
+
for k in extra_k})
|
| 1274 |
+
|
| 1275 |
+
st.caption(
|
| 1276 |
+
"**Send to extract** — fill the JSON Format editor on the **UAP Feature "
|
| 1277 |
+
"Extraction** step with one side of this diff:"
|
| 1278 |
+
)
|
| 1279 |
+
|
| 1280 |
+
def _send_to_extract(note: str, payload: dict) -> None:
|
| 1281 |
+
n = len(payload)
|
| 1282 |
+
st.session_state["_extract_seed_json"] = json.dumps(
|
| 1283 |
+
_unflatten_dotted(payload), indent=2
|
| 1284 |
+
)
|
| 1285 |
+
st.session_state["_extract_seed_note"] = f"{note} ({n} field{'' if n == 1 else 's'})"
|
| 1286 |
+
st.toast(f"📤 Sent {n} field{'' if n == 1 else 's'} to the extraction JSON editor.")
|
| 1287 |
+
|
| 1288 |
+
sb1, sb2, sb3, sb4 = st.columns(4)
|
| 1289 |
+
if sb1.button(f"📤 Source · {len(src_payload)}", key="send_extract_source",
|
| 1290 |
+
use_container_width=True,
|
| 1291 |
+
help="All fields defined by the selected schema."):
|
| 1292 |
+
_send_to_extract("Source / schema fields", src_payload)
|
| 1293 |
+
if sb2.button(f"📤 Destination · {len(dest_payload)}", key="send_extract_dest",
|
| 1294 |
+
use_container_width=True,
|
| 1295 |
+
help="Every column in the uploaded dataset (matched + 🟢 extra)."):
|
| 1296 |
+
_send_to_extract("Destination / dataset columns", dest_payload)
|
| 1297 |
+
if sb3.button(f"📤 Missing · {len(miss_payload)}", key="send_extract_missing",
|
| 1298 |
+
use_container_width=True, disabled=not miss_payload,
|
| 1299 |
+
help="🔴 Schema fields the dataset lacks — extract these to fill the gaps."):
|
| 1300 |
+
_send_to_extract("Missing fields", miss_payload)
|
| 1301 |
+
if sb4.button(f"📤 Both · {len(both_payload)}", key="send_extract_both",
|
| 1302 |
+
use_container_width=True, disabled=not both_payload,
|
| 1303 |
+
help="✓ Fields present in both the schema and the dataset."):
|
| 1304 |
+
_send_to_extract("Matched fields", both_payload)
|
| 1305 |
+
|
| 1306 |
return {
|
| 1307 |
"matched": [s_map[k] for k in matched_k],
|
| 1308 |
"missing": [s_map[k] for k in missing_k],
|
|
|
|
| 1500 |
st.error(f"Could not read CSV/XLSX: {e}")
|
| 1501 |
|
| 1502 |
# ── JSON-only export controls (shown when JSON loaded but no CSV uploaded) ─────
|
| 1503 |
+
if json_restore is not None and unparsed is None and _parsed_responses_dict():
|
| 1504 |
st.divider()
|
| 1505 |
st.markdown("**Loaded from JSON — export options**")
|
| 1506 |
|
|
|
|
| 1736 |
st.error("No files were processed successfully.")
|
| 1737 |
|
| 1738 |
# ── Inline export controls (available after processing) ───────────────
|
| 1739 |
+
if _parsed_responses_dict() and md_files:
|
| 1740 |
_pr = st.session_state['parsed_responses']
|
| 1741 |
_json_dl = json.dumps(_pr, indent=2)
|
| 1742 |
st.download_button(
|
|
|
|
| 1933 |
_merged_dict = _deep_merge(_merged_dict, _custom_extra)
|
| 1934 |
active_format = json.dumps(_merged_dict, indent=2)
|
| 1935 |
|
| 1936 |
+
# A "Send to extract" action from the schema↔dataset diff (above) seeds this
|
| 1937 |
+
# editor with a chosen field subset. Drop the persisted widget value so the
|
| 1938 |
+
# new payload becomes the text area's content (setting the keyed value
|
| 1939 |
+
# directly would clash with the value= arg passed to the widget below).
|
| 1940 |
+
_seed = st.session_state.pop("_extract_seed_json", None)
|
| 1941 |
+
if _seed is not None:
|
| 1942 |
+
active_format = _seed
|
| 1943 |
+
st.session_state.pop("preview_json_text", None)
|
| 1944 |
+
_seed_note = st.session_state.pop("_extract_seed_note", "")
|
| 1945 |
+
st.success(
|
| 1946 |
+
f"📥 Loaded {_seed_note} from the schema diff into the editor."
|
| 1947 |
+
if _seed_note else
|
| 1948 |
+
"📥 Loaded fields from the schema diff into the editor."
|
| 1949 |
+
)
|
| 1950 |
+
|
| 1951 |
st.markdown("### Preview / Edit JSON Format")
|
| 1952 |
_tab_tree, _tab_fields, _tab_raw = st.tabs(["🌳 Tree", "📋 Fields", "✏️ Raw"])
|
| 1953 |
|
|
|
|
| 2332 |
st.error(f"An error occurred: {str(e)}")
|
| 2333 |
|
| 2334 |
# ── Download / export ──────────────────────────────────────────────────────
|
| 2335 |
+
if _parsed_responses_dict() is not None:
|
| 2336 |
json_str = download_json(st.session_state['parsed_responses'])
|
| 2337 |
st.download_button(
|
| 2338 |
label="Download Parsed Data as JSON",
|
|
|
|
| 2358 |
|
| 2359 |
# Allow loading a previously saved parsed_responses.json when there is
|
| 2360 |
# no live session (e.g. user reloaded the page after parsing).
|
| 2361 |
+
if _parsed_responses_dict() is None:
|
| 2362 |
st.info(
|
| 2363 |
"No parsed data in session. Upload a previously saved "
|
| 2364 |
"`parsed_responses.json` **or** an SCU-format `.csv` to continue."
|
|
|
|
| 2387 |
st.session_state['_scu_csv_df'] = pd.read_csv(csv_upload)
|
| 2388 |
st.success(f"Loaded CSV: {len(st.session_state['_scu_csv_df'])} rows.")
|
| 2389 |
|
| 2390 |
+
if _parsed_responses_dict() is not None or '_scu_csv_df' in st.session_state:
|
| 2391 |
col_t, col_n = st.columns(2)
|
| 2392 |
xlsx_template = col_t.file_uploader(
|
| 2393 |
"Upload template spreadsheet (.xlsx)",
|
|
|
|
| 2549 |
# Markdown ingestion) and expose the SCU v2 normalized CSV + audit report.
|
| 2550 |
if st.session_state.get('scu_normalize_enabled'):
|
| 2551 |
st.divider()
|
| 2552 |
+
if _parsed_responses_dict():
|
| 2553 |
+
# Titled section, not an expander: render_scu_normalization opens its own
|
| 2554 |
+
# "Normalization audit report" expander, and Streamlit forbids nesting
|
| 2555 |
+
# expanders inside expanders.
|
| 2556 |
+
st.markdown("#### 🧹 SCU Normalization (SCU v2)")
|
| 2557 |
+
st.markdown(
|
| 2558 |
+
"Canonicalises the parsed data — country ISO codes, US-state "
|
| 2559 |
+
"codes, witness roles, craft shape/size bands — and derives the "
|
| 2560 |
+
"**SCU five-criterion eligibility gate** (`scu_eligible` plus the "
|
| 2561 |
+
"per-criterion columns). Turn this off in the app sidebar to hide."
|
| 2562 |
+
)
|
| 2563 |
+
render_scu_normalization(st.session_state['parsed_responses'])
|
| 2564 |
else:
|
| 2565 |
st.caption(
|
| 2566 |
"🧹 SCU Normalization is enabled — parse a dataset or load a "
|
pipeline/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# `pipeline/` — Document Preprocessing scripts (vendored)
|
| 2 |
+
|
| 3 |
+
These are the standalone scripts that turn raw government PDFs into the report
|
| 4 |
+
table consumed by the **UAP Feature Extraction** page. They used to live in an
|
| 5 |
+
external directory (`D:\divided` / `/mnt/d/divided`); they are now vendored here
|
| 6 |
+
so the **Document Preprocessing Pipeline** Streamlit page (`preprocessing.py`)
|
| 7 |
+
is self-contained — no external script folder, no separate interpreter.
|
| 8 |
+
|
| 9 |
+
## How they're run
|
| 10 |
+
|
| 11 |
+
`preprocessing.py` invokes each script with the **app's own interpreter**
|
| 12 |
+
(`sys.executable`) and an absolute path into this folder, while the **working
|
| 13 |
+
directory** (the configurable data dir, default `pipeline_data/`) is the cwd.
|
| 14 |
+
So the scripts read/write `raw/`, `pages_out/`, `concat/`, `extracted/`, … in
|
| 15 |
+
the working dir, and their code ships with the app. Each script is unmodified
|
| 16 |
+
from the original pipeline and remains runnable directly:
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
python pipeline/page_coverage.py --root /path/to/workdir
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## Stages
|
| 23 |
+
|
| 24 |
+
| Stage | Scripts |
|
| 25 |
+
|-------|---------|
|
| 26 |
+
| 0 · Scrape | `download_uap_pdfs.py` |
|
| 27 |
+
| 1 · Layout | `split_pages.py`, `restructure_pages.py`, `reorganize.py` |
|
| 28 |
+
| 2 · OCR | `stamp_pages.py`, `find_ocr_targets.py`, `run_ocr.py`, `destamp_pages.py` |
|
| 29 |
+
| 3 · Assembly | `page_coverage.py`, `concat_pages.py` |
|
| 30 |
+
| 4 · Extraction | `extract_reports.py`, `pdf_to_reports.py`, `reconcile.py` |
|
| 31 |
+
| 5 · Table | `add_source_agency.py`, `analyze_reports.py`, `join_reports.py`, `map_yaml_to_reports.py` |
|
| 32 |
+
| Audit | `audit.py`, `find_missing_concat.py` |
|
| 33 |
+
|
| 34 |
+
## Dependencies
|
| 35 |
+
|
| 36 |
+
Pinned in the project's `pyproject.toml` / `requirements.txt`, so the app's
|
| 37 |
+
interpreter already has them: `pyyaml`, `google-genai`, `pypdf`, `mistralai`,
|
| 38 |
+
`openai`, `pdfplumber`, `reportlab`, `pdf2image`, `pillow`, `pymupdf`,
|
| 39 |
+
`requests`. `pdf2image` additionally needs the system **poppler** binary
|
| 40 |
+
(`apt-get install poppler-utils`).
|
| 41 |
+
|
| 42 |
+
API keys (entered in the page or via `st.secrets`): `MISTRAL_API_KEY` (OCR),
|
| 43 |
+
`GEMINI_API_KEY` (extraction), `NVIDIA_API_KEY` (optional NIM extraction).
|
pipeline/add_source_agency.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
add_source_agency.py
|
| 3 |
+
────────────────────
|
| 4 |
+
Reads an Excel file and adds two columns after 'may_release_source_file':
|
| 5 |
+
• agency — top-level agency folder (DOD, FBI, NASA, DOS, NARA-CIA, MISC)
|
| 6 |
+
• collection — sub-folder within agency (mission-reports, series-38, transcripts …)
|
| 7 |
+
|
| 8 |
+
Path/slug resolution order for both columns:
|
| 9 |
+
1. Full or relative path → parse path parts directly
|
| 10 |
+
2. Slug prefix map → e.g. "dow-uap-*" → DOD / mission-reports
|
| 11 |
+
3. Substring scan → look for known tokens anywhere in the value
|
| 12 |
+
|
| 13 |
+
Usage
|
| 14 |
+
-----
|
| 15 |
+
python add_source_agency.py
|
| 16 |
+
python add_source_agency.py --input my_file.xlsx --col file_path --out out.xlsx
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import re
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
| 23 |
+
|
| 24 |
+
import pandas as pd
|
| 25 |
+
|
| 26 |
+
# ── Agency tokens (longer first to avoid sub-match, e.g. NARA-CIA before NASA) ─
|
| 27 |
+
AGENCY_TOKENS = ["NARA-CIA", "NARA_CIA", "NASA", "DOD", "FBI", "DOS", "MISC"]
|
| 28 |
+
|
| 29 |
+
# ── Fallback: map free-text agency names (from YAML) → canonical token ────────
|
| 30 |
+
YAML_AGENCY_FALLBACK: dict[str, str] = {
|
| 31 |
+
"department of war": "DOD",
|
| 32 |
+
"department of defense": "DOD",
|
| 33 |
+
"dod": "DOD",
|
| 34 |
+
"fbi": "FBI",
|
| 35 |
+
"federal bureau of investigation": "FBI",
|
| 36 |
+
"nasa": "NASA",
|
| 37 |
+
"national aeronautics and space administration": "NASA",
|
| 38 |
+
"dos": "DOS",
|
| 39 |
+
"department of state": "DOS",
|
| 40 |
+
"nara-cia": "NARA-CIA",
|
| 41 |
+
"nara_cia": "NARA-CIA",
|
| 42 |
+
"misc": "MISC",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# ── Normalised agency set for fast membership tests ───────────────────────────
|
| 46 |
+
_AGENCY_SET = {"NARA-CIA", "NASA", "DOD", "FBI", "DOS", "MISC"}
|
| 47 |
+
|
| 48 |
+
# ── Known collection sub-folders per agency ────────────────────────────────────
|
| 49 |
+
# Values are the canonical collection name returned by the script.
|
| 50 |
+
COLLECTION_TOKENS = {
|
| 51 |
+
# DOD
|
| 52 |
+
"mission-reports": "mission-reports",
|
| 53 |
+
"mission_reports": "mission-reports",
|
| 54 |
+
"range-fouler-debriefs": "range-fouler-debriefs",
|
| 55 |
+
"range_fouler_debriefs": "range-fouler-debriefs",
|
| 56 |
+
"email-correspondence": "email-correspondence",
|
| 57 |
+
"email_correspondence": "email-correspondence",
|
| 58 |
+
"reports-other": "reports-other",
|
| 59 |
+
"reports_other": "reports-other",
|
| 60 |
+
# FBI
|
| 61 |
+
"photo-collections": "photo-collections",
|
| 62 |
+
"photo_collections": "photo-collections",
|
| 63 |
+
# NASA
|
| 64 |
+
"transcripts": "transcripts",
|
| 65 |
+
"crew-debriefings": "crew-debriefings",
|
| 66 |
+
"crew_debriefings": "crew-debriefings",
|
| 67 |
+
# DOS
|
| 68 |
+
"cables": "cables",
|
| 69 |
+
# NARA-CIA series / collections
|
| 70 |
+
"hs1-834228961": "hs1-834228961",
|
| 71 |
+
"hs1-101634279": "hs1-101634279",
|
| 72 |
+
"series-18": "series-18",
|
| 73 |
+
"series-38": "series-38",
|
| 74 |
+
"series-59": "series-59",
|
| 75 |
+
"series-255": "series-255",
|
| 76 |
+
"series-331": "series-331",
|
| 77 |
+
"series-341": "series-341",
|
| 78 |
+
"series-342": "series-342",
|
| 79 |
+
# MISC
|
| 80 |
+
"statements-redacted": "statements-redacted",
|
| 81 |
+
"statements_redacted": "statements-redacted",
|
| 82 |
+
"visuals": "visuals",
|
| 83 |
+
"presentations": "presentations",
|
| 84 |
+
"unclassified": "unclassified",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
# ── Slug-prefix → (agency, collection) ────────────────────────────────────────
|
| 88 |
+
# Keyed by lowercase slug prefix; longest prefixes first.
|
| 89 |
+
PREFIX_MAP: list[tuple[str, str, str | None]] = [
|
| 90 |
+
# DOD mission reports
|
| 91 |
+
("dow-uap-d", "DOD", "mission-reports"),
|
| 92 |
+
("dow-uap-", "DOD", "mission-reports"),
|
| 93 |
+
("dod-range-fouler", "DOD", "range-fouler-debriefs"),
|
| 94 |
+
("dod-email", "DOD", "email-correspondence"),
|
| 95 |
+
("dod-", "DOD", None),
|
| 96 |
+
("pr-", "DOD", "mission-reports"),
|
| 97 |
+
# FBI
|
| 98 |
+
("fbi-", "FBI", "photo-collections"),
|
| 99 |
+
# NASA
|
| 100 |
+
("nasa-transcript", "NASA", "transcripts"),
|
| 101 |
+
("nasa-crew", "NASA", "crew-debriefings"),
|
| 102 |
+
("nasa-", "NASA", None),
|
| 103 |
+
# DOS
|
| 104 |
+
("dos-", "DOS", "cables"),
|
| 105 |
+
# NARA-CIA series (slug starts with series number)
|
| 106 |
+
("65_hs1-834228961", "NARA-CIA", "hs1-834228961"),
|
| 107 |
+
("65_hs1-101634279", "NARA-CIA", "hs1-101634279"),
|
| 108 |
+
("18_", "NARA-CIA", "series-18"),
|
| 109 |
+
("38_", "NARA-CIA", "series-38"),
|
| 110 |
+
("59_", "NARA-CIA", "series-59"),
|
| 111 |
+
("255_", "NARA-CIA", "series-255"),
|
| 112 |
+
("331_", "NARA-CIA", "series-331"),
|
| 113 |
+
("341_", "NARA-CIA", "series-341"),
|
| 114 |
+
("342_", "NARA-CIA", "series-342"),
|
| 115 |
+
("series-", "NARA-CIA", None),
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
AGENCY_RE = re.compile(
|
| 119 |
+
r"\b(" + "|".join(re.escape(a) for a in AGENCY_TOKENS) + r")\b",
|
| 120 |
+
re.IGNORECASE,
|
| 121 |
+
)
|
| 122 |
+
COLLECTION_RE = re.compile(
|
| 123 |
+
r"\b(" + "|".join(re.escape(k) for k in sorted(COLLECTION_TOKENS, key=len, reverse=True)) + r")\b",
|
| 124 |
+
re.IGNORECASE,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _norm_agency(token: str) -> str:
|
| 129 |
+
return token.upper().replace("_", "-")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _path_parts(value: str) -> list[str]:
|
| 133 |
+
"""Return path parts trying both Windows and Posix interpretations."""
|
| 134 |
+
for PathCls in (PureWindowsPath, PurePosixPath):
|
| 135 |
+
try:
|
| 136 |
+
parts = list(PathCls(value).parts)
|
| 137 |
+
if len(parts) > 1:
|
| 138 |
+
return parts
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
return [value]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def extract_both(value) -> tuple[str | None, str | None]:
|
| 145 |
+
"""Return (agency, collection) for a single cell value."""
|
| 146 |
+
if not isinstance(value, str) or not value.strip():
|
| 147 |
+
return None, None
|
| 148 |
+
|
| 149 |
+
v = value.strip()
|
| 150 |
+
lower = v.lower()
|
| 151 |
+
|
| 152 |
+
# 1 ── Path-based: walk parts, find agency then take the next part ──────────
|
| 153 |
+
parts = _path_parts(v)
|
| 154 |
+
for i, part in enumerate(parts):
|
| 155 |
+
norm = _norm_agency(part)
|
| 156 |
+
if norm in _AGENCY_SET:
|
| 157 |
+
agency = norm
|
| 158 |
+
# collection = next path segment after agency, if it exists and is known
|
| 159 |
+
collection = None
|
| 160 |
+
if i + 1 < len(parts):
|
| 161 |
+
nxt = parts[i + 1].lower().replace("_", "-")
|
| 162 |
+
collection = COLLECTION_TOKENS.get(nxt) or COLLECTION_TOKENS.get(parts[i + 1])
|
| 163 |
+
return agency, collection
|
| 164 |
+
|
| 165 |
+
# 2 ── Slug prefix map ─────────────────────────────────────────────────────
|
| 166 |
+
for prefix, agency, collection in PREFIX_MAP:
|
| 167 |
+
if lower.startswith(prefix):
|
| 168 |
+
# Try to refine collection from slug body if prefix gave None
|
| 169 |
+
if collection is None:
|
| 170 |
+
m = COLLECTION_RE.search(v)
|
| 171 |
+
if m:
|
| 172 |
+
collection = COLLECTION_TOKENS.get(m.group(1).lower().replace("_", "-"))
|
| 173 |
+
return agency, collection
|
| 174 |
+
|
| 175 |
+
# 3 ── Substring scan for agency ──────────────────────────────────────────
|
| 176 |
+
m_agency = AGENCY_RE.search(v)
|
| 177 |
+
if m_agency:
|
| 178 |
+
agency = _norm_agency(m_agency.group(1))
|
| 179 |
+
m_coll = COLLECTION_RE.search(v)
|
| 180 |
+
collection = COLLECTION_TOKENS.get(m_coll.group(1).lower().replace("_", "-")) if m_coll else None
|
| 181 |
+
return agency, collection
|
| 182 |
+
|
| 183 |
+
return None, None
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _find_path_column(df: pd.DataFrame) -> str | None:
|
| 187 |
+
"""Heuristic: find the column most likely to contain file paths or slugs."""
|
| 188 |
+
candidates = []
|
| 189 |
+
for col in df.columns:
|
| 190 |
+
sample = df[col].dropna().astype(str).head(30)
|
| 191 |
+
hits = sum(
|
| 192 |
+
1 for s in sample
|
| 193 |
+
if extract_both(s)[0] is not None
|
| 194 |
+
or any(sep in s for sep in ("/", "\\"))
|
| 195 |
+
)
|
| 196 |
+
if hits > 0:
|
| 197 |
+
candidates.append((hits, col))
|
| 198 |
+
if not candidates:
|
| 199 |
+
return None
|
| 200 |
+
candidates.sort(reverse=True)
|
| 201 |
+
return candidates[0][1]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _read_file(path: Path) -> pd.DataFrame:
|
| 205 |
+
"""Read CSV or Excel, detecting format from content (not just extension)."""
|
| 206 |
+
# Try CSV first by sniffing the first bytes
|
| 207 |
+
try:
|
| 208 |
+
with open(path, "rb") as fh:
|
| 209 |
+
header = fh.read(8)
|
| 210 |
+
is_excel = header[:4] in (b"\xd0\xcf\x11\xe0", b"PK\x03\x04") # XLS or XLSX magic
|
| 211 |
+
except OSError:
|
| 212 |
+
is_excel = path.suffix.lower() in (".xlsx", ".xls", ".xlsm")
|
| 213 |
+
|
| 214 |
+
if is_excel:
|
| 215 |
+
return pd.read_excel(path)
|
| 216 |
+
else:
|
| 217 |
+
# CSV — try common separators
|
| 218 |
+
for sep in (",", ";", "\t", "|"):
|
| 219 |
+
try:
|
| 220 |
+
df = pd.read_csv(path, sep=sep, dtype=str, encoding="utf-8-sig")
|
| 221 |
+
if len(df.columns) > 1:
|
| 222 |
+
return df
|
| 223 |
+
except Exception:
|
| 224 |
+
pass
|
| 225 |
+
# Last resort: let pandas infer
|
| 226 |
+
return pd.read_csv(path, dtype=str, encoding="utf-8-sig")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def process(input_path: Path, col_name: str | None, output_path: Path) -> None:
|
| 230 |
+
print(f"Reading: {input_path}")
|
| 231 |
+
df = _read_file(input_path)
|
| 232 |
+
print(f"Columns found: {list(df.columns)}")
|
| 233 |
+
|
| 234 |
+
if col_name:
|
| 235 |
+
if col_name not in df.columns:
|
| 236 |
+
sys.exit(f"ERROR: Column '{col_name}' not found. Available: {list(df.columns)}")
|
| 237 |
+
path_col = col_name
|
| 238 |
+
else:
|
| 239 |
+
path_col = _find_path_column(df)
|
| 240 |
+
if path_col is None:
|
| 241 |
+
sys.exit(
|
| 242 |
+
"ERROR: Could not auto-detect a path/slug column. "
|
| 243 |
+
"Re-run with --col <column_name>."
|
| 244 |
+
)
|
| 245 |
+
print(f"Auto-detected path column: '{path_col}'")
|
| 246 |
+
|
| 247 |
+
pairs = df[path_col].apply(extract_both)
|
| 248 |
+
agency_values = pairs.apply(lambda x: x[0])
|
| 249 |
+
collection_values = pairs.apply(lambda x: x[1])
|
| 250 |
+
|
| 251 |
+
# ── Fallback 1: may_release_agency_yaml column ────────────────────────────
|
| 252 |
+
# Rows where path extraction failed can borrow from the pre-existing YAML
|
| 253 |
+
# agency column (e.g. "Department of War" → "DOD").
|
| 254 |
+
YAML_COL = "may_release_agency_yaml"
|
| 255 |
+
if YAML_COL in df.columns:
|
| 256 |
+
null_mask = agency_values.isna()
|
| 257 |
+
if null_mask.any():
|
| 258 |
+
yaml_mapped = (
|
| 259 |
+
df.loc[null_mask, YAML_COL]
|
| 260 |
+
.fillna("")
|
| 261 |
+
.str.strip()
|
| 262 |
+
.str.lower()
|
| 263 |
+
.map(YAML_AGENCY_FALLBACK)
|
| 264 |
+
)
|
| 265 |
+
agency_values = agency_values.copy()
|
| 266 |
+
agency_values[null_mask] = yaml_mapped
|
| 267 |
+
fallback_filled = yaml_mapped.notna().sum()
|
| 268 |
+
print(f"Fallback from '{YAML_COL}': filled {fallback_filled} additional agency values")
|
| 269 |
+
|
| 270 |
+
# ── Fallback 2: updb_source column ───────────────────────────────────────
|
| 271 |
+
# Some rows have an updb_source that contains the source document slug.
|
| 272 |
+
for extra_col in ("updb_source", "overmeire_reference", "overmeire_Reference"):
|
| 273 |
+
if extra_col not in df.columns:
|
| 274 |
+
continue
|
| 275 |
+
null_mask = agency_values.isna()
|
| 276 |
+
if not null_mask.any():
|
| 277 |
+
break
|
| 278 |
+
extra_pairs = df.loc[null_mask, extra_col].apply(extract_both)
|
| 279 |
+
extra_agency = extra_pairs.apply(lambda x: x[0])
|
| 280 |
+
extra_coll = extra_pairs.apply(lambda x: x[1])
|
| 281 |
+
filled = extra_agency.notna().sum()
|
| 282 |
+
if filled:
|
| 283 |
+
agency_values = agency_values.copy()
|
| 284 |
+
collection_values = collection_values.copy()
|
| 285 |
+
agency_values[null_mask] = extra_agency.values
|
| 286 |
+
collection_values[null_mask] = extra_coll.values
|
| 287 |
+
print(f"Fallback from '{extra_col}': filled {filled} additional agency values")
|
| 288 |
+
|
| 289 |
+
# Insert both columns right after 'may_release_source_file' if it exists
|
| 290 |
+
if "may_release_source_file" in df.columns:
|
| 291 |
+
insert_pos = df.columns.get_loc("may_release_source_file") + 1
|
| 292 |
+
df.insert(insert_pos, "agency", agency_values)
|
| 293 |
+
df.insert(insert_pos + 1, "collection", collection_values)
|
| 294 |
+
else:
|
| 295 |
+
df["agency"] = agency_values
|
| 296 |
+
df["collection"] = collection_values
|
| 297 |
+
|
| 298 |
+
found_agency = df["agency"].notna().sum()
|
| 299 |
+
found_coll = df["collection"].notna().sum()
|
| 300 |
+
total = len(df)
|
| 301 |
+
print(f"agency resolved: {found_agency}/{total}")
|
| 302 |
+
print(f"collection resolved: {found_coll}/{total}")
|
| 303 |
+
|
| 304 |
+
if found_agency < total:
|
| 305 |
+
# Show a sample of what's still missing to guide further fixes
|
| 306 |
+
still_missing = df[df["agency"].isna()]
|
| 307 |
+
print(f"\nStill unresolved ({len(still_missing)} rows) — first 10 '{path_col}' values:")
|
| 308 |
+
for v in still_missing[path_col].head(10):
|
| 309 |
+
print(f" {str(v)[:100]!r}")
|
| 310 |
+
|
| 311 |
+
print(f"\nagency distribution:\n{df['agency'].value_counts(dropna=False).to_string()}")
|
| 312 |
+
print(f"\ncollection distribution:\n{df['collection'].value_counts(dropna=False).to_string()}\n")
|
| 313 |
+
|
| 314 |
+
if output_path.suffix.lower() in (".xlsx", ".xls", ".xlsm"):
|
| 315 |
+
df.to_excel(output_path, index=False)
|
| 316 |
+
else:
|
| 317 |
+
df.to_csv(output_path, index=False)
|
| 318 |
+
print(f"Saved → {output_path}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def main():
|
| 322 |
+
ap = argparse.ArgumentParser(
|
| 323 |
+
description="Add agency + collection columns to an Excel file"
|
| 324 |
+
)
|
| 325 |
+
ap.add_argument("--input", default="duplicates_UPDB_overmeire_reviewed_2.xlsx",
|
| 326 |
+
help="Input Excel file")
|
| 327 |
+
ap.add_argument("--col", default=None,
|
| 328 |
+
help="Column with file paths/slugs (auto-detected if omitted)")
|
| 329 |
+
ap.add_argument("--out", default=None,
|
| 330 |
+
help="Output path (default: <stem>_with_agency.xlsx)")
|
| 331 |
+
args = ap.parse_args()
|
| 332 |
+
|
| 333 |
+
input_path = Path(args.input)
|
| 334 |
+
if args.out:
|
| 335 |
+
output_path = Path(args.out)
|
| 336 |
+
else:
|
| 337 |
+
# Preserve the original extension so CSV in → CSV out
|
| 338 |
+
output_path = input_path.with_name(input_path.stem + "_with_agency" + input_path.suffix)
|
| 339 |
+
process(input_path, args.col, output_path)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
if __name__ == "__main__":
|
| 343 |
+
main()
|
pipeline/analyze_reports.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
analyze_reports.py
|
| 3 |
+
──────────────────
|
| 4 |
+
Reads _all_reports.json and flags structural and content anomalies.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python analyze_reports.py
|
| 8 |
+
python analyze_reports.py --input D:/divided/extracted/_all_reports.json
|
| 9 |
+
python analyze_reports.py --input _all_reports.json --out anomalies.md
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
+
import argparse
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
|
| 18 |
+
DEFAULT_INPUT = "D:/divided/extracted/_all_reports.json"
|
| 19 |
+
DEFAULT_OUT = "D:/divided/anomaly_report.md"
|
| 20 |
+
|
| 21 |
+
# ── thresholds ─────────────────────────────────────────────────────────────────
|
| 22 |
+
SHORT_TEXT_CHARS = 200 # raw_text shorter than this is suspicious
|
| 23 |
+
VERY_SHORT_CHARS = 50 # almost certainly truncated / empty
|
| 24 |
+
LONG_TEXT_CHARS = 60000 # single report longer than this may be two merged
|
| 25 |
+
ASSESS_DUP_RATIO = 0.95 # assessment/raw_text similarity threshold (len ratio)
|
| 26 |
+
MIN_REPORTS_PER_CHUNK = 0.2 # if reports / chunks < this, extraction may have failed
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_json(path: Path) -> list:
|
| 30 |
+
with open(path, encoding="utf-8") as f:
|
| 31 |
+
return json.load(f)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def page_label_to_ints(label: str) -> list[int]:
|
| 35 |
+
"""Extract all page numbers from a label like 'page_0004-page_0006'."""
|
| 36 |
+
return [int(m) for m in re.findall(r"(\d+)", label)]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def check_assessment_duplicate(raw: str, assess: str) -> bool:
|
| 40 |
+
"""Return True if assessment is essentially a copy of the end of raw_text."""
|
| 41 |
+
if not assess:
|
| 42 |
+
return False
|
| 43 |
+
assess_stripped = assess.strip()
|
| 44 |
+
raw_stripped = raw.strip()
|
| 45 |
+
# Check if assess appears verbatim in raw_text (common tail duplication)
|
| 46 |
+
if assess_stripped in raw_stripped:
|
| 47 |
+
return True
|
| 48 |
+
# Check length ratio — if assessment is almost as long as raw_text
|
| 49 |
+
if len(assess_stripped) / max(len(raw_stripped), 1) > ASSESS_DUP_RATIO:
|
| 50 |
+
return True
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def analyze(data: list) -> dict:
|
| 55 |
+
anomalies = defaultdict(list) # category → [items]
|
| 56 |
+
stats = {
|
| 57 |
+
"total_files": len(data),
|
| 58 |
+
"total_reports": 0,
|
| 59 |
+
"files_with_errors": 0,
|
| 60 |
+
"files_zero_reports": 0,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
for entry in data:
|
| 64 |
+
src = entry.get("source_file", "UNKNOWN")
|
| 65 |
+
chunks = entry.get("chunk_count", 1)
|
| 66 |
+
reports = entry.get("reports", [])
|
| 67 |
+
errors = entry.get("parse_errors", [])
|
| 68 |
+
|
| 69 |
+
stats["total_reports"] += len(reports)
|
| 70 |
+
|
| 71 |
+
# ── A1: API / parse errors ─────────────────────────────────────────────
|
| 72 |
+
if errors:
|
| 73 |
+
stats["files_with_errors"] += 1
|
| 74 |
+
for e in errors:
|
| 75 |
+
anomalies["A1_api_parse_error"].append({
|
| 76 |
+
"file": src,
|
| 77 |
+
"chunk": e.get("chunk"),
|
| 78 |
+
"error": e.get("error", ""),
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
# ── A2: Zero reports extracted ─────────────────────────────────────────
|
| 82 |
+
if len(reports) == 0:
|
| 83 |
+
stats["files_zero_reports"] += 1
|
| 84 |
+
anomalies["A2_zero_reports"].append({
|
| 85 |
+
"file": src,
|
| 86 |
+
"chunks": chunks,
|
| 87 |
+
"note": "No reports extracted — possible all-cover-page doc, "
|
| 88 |
+
"truly empty, or extraction failure",
|
| 89 |
+
})
|
| 90 |
+
continue # nothing to inspect inside reports
|
| 91 |
+
|
| 92 |
+
# ── A3: Low reports-per-chunk ratio ────────────────────────────────────
|
| 93 |
+
ratio = len(reports) / chunks
|
| 94 |
+
if ratio < MIN_REPORTS_PER_CHUNK and chunks > 2:
|
| 95 |
+
anomalies["A3_low_report_yield"].append({
|
| 96 |
+
"file": src,
|
| 97 |
+
"chunks": chunks,
|
| 98 |
+
"reports": len(reports),
|
| 99 |
+
"ratio": round(ratio, 2),
|
| 100 |
+
"note": "Very few reports relative to chunk count — "
|
| 101 |
+
"some chunks may have been silent failures",
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
# ── Per-report checks ──────────────────────────────────────────────────
|
| 105 |
+
seen_pages = []
|
| 106 |
+
|
| 107 |
+
for i, rep in enumerate(reports):
|
| 108 |
+
raw = rep.get("raw_text") or ""
|
| 109 |
+
assess = rep.get("assessment") or ""
|
| 110 |
+
pages = rep.get("pages")
|
| 111 |
+
rep_id = f"{src} · report {i+1}"
|
| 112 |
+
|
| 113 |
+
# ── A4: Null or missing pages label ───────────────────────────────
|
| 114 |
+
if not pages:
|
| 115 |
+
anomalies["A4_null_pages"].append({
|
| 116 |
+
"file": src,
|
| 117 |
+
"report": i + 1,
|
| 118 |
+
"note": "pages field is null — page attribution lost",
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
# ── A5: Very short raw_text ────────────────────────────────────────
|
| 122 |
+
if len(raw) < VERY_SHORT_CHARS:
|
| 123 |
+
anomalies["A5_very_short_text"].append({
|
| 124 |
+
"file": src,
|
| 125 |
+
"report": i + 1,
|
| 126 |
+
"pages": pages,
|
| 127 |
+
"char_count": len(raw),
|
| 128 |
+
"preview": raw[:80].replace("\n", " "),
|
| 129 |
+
"note": "raw_text < 50 chars — likely truncation or "
|
| 130 |
+
"cover-page / separator only",
|
| 131 |
+
})
|
| 132 |
+
elif len(raw) < SHORT_TEXT_CHARS:
|
| 133 |
+
anomalies["A6_short_text"].append({
|
| 134 |
+
"file": src,
|
| 135 |
+
"report": i + 1,
|
| 136 |
+
"pages": pages,
|
| 137 |
+
"char_count": len(raw),
|
| 138 |
+
"preview": raw[:120].replace("\n", " "),
|
| 139 |
+
"note": "raw_text < 200 chars — possibly incomplete",
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
# ── A7: Suspiciously long raw_text (possible merge of 2+ reports) ─
|
| 143 |
+
if len(raw) > LONG_TEXT_CHARS:
|
| 144 |
+
anomalies["A7_very_long_text"].append({
|
| 145 |
+
"file": src,
|
| 146 |
+
"report": i + 1,
|
| 147 |
+
"pages": pages,
|
| 148 |
+
"char_count": len(raw),
|
| 149 |
+
"note": f"raw_text > {LONG_TEXT_CHARS} chars — "
|
| 150 |
+
"may be two reports merged into one",
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
# ── A8: Assessment duplicates raw_text ─────────────────────────────
|
| 154 |
+
if assess and check_assessment_duplicate(raw, assess):
|
| 155 |
+
anomalies["A8_assessment_duplication"].append({
|
| 156 |
+
"file": src,
|
| 157 |
+
"report": i + 1,
|
| 158 |
+
"pages": pages,
|
| 159 |
+
"note": "assessment appears to be a verbatim copy of (part of) raw_text",
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
# ── A9: Non-standard pages label format ───────────────────────────
|
| 163 |
+
if pages and pages not in ("all-pages",):
|
| 164 |
+
if not re.match(r"^page_\d{4}(-page_\d{4})?$", pages):
|
| 165 |
+
anomalies["A9_nonstandard_pages_label"].append({
|
| 166 |
+
"file": src,
|
| 167 |
+
"report": i + 1,
|
| 168 |
+
"pages": pages,
|
| 169 |
+
"note": "pages label doesn't match expected pattern "
|
| 170 |
+
"(page_XXXX or page_XXXX-page_XXXX)",
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
# Accumulate page numbers for overlap check
|
| 174 |
+
if pages:
|
| 175 |
+
seen_pages.append((i + 1, pages, page_label_to_ints(pages)))
|
| 176 |
+
|
| 177 |
+
# ── A10: Overlapping page ranges within same document ─────────────────
|
| 178 |
+
for idx_a, (ri_a, lbl_a, nums_a) in enumerate(seen_pages):
|
| 179 |
+
for ri_b, lbl_b, nums_b in seen_pages[idx_a + 1:]:
|
| 180 |
+
overlap = set(nums_a) & set(nums_b)
|
| 181 |
+
if overlap:
|
| 182 |
+
anomalies["A10_overlapping_pages"].append({
|
| 183 |
+
"file": src,
|
| 184 |
+
"report_a": ri_a,
|
| 185 |
+
"pages_a": lbl_a,
|
| 186 |
+
"report_b": ri_b,
|
| 187 |
+
"pages_b": lbl_b,
|
| 188 |
+
"overlap": sorted(overlap),
|
| 189 |
+
"note": "Two reports claim the same page(s) — "
|
| 190 |
+
"possible split boundary error",
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
return {"stats": stats, "anomalies": dict(anomalies)}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
CATEGORY_LABELS = {
|
| 197 |
+
"A1_api_parse_error": "A1 — API / parse errors",
|
| 198 |
+
"A2_zero_reports": "A2 — Zero reports extracted",
|
| 199 |
+
"A3_low_report_yield": "A3 — Low reports-per-chunk ratio",
|
| 200 |
+
"A4_null_pages": "A4 — Null pages label",
|
| 201 |
+
"A5_very_short_text": "A5 — Very short raw_text (< 50 chars)",
|
| 202 |
+
"A6_short_text": "A6 — Short raw_text (50–200 chars)",
|
| 203 |
+
"A7_very_long_text": "A7 — Very long raw_text (> 60 000 chars)",
|
| 204 |
+
"A8_assessment_duplication": "A8 — Assessment duplicates raw_text",
|
| 205 |
+
"A9_nonstandard_pages_label": "A9 — Non-standard pages label",
|
| 206 |
+
"A10_overlapping_pages": "A10 — Overlapping page ranges",
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def render_markdown(result: dict, input_path: Path) -> str:
|
| 211 |
+
stats = result["stats"]
|
| 212 |
+
anomalies = result["anomalies"]
|
| 213 |
+
|
| 214 |
+
lines = [
|
| 215 |
+
"# _all_reports.json — Anomaly Report",
|
| 216 |
+
"",
|
| 217 |
+
f"**Source:** `{input_path}` ",
|
| 218 |
+
"",
|
| 219 |
+
"## Summary",
|
| 220 |
+
"",
|
| 221 |
+
f"| Metric | Count |",
|
| 222 |
+
f"|--------|-------|",
|
| 223 |
+
f"| Total files | {stats['total_files']} |",
|
| 224 |
+
f"| Total reports extracted | {stats['total_reports']} |",
|
| 225 |
+
f"| Files with API/parse errors | {stats['files_with_errors']} |",
|
| 226 |
+
f"| Files with zero reports | {stats['files_zero_reports']} |",
|
| 227 |
+
f"| Avg reports per file | {stats['total_reports'] / max(stats['total_files'], 1):.1f} |",
|
| 228 |
+
"",
|
| 229 |
+
"## Anomaly Counts",
|
| 230 |
+
"",
|
| 231 |
+
"| Code | Category | Count |",
|
| 232 |
+
"|------|----------|-------|",
|
| 233 |
+
]
|
| 234 |
+
|
| 235 |
+
total_issues = 0
|
| 236 |
+
for cat, label in CATEGORY_LABELS.items():
|
| 237 |
+
n = len(anomalies.get(cat, []))
|
| 238 |
+
total_issues += n
|
| 239 |
+
lines.append(f"| {cat.split('_')[0]} | {label.split(' — ')[1]} | {n} |")
|
| 240 |
+
|
| 241 |
+
lines += [
|
| 242 |
+
f"| | **Total issues** | **{total_issues}** |",
|
| 243 |
+
"",
|
| 244 |
+
]
|
| 245 |
+
|
| 246 |
+
if total_issues == 0:
|
| 247 |
+
lines.append("✅ No anomalies detected.")
|
| 248 |
+
return "\n".join(lines)
|
| 249 |
+
|
| 250 |
+
lines.append("## Detailed Findings")
|
| 251 |
+
|
| 252 |
+
for cat, label in CATEGORY_LABELS.items():
|
| 253 |
+
items = anomalies.get(cat, [])
|
| 254 |
+
if not items:
|
| 255 |
+
continue
|
| 256 |
+
lines += [
|
| 257 |
+
"",
|
| 258 |
+
f"### {label} ({len(items)})",
|
| 259 |
+
"",
|
| 260 |
+
]
|
| 261 |
+
for item in items:
|
| 262 |
+
file_ = item.get("file", "")
|
| 263 |
+
report = item.get("report")
|
| 264 |
+
pages = item.get("pages")
|
| 265 |
+
note = item.get("note", "")
|
| 266 |
+
loc = f"report {report}" if report else ""
|
| 267 |
+
pg = f"pages `{pages}`" if pages else ""
|
| 268 |
+
detail = ", ".join(filter(None, [loc, pg]))
|
| 269 |
+
lines.append(f"- **`{file_}`**{' — ' + detail if detail else ''}")
|
| 270 |
+
lines.append(f" {note}")
|
| 271 |
+
# Extra fields
|
| 272 |
+
for k in ("error", "preview", "char_count", "chunks", "reports",
|
| 273 |
+
"ratio", "overlap", "report_a", "pages_a", "report_b",
|
| 274 |
+
"pages_b"):
|
| 275 |
+
if k in item:
|
| 276 |
+
lines.append(f" _{k}:_ `{item[k]}`")
|
| 277 |
+
lines.append("")
|
| 278 |
+
|
| 279 |
+
return "\n".join(lines)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def main():
|
| 283 |
+
ap = argparse.ArgumentParser(description="Analyze _all_reports.json for anomalies")
|
| 284 |
+
ap.add_argument("--input", default=DEFAULT_INPUT,
|
| 285 |
+
help="Path to _all_reports.json")
|
| 286 |
+
ap.add_argument("--out", default=DEFAULT_OUT,
|
| 287 |
+
help="Output Markdown report path")
|
| 288 |
+
ap.add_argument("--json-out", default=None,
|
| 289 |
+
help="Also write raw anomaly data as JSON")
|
| 290 |
+
args = ap.parse_args()
|
| 291 |
+
|
| 292 |
+
input_path = Path(args.input)
|
| 293 |
+
if not input_path.exists():
|
| 294 |
+
raise SystemExit(f"✗ File not found: {input_path}")
|
| 295 |
+
|
| 296 |
+
print(f"Loading {input_path} …")
|
| 297 |
+
data = load_json(input_path)
|
| 298 |
+
print(f" {len(data)} file entries loaded")
|
| 299 |
+
|
| 300 |
+
result = analyze(data)
|
| 301 |
+
stats = result["stats"]
|
| 302 |
+
print(f"\n Total reports : {stats['total_reports']}")
|
| 303 |
+
print(f" Zero-report files : {stats['files_zero_reports']}")
|
| 304 |
+
print(f" Files with errors : {stats['files_with_errors']}")
|
| 305 |
+
|
| 306 |
+
total_issues = sum(len(v) for v in result["anomalies"].values())
|
| 307 |
+
print(f" Total anomaly flags : {total_issues}")
|
| 308 |
+
|
| 309 |
+
# Markdown report
|
| 310 |
+
md = render_markdown(result, input_path)
|
| 311 |
+
out_path = Path(args.out)
|
| 312 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 313 |
+
out_path.write_text(md, encoding="utf-8")
|
| 314 |
+
print(f"\n ✓ Markdown report → {out_path}")
|
| 315 |
+
|
| 316 |
+
# Optional JSON dump
|
| 317 |
+
if args.json_out:
|
| 318 |
+
jout = Path(args.json_out)
|
| 319 |
+
jout.write_text(json.dumps(result, indent=2, ensure_ascii=False),
|
| 320 |
+
encoding="utf-8")
|
| 321 |
+
print(f" ✓ JSON data → {jout}")
|
| 322 |
+
|
| 323 |
+
# Print top anomalies to console
|
| 324 |
+
print("\n── Top anomalies ────────────────────────────────────────────────")
|
| 325 |
+
for cat, label in CATEGORY_LABELS.items():
|
| 326 |
+
items = result["anomalies"].get(cat, [])
|
| 327 |
+
if items:
|
| 328 |
+
print(f" {label}: {len(items)}")
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
if __name__ == "__main__":
|
| 332 |
+
main()
|
pipeline/audit.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
audit.py
|
| 3 |
+
────────
|
| 4 |
+
Produces a statistical summary of the UAP archive corpus.
|
| 5 |
+
|
| 6 |
+
Metrics
|
| 7 |
+
-------
|
| 8 |
+
• Total documents (PDF + VID) in CSV
|
| 9 |
+
• Released vs pending (CSV rows with/without matching local folder)
|
| 10 |
+
• Redacted — CSV flag + OCR keyword scan per document
|
| 11 |
+
• Contains embedded images / photos in .md files
|
| 12 |
+
• Insufficient evidence (heavy redaction or very thin content)
|
| 13 |
+
• Previously known / reviewed (keyword scan of blurb + OCR)
|
| 14 |
+
|
| 15 |
+
Output
|
| 16 |
+
------
|
| 17 |
+
Prints a summary to stdout.
|
| 18 |
+
Writes a detailed per-document markdown report to --out (default: audit_report.md).
|
| 19 |
+
|
| 20 |
+
Usage
|
| 21 |
+
-----
|
| 22 |
+
python audit.py
|
| 23 |
+
python audit.py --csv uap-csv.csv --root D:/divided --out audit_report.md
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import re
|
| 27 |
+
import csv
|
| 28 |
+
import argparse
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from collections import defaultdict
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
|
| 33 |
+
DEFAULT_CSV = "uap-csv.csv"
|
| 34 |
+
DEFAULT_ROOT = "D:/divided"
|
| 35 |
+
DEFAULT_OUT = "audit_report.md"
|
| 36 |
+
|
| 37 |
+
# ── keyword patterns ──────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
RE_REDACTED = re.compile(r"\[?redacted\]?|\bblacked.out\b|\bcensored\b", re.I)
|
| 40 |
+
RE_IMAGE = re.compile(r"!\[.*?\]\(.*?\)")
|
| 41 |
+
RE_INSUFF = re.compile(
|
| 42 |
+
r"insufficient\s+(?:information|evidence|data)|no\s+(?:further\s+)?detail|"
|
| 43 |
+
r"unable\s+to\s+(?:determine|assess|identify)|not\s+enough\s+(?:data|evidence)|"
|
| 44 |
+
r"inconclusive",
|
| 45 |
+
re.I
|
| 46 |
+
)
|
| 47 |
+
RE_KNOWN = re.compile(
|
| 48 |
+
r"previously\s+(?:reported|documented|known|reviewed|identified)|"
|
| 49 |
+
r"prior\s+(?:report|incident|case)|known\s+(?:case|incident)|"
|
| 50 |
+
r"already\s+(?:reported|documented)|follow.up\s+to",
|
| 51 |
+
re.I
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
PAGE_DIR_RE = re.compile(r"^page_(\d+)$", re.IGNORECASE)
|
| 57 |
+
|
| 58 |
+
SKIP_NAMES = {
|
| 59 |
+
"reorganize.py", "restructure_pages.py", "stamp_pages.py",
|
| 60 |
+
"reconcile.py", "pdf_to_reports.py", "concat_pages.py", "audit.py",
|
| 61 |
+
"uap_record_schema.yaml", "uap-csv.csv",
|
| 62 |
+
"move_log.json", "page_move_log.json",
|
| 63 |
+
"records", "reports_out", "pages_out", "wiki", "scripts",
|
| 64 |
+
"MISC", "DOD", "NASA", "FBI", "DOS", "NARA-CIA",
|
| 65 |
+
"CLAUDE.md", "Untitled.md", "README.md",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _slug(title: str) -> str:
|
| 70 |
+
s = title.lower().strip()
|
| 71 |
+
s = re.sub(r"[\s,]+", "-", s)
|
| 72 |
+
s = re.sub(r"[^\w\-]", "", s)
|
| 73 |
+
return re.sub(r"-+", "-", s).strip("-")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _index_docs(root: Path) -> dict[str, Path]:
|
| 77 |
+
"""Map doc-slug → Path for every folder containing page_XXXX subfolders."""
|
| 78 |
+
index = {}
|
| 79 |
+
for d in root.rglob("*"):
|
| 80 |
+
if not d.is_dir() or d.name in SKIP_NAMES:
|
| 81 |
+
continue
|
| 82 |
+
if any(PAGE_DIR_RE.match(sub.name) for sub in d.iterdir() if sub.is_dir()):
|
| 83 |
+
index[d.name.lower()] = d
|
| 84 |
+
return index
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _collect_md_texts(doc_dir: Path) -> list[tuple[str, str]]:
|
| 88 |
+
"""Return [(page_name, text), ...] sorted by page number."""
|
| 89 |
+
pages = []
|
| 90 |
+
for sub in doc_dir.iterdir():
|
| 91 |
+
if not sub.is_dir():
|
| 92 |
+
continue
|
| 93 |
+
m = PAGE_DIR_RE.match(sub.name)
|
| 94 |
+
if not m:
|
| 95 |
+
continue
|
| 96 |
+
md = sub / f"{sub.name}.md"
|
| 97 |
+
text = md.read_text(encoding="utf-8", errors="replace") if md.exists() else ""
|
| 98 |
+
pages.append((int(m.group(1)), sub.name, text))
|
| 99 |
+
return [(name, text) for _, name, text in sorted(pages)]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _find_doc_dir(index: dict[str, Path], title: str) -> Path | None:
|
| 103 |
+
slug = _slug(title)
|
| 104 |
+
if slug in index:
|
| 105 |
+
return index[slug]
|
| 106 |
+
prefix = slug[:20]
|
| 107 |
+
for key, path in index.items():
|
| 108 |
+
if key.startswith(prefix):
|
| 109 |
+
return path
|
| 110 |
+
words = set(slug.split("-")[:5])
|
| 111 |
+
for key, path in index.items():
|
| 112 |
+
kwords = set(key.split("-")[:5])
|
| 113 |
+
if len(words & kwords) >= min(3, len(words)):
|
| 114 |
+
return path
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ── per-document analysis ─────────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
def analyse_document(title: str, csv_row: dict, doc_dir: Path | None) -> dict:
|
| 121 |
+
result = {
|
| 122 |
+
"title": title,
|
| 123 |
+
"agency": csv_row.get("Agency", "").strip(),
|
| 124 |
+
"doc_type": csv_row.get("Type", "PDF").strip().upper(),
|
| 125 |
+
"csv_redacted": csv_row.get("Redaction", "").strip().upper() == "TRUE",
|
| 126 |
+
"release_date": csv_row.get("Release Date", "").strip(),
|
| 127 |
+
"incident_date": csv_row.get("Incident Date", "").strip(),
|
| 128 |
+
"location": csv_row.get("Incident Location", "").strip(),
|
| 129 |
+
"blurb": csv_row.get("Description Blurb", "").replace("\n", " ").strip(),
|
| 130 |
+
# page-level findings
|
| 131 |
+
"found_locally": doc_dir is not None,
|
| 132 |
+
"page_count": 0,
|
| 133 |
+
"ocr_redacted": False,
|
| 134 |
+
"redacted_pages": [],
|
| 135 |
+
"has_images": False,
|
| 136 |
+
"image_pages": [],
|
| 137 |
+
"insuff_evidence": False,
|
| 138 |
+
"previously_known":False,
|
| 139 |
+
"total_chars": 0,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
blurb = result["blurb"]
|
| 143 |
+
if RE_INSUFF.search(blurb):
|
| 144 |
+
result["insuff_evidence"] = True
|
| 145 |
+
if RE_KNOWN.search(blurb):
|
| 146 |
+
result["previously_known"] = True
|
| 147 |
+
|
| 148 |
+
if doc_dir is None:
|
| 149 |
+
return result
|
| 150 |
+
|
| 151 |
+
pages = _collect_md_texts(doc_dir)
|
| 152 |
+
result["page_count"] = len(pages)
|
| 153 |
+
|
| 154 |
+
all_text = ""
|
| 155 |
+
for page_name, text in pages:
|
| 156 |
+
all_text += text
|
| 157 |
+
if RE_REDACTED.search(text):
|
| 158 |
+
result["ocr_redacted"] = True
|
| 159 |
+
result["redacted_pages"].append(page_name)
|
| 160 |
+
if RE_IMAGE.search(text):
|
| 161 |
+
result["has_images"] = True
|
| 162 |
+
result["image_pages"].append(page_name)
|
| 163 |
+
if RE_INSUFF.search(text):
|
| 164 |
+
result["insuff_evidence"] = True
|
| 165 |
+
if RE_KNOWN.search(text):
|
| 166 |
+
result["previously_known"] = True
|
| 167 |
+
|
| 168 |
+
result["total_chars"] = len(all_text)
|
| 169 |
+
|
| 170 |
+
# Insufficient evidence heuristic: very thin content or mostly redacted
|
| 171 |
+
if result["page_count"] > 0:
|
| 172 |
+
redact_ratio = len(result["redacted_pages"]) / result["page_count"]
|
| 173 |
+
avg_chars = result["total_chars"] / result["page_count"]
|
| 174 |
+
if redact_ratio > 0.5 or avg_chars < 200:
|
| 175 |
+
result["insuff_evidence"] = True
|
| 176 |
+
|
| 177 |
+
return result
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ── report writer ─────────────────────────────────────────────────────────────
|
| 181 |
+
|
| 182 |
+
def write_report(docs: list[dict], out_path: Path) -> None:
|
| 183 |
+
total = len(docs)
|
| 184 |
+
found = sum(1 for d in docs if d["found_locally"])
|
| 185 |
+
pending = total - found
|
| 186 |
+
pdfs = sum(1 for d in docs if d["doc_type"] == "PDF")
|
| 187 |
+
vids = sum(1 for d in docs if d["doc_type"] == "VID")
|
| 188 |
+
csv_redact = sum(1 for d in docs if d["csv_redacted"])
|
| 189 |
+
ocr_redact = sum(1 for d in docs if d["ocr_redacted"])
|
| 190 |
+
has_images = sum(1 for d in docs if d["has_images"])
|
| 191 |
+
insuff = sum(1 for d in docs if d["insuff_evidence"])
|
| 192 |
+
known = sum(1 for d in docs if d["previously_known"])
|
| 193 |
+
|
| 194 |
+
lines = [
|
| 195 |
+
f"# UAP Archive Audit Report",
|
| 196 |
+
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
| 197 |
+
"",
|
| 198 |
+
"---",
|
| 199 |
+
"",
|
| 200 |
+
"## Summary",
|
| 201 |
+
"",
|
| 202 |
+
f"| Metric | Count |",
|
| 203 |
+
f"|--------|-------|",
|
| 204 |
+
f"| Total documents in CSV | {total} |",
|
| 205 |
+
f"| — PDFs | {pdfs} |",
|
| 206 |
+
f"| — Videos (VID) | {vids} |",
|
| 207 |
+
f"| Found locally (pages on disk) | {found} |",
|
| 208 |
+
f"| **Pending / not yet processed** | **{pending}** |",
|
| 209 |
+
f"| Redacted (CSV flag) | {csv_redact} |",
|
| 210 |
+
f"| Redacted (detected in OCR text) | {ocr_redact} |",
|
| 211 |
+
f"| Contains embedded photos/images | {has_images} |",
|
| 212 |
+
f"| Insufficient evidence | {insuff} |",
|
| 213 |
+
f"| Previously known / reviewed | {known} |",
|
| 214 |
+
"",
|
| 215 |
+
"---",
|
| 216 |
+
"",
|
| 217 |
+
"## Pending (no local folder found)",
|
| 218 |
+
"",
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
pending_docs = [d for d in docs if not d["found_locally"]]
|
| 222 |
+
if pending_docs:
|
| 223 |
+
for d in pending_docs:
|
| 224 |
+
lines.append(f"- {d['title']} _(agency: {d['agency']}, type: {d['doc_type']})_")
|
| 225 |
+
else:
|
| 226 |
+
lines.append("_None — all CSV rows matched a local folder._")
|
| 227 |
+
|
| 228 |
+
lines += ["", "---", "", "## Redacted documents (OCR-detected)", ""]
|
| 229 |
+
redact_docs = [d for d in docs if d["ocr_redacted"]]
|
| 230 |
+
if redact_docs:
|
| 231 |
+
lines.append("| Document | Pages with redactions | Total pages |")
|
| 232 |
+
lines.append("|----------|-----------------------|-------------|")
|
| 233 |
+
for d in redact_docs:
|
| 234 |
+
pages_str = ", ".join(d["redacted_pages"][:5])
|
| 235 |
+
if len(d["redacted_pages"]) > 5:
|
| 236 |
+
pages_str += f" … +{len(d['redacted_pages'])-5} more"
|
| 237 |
+
lines.append(f"| {d['title'][:60]} | {pages_str} | {d['page_count']} |")
|
| 238 |
+
else:
|
| 239 |
+
lines.append("_None detected._")
|
| 240 |
+
|
| 241 |
+
lines += ["", "---", "", "## Documents with embedded photos/images", ""]
|
| 242 |
+
img_docs = [d for d in docs if d["has_images"]]
|
| 243 |
+
if img_docs:
|
| 244 |
+
lines.append("| Document | Pages with images |")
|
| 245 |
+
lines.append("|----------|-------------------|")
|
| 246 |
+
for d in img_docs:
|
| 247 |
+
lines.append(f"| {d['title'][:60]} | {', '.join(d['image_pages'])} |")
|
| 248 |
+
else:
|
| 249 |
+
lines.append("_None detected._")
|
| 250 |
+
|
| 251 |
+
lines += ["", "---", "", "## Insufficient evidence", ""]
|
| 252 |
+
insuff_docs = [d for d in docs if d["insuff_evidence"]]
|
| 253 |
+
if insuff_docs:
|
| 254 |
+
for d in insuff_docs:
|
| 255 |
+
lines.append(f"- {d['title']} _(pages: {d['page_count']}, redacted pages: {len(d['redacted_pages'])})_")
|
| 256 |
+
else:
|
| 257 |
+
lines.append("_None flagged._")
|
| 258 |
+
|
| 259 |
+
lines += ["", "---", "", "## Previously known / reviewed", ""]
|
| 260 |
+
known_docs = [d for d in docs if d["previously_known"]]
|
| 261 |
+
if known_docs:
|
| 262 |
+
for d in known_docs:
|
| 263 |
+
lines.append(f"- {d['title']}")
|
| 264 |
+
else:
|
| 265 |
+
lines.append("_None detected._")
|
| 266 |
+
|
| 267 |
+
lines += ["", "---", "", "## Full document inventory", ""]
|
| 268 |
+
lines.append("| # | Title | Agency | Type | Local | Redacted | Images | Insuff | Known |")
|
| 269 |
+
lines.append("|---|-------|--------|------|-------|----------|--------|--------|-------|")
|
| 270 |
+
for i, d in enumerate(docs, 1):
|
| 271 |
+
lines.append(
|
| 272 |
+
f"| {i} | {d['title'][:50]} | {d['agency']} | {d['doc_type']} "
|
| 273 |
+
f"| {'✓' if d['found_locally'] else '✗'} "
|
| 274 |
+
f"| {'✓' if d['csv_redacted'] or d['ocr_redacted'] else '—'} "
|
| 275 |
+
f"| {'✓' if d['has_images'] else '—'} "
|
| 276 |
+
f"| {'✓' if d['insuff_evidence'] else '—'} "
|
| 277 |
+
f"| {'✓' if d['previously_known'] else '—'} |"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
out_path.write_text("\n".join(lines), encoding="utf-8")
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
# ── main ──────────────────────────────────────────────────────────────────────
|
| 284 |
+
|
| 285 |
+
def main():
|
| 286 |
+
ap = argparse.ArgumentParser(description="Audit the UAP archive corpus")
|
| 287 |
+
ap.add_argument("--csv", default=DEFAULT_CSV)
|
| 288 |
+
ap.add_argument("--root", default=DEFAULT_ROOT)
|
| 289 |
+
ap.add_argument("--out", default=DEFAULT_OUT)
|
| 290 |
+
args = ap.parse_args()
|
| 291 |
+
|
| 292 |
+
root = Path(args.root)
|
| 293 |
+
out_path = Path(args.out)
|
| 294 |
+
|
| 295 |
+
print(f"\n Reading CSV: {args.csv}")
|
| 296 |
+
with open(args.csv, newline="", encoding="utf-8-sig") as f:
|
| 297 |
+
rows = list(csv.DictReader(f))
|
| 298 |
+
print(f" {len(rows)} rows found")
|
| 299 |
+
|
| 300 |
+
print(f" Indexing local folders under {root} …")
|
| 301 |
+
doc_index = _index_docs(root)
|
| 302 |
+
print(f" {len(doc_index)} document folders found\n")
|
| 303 |
+
|
| 304 |
+
docs = []
|
| 305 |
+
for i, row in enumerate(rows):
|
| 306 |
+
title = row.get("Title", "").replace("\n", " ").strip()
|
| 307 |
+
if not title:
|
| 308 |
+
continue
|
| 309 |
+
doc_dir = _find_doc_dir(doc_index, title)
|
| 310 |
+
result = analyse_document(title, row, doc_dir)
|
| 311 |
+
docs.append(result)
|
| 312 |
+
status = "✓" if doc_dir else "✗"
|
| 313 |
+
if (i + 1) % 10 == 0 or not doc_dir:
|
| 314 |
+
print(f" {status} [{i+1:>3d}] {title[:60]}")
|
| 315 |
+
|
| 316 |
+
# ── print summary ─────────────────────────────────────────────────────────
|
| 317 |
+
total = len(docs)
|
| 318 |
+
found = sum(1 for d in docs if d["found_locally"])
|
| 319 |
+
print(f"\n{'─'*60}")
|
| 320 |
+
print(f" AUDIT SUMMARY")
|
| 321 |
+
print(f"{'─'*60}")
|
| 322 |
+
print(f" Total in CSV : {total}")
|
| 323 |
+
print(f" PDFs : {sum(1 for d in docs if d['doc_type']=='PDF')}")
|
| 324 |
+
print(f" Videos : {sum(1 for d in docs if d['doc_type']=='VID')}")
|
| 325 |
+
print(f" Found locally : {found}")
|
| 326 |
+
print(f" Pending (not on disk) : {total - found}")
|
| 327 |
+
print(f" Redacted (CSV) : {sum(1 for d in docs if d['csv_redacted'])}")
|
| 328 |
+
print(f" Redacted (OCR scan) : {sum(1 for d in docs if d['ocr_redacted'])}")
|
| 329 |
+
print(f" Has photos/images : {sum(1 for d in docs if d['has_images'])}")
|
| 330 |
+
print(f" Insufficient evidence : {sum(1 for d in docs if d['insuff_evidence'])}")
|
| 331 |
+
print(f" Previously known : {sum(1 for d in docs if d['previously_known'])}")
|
| 332 |
+
print(f"{'─'*60}\n")
|
| 333 |
+
|
| 334 |
+
write_report(docs, out_path)
|
| 335 |
+
print(f" ✅ Full report → {out_path}\n")
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
if __name__ == "__main__":
|
| 339 |
+
main()
|
pipeline/concat_pages.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
concat_pages.py
|
| 3 |
+
───────────────
|
| 4 |
+
Concatenates all page_XXXX.md files for each document into a single
|
| 5 |
+
<document-slug>.md at the document folder root AND copies a flat version
|
| 6 |
+
into <root>/concat/<document-slug>.md for easy bulk review.
|
| 7 |
+
|
| 8 |
+
Source (unchanged):
|
| 9 |
+
<doc-slug>/page_0001/page_0001.md
|
| 10 |
+
<doc-slug>/page_0002/page_0002.md
|
| 11 |
+
...
|
| 12 |
+
|
| 13 |
+
Output:
|
| 14 |
+
<doc-slug>/<doc-slug>.md ← all pages joined in order (next to source)
|
| 15 |
+
concat/<doc-slug>.md ← flat copy for bulk review / sharing
|
| 16 |
+
|
| 17 |
+
Pipeline with stamp_pages in the middle
|
| 18 |
+
---------------------------------------
|
| 19 |
+
python stamp_pages.py --src raw --out pages_out # stamp individual pages
|
| 20 |
+
python concat_pages.py --src pages_out --execute # concat stamped pages
|
| 21 |
+
|
| 22 |
+
Usage
|
| 23 |
+
-----
|
| 24 |
+
python concat_pages.py # dry-run: list what would be written
|
| 25 |
+
python concat_pages.py --execute # write concatenated files
|
| 26 |
+
python concat_pages.py --root D:/divided # explicit root (also controls concat/ location)
|
| 27 |
+
python concat_pages.py --src pages_out --execute # read from pages_out/, write concat/ to root
|
| 28 |
+
python concat_pages.py --execute --force # overwrite existing concatenated files
|
| 29 |
+
python concat_pages.py --no-inline # skip writing next to source, only write concat/
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import os
|
| 33 |
+
import re
|
| 34 |
+
import argparse
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
|
| 37 |
+
DEFAULT_ROOT = "D:/divided"
|
| 38 |
+
|
| 39 |
+
# Directories to never descend into during os.walk / rglob
|
| 40 |
+
# NOTE: agency folder names (DOD, NASA, FBI, DOS, NARA-CIA, MISC) are intentionally
|
| 41 |
+
# NOT here — we need to descend into them when --src points at pages_out/ or raw/.
|
| 42 |
+
SKIP_TRAVERSE = {
|
| 43 |
+
"records", "reports_out", "wiki",
|
| 44 |
+
"concat", "extracted", "scripts", "raw",
|
| 45 |
+
".git", "__pycache__",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
# Directory/file names to skip as document-folder *candidates* (not as traversal roots)
|
| 49 |
+
SKIP_CANDIDATE = {
|
| 50 |
+
"reorganize.py", "restructure_pages.py", "stamp_pages.py",
|
| 51 |
+
"reconcile.py", "pdf_to_reports.py", "concat_pages.py",
|
| 52 |
+
"find_missing_concat.py", "find_ocr_targets.py", "run_ocr.py",
|
| 53 |
+
"audit.py", "page_coverage.py", "destamp_pages.py",
|
| 54 |
+
"uap_record_schema.yaml", "uap-csv.csv",
|
| 55 |
+
"move_log.json", "page_move_log.json",
|
| 56 |
+
"CLAUDE.md", "Untitled.md", "README.md",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
PAGE_DIR_RE = re.compile(r"^page_(\d+)$", re.IGNORECASE)
|
| 60 |
+
|
| 61 |
+
PAGE_SEP = "\n\n---\n\n" # separator inserted between pages
|
| 62 |
+
|
| 63 |
+
CONCAT_DIR = "concat" # flat output folder name (always at root level)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def find_document_dirs(src: Path) -> list[Path]:
|
| 67 |
+
"""Return all document folders (those containing page_XXXX/ subfolders)."""
|
| 68 |
+
doc_dirs = []
|
| 69 |
+
for dirpath, dirnames, _ in os.walk(str(src)):
|
| 70 |
+
dirnames[:] = [d for d in dirnames if d not in SKIP_TRAVERSE]
|
| 71 |
+
p = Path(dirpath)
|
| 72 |
+
if p == src or p.name in SKIP_CANDIDATE:
|
| 73 |
+
continue
|
| 74 |
+
try:
|
| 75 |
+
has_pages = any(
|
| 76 |
+
PAGE_DIR_RE.match(sub.name)
|
| 77 |
+
for sub in p.iterdir() if sub.is_dir()
|
| 78 |
+
)
|
| 79 |
+
except (PermissionError, OSError):
|
| 80 |
+
continue
|
| 81 |
+
if has_pages:
|
| 82 |
+
doc_dirs.append(p)
|
| 83 |
+
return sorted(doc_dirs)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def collect_pages(doc_dir: Path) -> list[Path]:
|
| 87 |
+
"""Return page .md files sorted by page number."""
|
| 88 |
+
pages = []
|
| 89 |
+
for sub in doc_dir.iterdir():
|
| 90 |
+
if not sub.is_dir():
|
| 91 |
+
continue
|
| 92 |
+
m = PAGE_DIR_RE.match(sub.name)
|
| 93 |
+
if not m:
|
| 94 |
+
continue
|
| 95 |
+
md = sub / f"{sub.name}.md"
|
| 96 |
+
if md.exists():
|
| 97 |
+
pages.append((int(m.group(1)), md))
|
| 98 |
+
return [md for _, md in sorted(pages)]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def build_concat(doc_dir: Path, pages: list[Path]) -> str:
|
| 102 |
+
"""Build the full concatenated markdown string."""
|
| 103 |
+
chunks = []
|
| 104 |
+
for md in pages:
|
| 105 |
+
page_num = md.parent.name # e.g. "page_0001"
|
| 106 |
+
header = f"## {page_num}\n\n"
|
| 107 |
+
body = md.read_text(encoding="utf-8", errors="replace").strip()
|
| 108 |
+
chunks.append(header + body)
|
| 109 |
+
return PAGE_SEP.join(chunks)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def process(src: Path, root: Path, execute: bool, force: bool, inline: bool = True) -> None:
|
| 113 |
+
doc_dirs = find_document_dirs(src)
|
| 114 |
+
|
| 115 |
+
if not doc_dirs:
|
| 116 |
+
print(" No document folders found.")
|
| 117 |
+
return
|
| 118 |
+
|
| 119 |
+
concat_dir = root / CONCAT_DIR
|
| 120 |
+
|
| 121 |
+
if execute:
|
| 122 |
+
concat_dir.mkdir(exist_ok=True)
|
| 123 |
+
|
| 124 |
+
written = skipped = errors = 0
|
| 125 |
+
|
| 126 |
+
for doc_dir in doc_dirs:
|
| 127 |
+
pages = collect_pages(doc_dir)
|
| 128 |
+
if not pages:
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
inline_path = doc_dir / f"{doc_dir.name}.md"
|
| 132 |
+
flat_path = concat_dir / f"{doc_dir.name}.md"
|
| 133 |
+
|
| 134 |
+
# In dry-run mode, report both destinations
|
| 135 |
+
if not execute:
|
| 136 |
+
destinations = []
|
| 137 |
+
if inline and (force or not inline_path.exists()):
|
| 138 |
+
try:
|
| 139 |
+
destinations.append(str(inline_path.relative_to(root)))
|
| 140 |
+
except ValueError:
|
| 141 |
+
destinations.append(str(inline_path))
|
| 142 |
+
if force or not flat_path.exists():
|
| 143 |
+
destinations.append(f"{CONCAT_DIR}/{doc_dir.name}.md")
|
| 144 |
+
if destinations:
|
| 145 |
+
for dest in destinations:
|
| 146 |
+
print(f" would write {dest} ({len(pages)} pages)")
|
| 147 |
+
written += 1
|
| 148 |
+
else:
|
| 149 |
+
skipped += 1
|
| 150 |
+
continue
|
| 151 |
+
|
| 152 |
+
# Build content once, write to both destinations
|
| 153 |
+
try:
|
| 154 |
+
content = build_concat(doc_dir, pages)
|
| 155 |
+
wrote_any = False
|
| 156 |
+
|
| 157 |
+
if inline:
|
| 158 |
+
if force or not inline_path.exists():
|
| 159 |
+
inline_path.write_text(content, encoding="utf-8")
|
| 160 |
+
print(f" ✓ {inline_path.relative_to(root)} ({len(pages)} pages)")
|
| 161 |
+
wrote_any = True
|
| 162 |
+
|
| 163 |
+
if force or not flat_path.exists():
|
| 164 |
+
flat_path.write_text(content, encoding="utf-8")
|
| 165 |
+
print(f" ✓ {CONCAT_DIR}/{doc_dir.name}.md ({len(pages)} pages)")
|
| 166 |
+
wrote_any = True
|
| 167 |
+
|
| 168 |
+
if wrote_any:
|
| 169 |
+
written += 1
|
| 170 |
+
else:
|
| 171 |
+
skipped += 1
|
| 172 |
+
|
| 173 |
+
except Exception as exc:
|
| 174 |
+
print(f" ✗ {doc_dir.name} ERROR: {exc}")
|
| 175 |
+
errors += 1
|
| 176 |
+
|
| 177 |
+
label = "would write" if not execute else "written"
|
| 178 |
+
print(f"\n {written} {label}, {skipped} skipped (already exist — use --force to overwrite), {errors} errors\n")
|
| 179 |
+
if execute:
|
| 180 |
+
print(f" Flat copies → {concat_dir}\n")
|
| 181 |
+
print(f" Source read → {src}\n")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def main():
|
| 185 |
+
ap = argparse.ArgumentParser(
|
| 186 |
+
description="Concatenate per-page .md files into one .md per document"
|
| 187 |
+
)
|
| 188 |
+
ap.add_argument("--root", default=DEFAULT_ROOT,
|
| 189 |
+
help="Root folder — controls where concat/ is written (default: D:/divided)")
|
| 190 |
+
ap.add_argument("--src", default=None,
|
| 191 |
+
help="Source folder to scan for documents (default: same as --root). "
|
| 192 |
+
"Set to pages_out/ when stamp_pages is in the pipeline.")
|
| 193 |
+
ap.add_argument("--execute", action="store_true", help="Write files (default: dry-run)")
|
| 194 |
+
ap.add_argument("--force", action="store_true", help="Overwrite existing concatenated files")
|
| 195 |
+
ap.add_argument("--no-inline", action="store_true", help="Skip writing next to source; only write to concat/")
|
| 196 |
+
args = ap.parse_args()
|
| 197 |
+
|
| 198 |
+
root = Path(args.root)
|
| 199 |
+
src = Path(args.src) if args.src else root
|
| 200 |
+
|
| 201 |
+
if not root.exists():
|
| 202 |
+
print(f" ✗ Root not found: {root}")
|
| 203 |
+
return
|
| 204 |
+
if not src.exists():
|
| 205 |
+
print(f" ✗ Source not found: {src}")
|
| 206 |
+
return
|
| 207 |
+
|
| 208 |
+
if not args.execute:
|
| 209 |
+
print(f"\n{'─'*60}")
|
| 210 |
+
print(f" DRY RUN — nothing will be written")
|
| 211 |
+
print(f" Source: {src}")
|
| 212 |
+
print(f" Root: {root}")
|
| 213 |
+
print(f" Flat copies: {root / CONCAT_DIR}")
|
| 214 |
+
print(f"{'─'*60}\n")
|
| 215 |
+
|
| 216 |
+
process(src, root, args.execute, args.force, inline=not args.no_inline)
|
| 217 |
+
|
| 218 |
+
if not args.execute:
|
| 219 |
+
print(" Run with --execute to apply.\n")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
main()
|
pipeline/destamp_pages.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
destamp_pages.py
|
| 3 |
+
────────────────
|
| 4 |
+
Strips the YAML frontmatter stamp prepended by stamp_pages.py from every
|
| 5 |
+
page_XXXX.md file.
|
| 6 |
+
|
| 7 |
+
The stamp always looks like this at the top of the file:
|
| 8 |
+
---
|
| 9 |
+
document: ...
|
| 10 |
+
page: ...
|
| 11 |
+
of: ...
|
| 12 |
+
agency: ...
|
| 13 |
+
subtype: ...
|
| 14 |
+
region: ...
|
| 15 |
+
src_path: ...
|
| 16 |
+
---
|
| 17 |
+
<blank line>
|
| 18 |
+
<original OCR content>
|
| 19 |
+
|
| 20 |
+
Modes
|
| 21 |
+
-----
|
| 22 |
+
--inplace (default) Strip stamps from --src in place.
|
| 23 |
+
--out DIR Write destamped copies to DIR, keeping the same
|
| 24 |
+
folder hierarchy. Source files are not modified.
|
| 25 |
+
|
| 26 |
+
Usage
|
| 27 |
+
-----
|
| 28 |
+
python destamp_pages.py # strip pages_out/ in place
|
| 29 |
+
python destamp_pages.py --src pages_out # same, explicit
|
| 30 |
+
python destamp_pages.py --out clean_pages # write copies, don't touch originals
|
| 31 |
+
python destamp_pages.py --src pages_out --dry-run # preview without writing
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
import re
|
| 35 |
+
import argparse
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
|
| 38 |
+
DEFAULT_SRC = "pages_out"
|
| 39 |
+
|
| 40 |
+
# Matches the stamp: starts with ---, ends with --- followed by optional blank line
|
| 41 |
+
# Anchored to the very start of the file (re.DOTALL so . matches newlines)
|
| 42 |
+
STAMP_RE = re.compile(
|
| 43 |
+
r"^---\r?\n(?:[^\n]+\r?\n)*?---\r?\n\r?\n?",
|
| 44 |
+
re.DOTALL,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def strip_stamp(text: str) -> tuple[str, bool]:
|
| 49 |
+
"""
|
| 50 |
+
Remove the leading YAML frontmatter stamp from text.
|
| 51 |
+
Returns (stripped_text, was_stamped).
|
| 52 |
+
"""
|
| 53 |
+
m = STAMP_RE.match(text)
|
| 54 |
+
if m:
|
| 55 |
+
return text[m.end():], True
|
| 56 |
+
return text, False
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def process(src_root: Path, out_root: Path | None, dry_run: bool) -> tuple[int, int, int]:
|
| 60 |
+
"""
|
| 61 |
+
Walk src_root, strip stamps, write results.
|
| 62 |
+
If out_root is None → write back to the same file (in-place).
|
| 63 |
+
Returns (stripped, skipped, errors).
|
| 64 |
+
"""
|
| 65 |
+
stripped = skipped = errors = 0
|
| 66 |
+
|
| 67 |
+
md_files = sorted(src_root.rglob("page_*.md"))
|
| 68 |
+
total = len(md_files)
|
| 69 |
+
|
| 70 |
+
if total == 0:
|
| 71 |
+
print(f" No page_*.md files found under {src_root}")
|
| 72 |
+
return 0, 0, 0
|
| 73 |
+
|
| 74 |
+
print(f" Found {total} page_*.md files\n")
|
| 75 |
+
|
| 76 |
+
for md_file in md_files:
|
| 77 |
+
try:
|
| 78 |
+
text = md_file.read_text(encoding="utf-8", errors="replace")
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
print(f" ✗ READ {md_file.name} {exc}")
|
| 81 |
+
errors += 1
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
clean, was_stamped = strip_stamp(text)
|
| 85 |
+
|
| 86 |
+
if not was_stamped:
|
| 87 |
+
skipped += 1
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
if out_root is None:
|
| 91 |
+
dst = md_file # in-place
|
| 92 |
+
else:
|
| 93 |
+
rel = md_file.relative_to(src_root)
|
| 94 |
+
dst = out_root / rel
|
| 95 |
+
|
| 96 |
+
if dry_run:
|
| 97 |
+
rel_display = md_file.relative_to(src_root)
|
| 98 |
+
print(f" [dry-run] would strip {rel_display}")
|
| 99 |
+
stripped += 1
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
if out_root is not None:
|
| 104 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
dst.write_text(clean, encoding="utf-8")
|
| 106 |
+
stripped += 1
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
print(f" ✗ WRITE {dst} {exc}")
|
| 109 |
+
errors += 1
|
| 110 |
+
|
| 111 |
+
return stripped, skipped, errors
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
ap = argparse.ArgumentParser(description="Remove YAML frontmatter stamps from page .md files")
|
| 116 |
+
ap.add_argument("--src", default=DEFAULT_SRC,
|
| 117 |
+
help=f"Source folder to scan (default: {DEFAULT_SRC})")
|
| 118 |
+
ap.add_argument("--out", default=None,
|
| 119 |
+
help="Write destamped copies here instead of modifying in place")
|
| 120 |
+
ap.add_argument("--dry-run", action="store_true",
|
| 121 |
+
help="Print what would be stripped without writing anything")
|
| 122 |
+
args = ap.parse_args()
|
| 123 |
+
|
| 124 |
+
src_root = Path(args.src)
|
| 125 |
+
if not src_root.exists():
|
| 126 |
+
raise SystemExit(f" ✗ Source not found: {src_root}")
|
| 127 |
+
|
| 128 |
+
out_root = Path(args.out) if args.out else None
|
| 129 |
+
|
| 130 |
+
mode = "dry-run" if args.dry_run else ("in-place" if out_root is None else f"→ {out_root}")
|
| 131 |
+
print(f"\n Source : {src_root}")
|
| 132 |
+
print(f" Mode : {mode}\n")
|
| 133 |
+
|
| 134 |
+
stripped, skipped, errors = process(src_root, out_root, args.dry_run)
|
| 135 |
+
|
| 136 |
+
print(f"\n {'─'*50}")
|
| 137 |
+
print(f" Stripped : {stripped}")
|
| 138 |
+
print(f" Skipped : {skipped} (no stamp found — already clean or not stamped)")
|
| 139 |
+
print(f" Errors : {errors}")
|
| 140 |
+
print(f" {'─'*50}\n")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
pipeline/download_uap_pdfs.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""UAP PDF Downloader — fixed for war.gov hotlink protection
|
| 3 |
+
|
| 4 |
+
Downloads all PDFs into a UAP_PDFs folder beside this script.
|
| 5 |
+
Run: python3 download_uap_pdfs.py
|
| 6 |
+
"""
|
| 7 |
+
import urllib.request
|
| 8 |
+
import urllib.parse
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
PDF_URLS = [
|
| 13 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_10.pdf',
|
| 14 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_2.pdf',
|
| 15 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_3.pdf',
|
| 16 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_4.pdf',
|
| 17 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_5.pdf',
|
| 18 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_6.pdf',
|
| 19 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_7.pdf',
|
| 20 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_9.pdf',
|
| 21 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_serial_130.pdf',
|
| 22 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_serial_164.pdf',
|
| 23 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_serial_220.pdf',
|
| 24 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_serial_403.pdf',
|
| 25 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_serial_438.pdf',
|
| 26 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_serial_449.pdf',
|
| 27 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_sub_a.pdf',
|
| 28 |
+
'https://www.war.gov/medialink/ufo/release_1/18_100754_ general 1946-7_vol_2.pdf',
|
| 29 |
+
'https://www.war.gov/medialink/ufo/release_1/18_6369445_general_1948_vol_1.pdf',
|
| 30 |
+
"https://www.war.gov/medialink/ufo/release_1/255_413270_ufo's_and_defense_what_should_we_prepare_for.pdf",
|
| 31 |
+
'https://www.war.gov/medialink/ufo/release_1/255_t_763_r1b_transcripts.pdf',
|
| 32 |
+
'https://www.war.gov/medialink/ufo/release_1/331_120752_numeric_files_1944–1945_37153_german_armament_equipment_documents.pdf',
|
| 33 |
+
'https://www.war.gov/medialink/ufo/release_1/341_110448_records_relating_to_the_collection_and_dissemination_of_intelligence_1948-1955-ts_cont_no.2_2-5300-2-5399.pdf',
|
| 34 |
+
'https://www.war.gov/medialink/ufo/release_1/341_110677_numerical_file_5-2500.pdf',
|
| 35 |
+
'https://www.war.gov/medialink/ufo/release_1/342_hs1-416511228_box186_319.1-flying-discs-1949.pdf',
|
| 36 |
+
'https://www.war.gov/medialink/ufo/release_1/38_143685_box7_incident_summaries_101-172.pdf',
|
| 37 |
+
'https://www.war.gov/medialink/ufo/release_1/38_143685_box7_incident_summaries_173-233.pdf',
|
| 38 |
+
'https://www.war.gov/medialink/ufo/release_1/38_143685_box7_incident_summaries_1-100.pdf',
|
| 39 |
+
'https://www.war.gov/medialink/ufo/release_1/59_214434_sp_16_[7.18.1963].pdf',
|
| 40 |
+
'https://www.war.gov/medialink/ufo/release_1/59_214434_sp_16_7.18.1963.pdf',
|
| 41 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-101634279_100-de-18221_serial_844.pdf',
|
| 42 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-101634279_100-de-26505.pdf',
|
| 43 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_1.pdf',
|
| 44 |
+
'https://www.war.gov/medialink/ufo/release_1/65_hs1-834228961_62-hq-83894_section_8.pdf',
|
| 45 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d10-mission-report-middle-east-may-2022.pdf',
|
| 46 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d12-mission-report-iraq-may-2022.pdf',
|
| 47 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d14-mission-report-iraq-may-2022.pdf',
|
| 48 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d16-mission-report-syria-july-2022.pdf',
|
| 49 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d18-mission-report-iraq-december-2022.pdf',
|
| 50 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d19-mission-report-syria-february-21-2023.pdf',
|
| 51 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d20-mission-report-southern-united-states-2020.pdf',
|
| 52 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d23-mission-report-united-arab-emirates-october-2023.pdf',
|
| 53 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d25-mission-report-greece-january-2024.pdf',
|
| 54 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d27-mission-report-united-arab-emirates-october-2023.pdf',
|
| 55 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d28-mission-report-east-china-sea-2024.pdf',
|
| 56 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d3-mission-report-arabian-gulf-2020.pdf',
|
| 57 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d32-mission-report,-syria-october-2024.pdf',
|
| 58 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d33-mission-report-greece-october-2023.pdf',
|
| 59 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d35-mission-report-greece-october-2023.pdf',
|
| 60 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d38-range-fouler-debrief-middle-east-may-2020.pdf',
|
| 61 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d4-mission-report-arabian-gulf-2020.pdf',
|
| 62 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d42-range-fouler-debrief-japan-2023.pdf',
|
| 63 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d44-range-fouler-arabian-sea-october-2020.pdf',
|
| 64 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d48-report-september-1996.pdf',
|
| 65 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d49-launch-summary-february-2000.pdf',
|
| 66 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d5-mission-report-arabian-gulf-2020.pdf',
|
| 67 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d50-email-correspondence-indopacom-april-2025.pdf',
|
| 68 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d51-email-correspondence-pacific-time-zone-march-2023.pdf',
|
| 69 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d52-email-correspondance-na-august-2024.pdf',
|
| 70 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d54-mission-report-mediterranean-sea-na.pdf',
|
| 71 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d55-mission-report-syria-november-2016.pdf',
|
| 72 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d56-range-fouler-debrief-arabian-sea-august-2020.pdf',
|
| 73 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d57-mission-report-gulf-of-aden-september-2020.pdf',
|
| 74 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d58-range-fouler-debrief-na-october-2020.pdf',
|
| 75 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d6-mission-report-arabian-gulf-2020.pdf',
|
| 76 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d60-mission-report-persian-gulf-august-2020.pdf',
|
| 77 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d61-mission-report-persian-gulf-august-2020.pdf',
|
| 78 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d62-mission-report-strait-of-hormuz-september-2020.pdf',
|
| 79 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d63-mission-report-strait-of-hormuz-october-2020.pdf',
|
| 80 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d64-mission-report-iran-november-2020.pdf',
|
| 81 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d65-mission-report-persian-gulf-july-2020.pdf',
|
| 82 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d7-mission-report-arabian-gulf-2020.pdf',
|
| 83 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d74-mission-report-syria-november-2023.pdf',
|
| 84 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d75-mission-report-gulf-of-aden-july-2024.pdf',
|
| 85 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-d8-mission-report-djibouti-2025.pdf',
|
| 86 |
+
'https://www.war.gov/medialink/ufo/release_1/dow-uap-pr20.pdf',
|
| 87 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b1.pdf',
|
| 88 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b10.pdf',
|
| 89 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b11.pdf',
|
| 90 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b12.pdf',
|
| 91 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b13.pdf',
|
| 92 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b14.pdf',
|
| 93 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b15.pdf',
|
| 94 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b16.pdf',
|
| 95 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b17.pdf',
|
| 96 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b18.pdf',
|
| 97 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b19.pdf',
|
| 98 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b2.pdf',
|
| 99 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b20.pdf',
|
| 100 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b21.pdf',
|
| 101 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b22.pdf',
|
| 102 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b23.pdf',
|
| 103 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b24.pdf',
|
| 104 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b3.pdf',
|
| 105 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b4.pdf',
|
| 106 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b5.pdf',
|
| 107 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b6.pdf',
|
| 108 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b7.pdf',
|
| 109 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b8.pdf',
|
| 110 |
+
'https://www.war.gov/medialink/ufo/release_1/fbi-photo-b9.pdf',
|
| 111 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d1-apollo-12-transcript-1969.pdf',
|
| 112 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d2-apollo-17-transcript-1972.pdf',
|
| 113 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d3-gemini-7-transcript-1965.pdf',
|
| 114 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d4-apollo-11-technical-crew-debriefing-1969.pdf',
|
| 115 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d5-apollo-17-crew-debriefing-for-science-1973.pdf',
|
| 116 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d6-apollo-17-technical-crew-debriefing-1973.pdf',
|
| 117 |
+
'https://www.war.gov/medialink/ufo/release_1/nasa-uap-d7-skylab-technical-crew-debriefing-1973.pdf',
|
| 118 |
+
'https://www.war.gov/medialink/ufo/release_1/dos-uap-d1-cable-1-papua-new-guinea-january-1985.pdf',
|
| 119 |
+
'https://www.war.gov/medialink/ufo/release_1/dos-uap-d2-cable-2-kazakhstan-january-1994.pdf',
|
| 120 |
+
'https://www.war.gov/medialink/ufo/release_1/059uap00011.pdf',
|
| 121 |
+
'https://www.war.gov/medialink/ufo/release_1/059uap00012.pdf',
|
| 122 |
+
'https://www.war.gov/medialink/ufo/release_1/059uap00013.pdf',
|
| 123 |
+
'https://www.war.gov/medialink/ufo/release_1/usper-statement-redacted.pdf',
|
| 124 |
+
'https://www.war.gov/medialink/ufo/release_1/2024-04-30-composite-sketch.pdf',
|
| 125 |
+
'https://www.war.gov/medialink/ufo/release_1/serial 5 redacted_redacted.pdf',
|
| 126 |
+
'https://www.war.gov/medialink/ufo/release_1/serial-3_redacted.pdf',
|
| 127 |
+
'https://www.war.gov/medialink/ufo/release_1/serial-4-redacted_redacted.pdf',
|
| 128 |
+
'https://www.war.gov/medialink/ufo/release_1/western_us_event_slides_5.08.2026.pdf',
|
| 129 |
+
]
|
| 130 |
+
|
| 131 |
+
HEADERS = {
|
| 132 |
+
"User-Agent": (
|
| 133 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 134 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 135 |
+
"Chrome/124.0.0.0 Safari/537.36"
|
| 136 |
+
),
|
| 137 |
+
"Accept": "application/pdf,application/octet-stream,*/*;q=0.9",
|
| 138 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 139 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 140 |
+
"Referer": "https://www.war.gov/",
|
| 141 |
+
"Origin": "https://www.war.gov",
|
| 142 |
+
"Sec-Fetch-Dest": "document",
|
| 143 |
+
"Sec-Fetch-Mode": "navigate",
|
| 144 |
+
"Sec-Fetch-Site": "same-origin",
|
| 145 |
+
"Connection": "keep-alive",
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def download_all():
|
| 150 |
+
output_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "UAP_PDFs")
|
| 151 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 152 |
+
|
| 153 |
+
# Prime a session cookie by visiting the homepage first
|
| 154 |
+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
|
| 155 |
+
urllib.request.install_opener(opener)
|
| 156 |
+
try:
|
| 157 |
+
print("Priming session on war.gov...")
|
| 158 |
+
req0 = urllib.request.Request("https://www.war.gov/", headers=HEADERS)
|
| 159 |
+
opener.open(req0, timeout=15)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
print(f" (homepage pre-fetch skipped: {e})")
|
| 162 |
+
|
| 163 |
+
success, failed = [], []
|
| 164 |
+
for i, url in enumerate(PDF_URLS):
|
| 165 |
+
filename = os.path.basename(urllib.parse.unquote(url))
|
| 166 |
+
filepath = os.path.join(output_dir, filename)
|
| 167 |
+
if os.path.exists(filepath) and os.path.getsize(filepath) > 1024:
|
| 168 |
+
print(f"[{i+1}/{len(PDF_URLS)}] SKIP (exists): {filename}")
|
| 169 |
+
success.append(filename)
|
| 170 |
+
continue
|
| 171 |
+
try:
|
| 172 |
+
req = urllib.request.Request(url, headers=HEADERS)
|
| 173 |
+
with opener.open(req, timeout=30) as resp:
|
| 174 |
+
data = resp.read()
|
| 175 |
+
if len(data) < 100:
|
| 176 |
+
raise ValueError(f"Response too small ({len(data)} bytes) — likely an error page")
|
| 177 |
+
with open(filepath, "wb") as f:
|
| 178 |
+
f.write(data)
|
| 179 |
+
size_kb = len(data) // 1024
|
| 180 |
+
print(f"[{i+1}/{len(PDF_URLS)}] OK ({size_kb} KB): {filename}")
|
| 181 |
+
success.append(filename)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
print(f"[{i+1}/{len(PDF_URLS)}] FAIL: {filename} — {e}")
|
| 184 |
+
failed.append((url, str(e)))
|
| 185 |
+
time.sleep(0.4)
|
| 186 |
+
|
| 187 |
+
print(f"\n{'='*60}")
|
| 188 |
+
print(f"Downloaded: {len(success)}/{len(PDF_URLS)}")
|
| 189 |
+
if failed:
|
| 190 |
+
print(f"\nFailed ({len(failed)}):")
|
| 191 |
+
for url, err in failed:
|
| 192 |
+
print(f" {os.path.basename(url)}: {err}")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
if __name__ == "__main__":
|
| 196 |
+
download_all()
|
pipeline/extract_reports.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
extract_reports.py
|
| 3 |
+
──────────────────
|
| 4 |
+
For every concat/*.md whose filename does NOT start with a digit, sends the
|
| 5 |
+
full text to Gemini and asks it to extract each individual UAP/UFO sighting
|
| 6 |
+
report as structured JSON.
|
| 7 |
+
|
| 8 |
+
Output per file:
|
| 9 |
+
{
|
| 10 |
+
"source_file": "dow-uap-d33-mission-report-greece-october-2023.md",
|
| 11 |
+
"document_id": "dow-uap-d33-mission-report-greece-october-2023",
|
| 12 |
+
"agency": "DOD",
|
| 13 |
+
"collection": "mission-reports",
|
| 14 |
+
"region": "greece",
|
| 15 |
+
"manifest": { ... war.gov CSV row, if --csv given ... },
|
| 16 |
+
"reports": [
|
| 17 |
+
{
|
| 18 |
+
"document_id": "dow-uap-d33-mission-report-greece-october-2023",
|
| 19 |
+
"agency": "DOD",
|
| 20 |
+
"collection": "mission-reports",
|
| 21 |
+
"pages": "page_0004-page_0006",
|
| 22 |
+
"raw_text": "<verbatim text block for this report>",
|
| 23 |
+
"assessment": "<verbatim assessment / analysis block, or null>"
|
| 24 |
+
},
|
| 25 |
+
...
|
| 26 |
+
]
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
No summarisation — raw text only. Page numbers, agency and collection are derived
|
| 30 |
+
deterministically from the document path and the '## page_XXXX' markers — NOT from
|
| 31 |
+
the LLM, which only identifies report boundaries (raw_text + assessment).
|
| 32 |
+
|
| 33 |
+
Chunking
|
| 34 |
+
--------
|
| 35 |
+
Large documents are split into page-blocks before sending to avoid the 65,536
|
| 36 |
+
output-token ceiling. Thinking is disabled (pure extraction — no reasoning
|
| 37 |
+
needed) to maximise the usable output budget. Use --chunk-pages to tune block
|
| 38 |
+
size (default 40 pages). Chunk results are merged into a single JSON file.
|
| 39 |
+
|
| 40 |
+
Parallelism
|
| 41 |
+
-----------
|
| 42 |
+
--workers N runs N files concurrently (default 1 = sequential).
|
| 43 |
+
Gemini API handles concurrent requests; stay within your rate-limit quota.
|
| 44 |
+
|
| 45 |
+
Usage
|
| 46 |
+
-----
|
| 47 |
+
set GEMINI_API_KEY=your_key_here (Windows CMD)
|
| 48 |
+
export GEMINI_API_KEY=your_key_here (bash)
|
| 49 |
+
|
| 50 |
+
python extract_reports.py
|
| 51 |
+
python extract_reports.py --concat D:/divided/concat --out D:/divided/extracted
|
| 52 |
+
python extract_reports.py --file dow-uap-d33-mission-report-greece-october-2023.md
|
| 53 |
+
python extract_reports.py --workers 4 # parallel files
|
| 54 |
+
python extract_reports.py --chunk-pages 30 # smaller chunks
|
| 55 |
+
python extract_reports.py --no-skip # re-process existing
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
import os
|
| 59 |
+
import re
|
| 60 |
+
import csv
|
| 61 |
+
import json
|
| 62 |
+
import time
|
| 63 |
+
import argparse
|
| 64 |
+
import threading
|
| 65 |
+
from pathlib import Path
|
| 66 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 67 |
+
|
| 68 |
+
from google import genai
|
| 69 |
+
from google.genai import types
|
| 70 |
+
from google.api_core import exceptions as google_exceptions
|
| 71 |
+
|
| 72 |
+
DEFAULT_CONCAT = "D:/divided/concat"
|
| 73 |
+
DEFAULT_OUT = "D:/divided/extracted"
|
| 74 |
+
DEFAULT_CHUNK_PAGES = 40 # pages per API call; tune down if still truncating
|
| 75 |
+
DEFAULT_WORKERS = 1 # concurrent files; increase for throughput
|
| 76 |
+
MAX_RETRIES = 6 # retries on 429 / 503 before giving up
|
| 77 |
+
RETRY_BASE_SECS = 5 # first wait; doubles each attempt (5, 10, 20, 40, 80, 160)
|
| 78 |
+
MODEL = "gemini-3.1-pro-preview"
|
| 79 |
+
|
| 80 |
+
# Thread-safe print lock
|
| 81 |
+
_print_lock = threading.Lock()
|
| 82 |
+
|
| 83 |
+
SYSTEM_PROMPT = """\
|
| 84 |
+
You are a document analyst processing declassified U.S. government UAP/UFO records.
|
| 85 |
+
Your job is to identify every distinct UAP/UFO sighting report within the document.
|
| 86 |
+
No summaries, no paraphrasing — raw verbatim text only.
|
| 87 |
+
|
| 88 |
+
Rules:
|
| 89 |
+
1. Each "report" is one coherent UAP/UFO sighting description (may span one or more pages).
|
| 90 |
+
2. "raw_text" must contain the verbatim text of that sighting report exactly as it appears
|
| 91 |
+
in the source, including headers, field labels, and any partially redacted content.
|
| 92 |
+
Do NOT shorten, paraphrase, or omit anything.
|
| 93 |
+
3. "assessment" must contain the verbatim text of any analyst assessment, conclusion,
|
| 94 |
+
classification, or recommendation block associated with that report.
|
| 95 |
+
Use null if none is present.
|
| 96 |
+
4. Return the reports in the order they appear in the document.
|
| 97 |
+
5. If the whole document is a single report, return one entry.
|
| 98 |
+
6. If this is a chunk of a larger document, extract only what appears in THIS chunk.
|
| 99 |
+
|
| 100 |
+
Do NOT output page numbers. Page ranges are assigned deterministically afterwards
|
| 101 |
+
from the document's '## page_XXXX' markers — that is not your job.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
# JSON schema for structured output — enforced at API level, no manual parsing needed.
|
| 105 |
+
# Google GenAI uses OpenAPI 3.0 style: nullable fields use {"type": "string", "nullable": True}
|
| 106 |
+
# NOT JSON Schema draft-07 union types like {"type": ["string", "null"]}.
|
| 107 |
+
RESPONSE_SCHEMA = {
|
| 108 |
+
"type": "object",
|
| 109 |
+
"properties": {
|
| 110 |
+
"reports": {
|
| 111 |
+
"type": "array",
|
| 112 |
+
"items": {
|
| 113 |
+
"type": "object",
|
| 114 |
+
"properties": {
|
| 115 |
+
"raw_text": {"type": "string"},
|
| 116 |
+
"assessment": {"type": "string", "nullable": True},
|
| 117 |
+
},
|
| 118 |
+
"required": ["raw_text", "assessment"],
|
| 119 |
+
},
|
| 120 |
+
},
|
| 121 |
+
},
|
| 122 |
+
"required": ["reports"],
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# ── page splitting ─────────────────────────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
PAGE_HEADER_RE = re.compile(r"^## (page_\d+)\s*$", re.MULTILINE)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def split_into_chunks(text: str, chunk_pages: int) -> list[tuple[str, str]]:
|
| 131 |
+
"""
|
| 132 |
+
Split a concat .md (pages separated by '## page_XXXX' headers) into
|
| 133 |
+
blocks of at most chunk_pages pages.
|
| 134 |
+
|
| 135 |
+
Returns list of (label, chunk_text) where label is e.g. "page_0001-page_0040".
|
| 136 |
+
If the document has no page headers (single-page or unstamped), returns one chunk.
|
| 137 |
+
"""
|
| 138 |
+
# Find all page header positions
|
| 139 |
+
headers = [(m.group(1), m.start()) for m in PAGE_HEADER_RE.finditer(text)]
|
| 140 |
+
|
| 141 |
+
if not headers:
|
| 142 |
+
return [("all-pages", text)]
|
| 143 |
+
|
| 144 |
+
# Build page boundaries
|
| 145 |
+
chunks = []
|
| 146 |
+
for i in range(0, len(headers), chunk_pages):
|
| 147 |
+
block_headers = headers[i : i + chunk_pages]
|
| 148 |
+
start_pos = block_headers[0][1]
|
| 149 |
+
end_pos = headers[i + chunk_pages][1] if (i + chunk_pages) < len(headers) else len(text)
|
| 150 |
+
first_page = block_headers[0][0]
|
| 151 |
+
last_page = block_headers[-1][0]
|
| 152 |
+
label = first_page if first_page == last_page else f"{first_page}-{last_page}"
|
| 153 |
+
chunks.append((label, text[start_pos:end_pos]))
|
| 154 |
+
|
| 155 |
+
return chunks
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# ── deterministic metadata — page numbers, agency, collection ──────────────────
|
| 159 |
+
# These come from the document path and the '## page_XXXX' markers, NOT the LLM.
|
| 160 |
+
# The LLM only identifies report boundaries (raw_text + assessment).
|
| 161 |
+
|
| 162 |
+
_AGENCY_PREFIXES = [
|
| 163 |
+
("dow-uap-d", ("DOD", "mission-reports")),
|
| 164 |
+
("dow-uap-", ("DOD", "mission-reports")),
|
| 165 |
+
("dod-range-fouler", ("DOD", "range-fouler-debriefs")),
|
| 166 |
+
("dod-email", ("DOD", "email-correspondence")),
|
| 167 |
+
("dod-", ("DOD", None)),
|
| 168 |
+
("pr-", ("DOD", "mission-reports")),
|
| 169 |
+
("fbi-", ("FBI", "photo-collections")),
|
| 170 |
+
("nasa-transcript", ("NASA", "transcripts")),
|
| 171 |
+
("nasa-crew", ("NASA", "crew-debriefings")),
|
| 172 |
+
("nasa-", ("NASA", None)),
|
| 173 |
+
("dos-", ("DOS", "cables")),
|
| 174 |
+
("65_hs1-834228961", ("NARA-CIA", "hs1-834228961")),
|
| 175 |
+
("65_hs1-101634279", ("NARA-CIA", "hs1-101634279")),
|
| 176 |
+
("18_", ("NARA-CIA", "series-18")),
|
| 177 |
+
("38_", ("NARA-CIA", "series-38")),
|
| 178 |
+
("59_", ("NARA-CIA", "series-59")),
|
| 179 |
+
("255_", ("NARA-CIA", "series-255")),
|
| 180 |
+
("331_", ("NARA-CIA", "series-331")),
|
| 181 |
+
("341_", ("NARA-CIA", "series-341")),
|
| 182 |
+
("342_", ("NARA-CIA", "series-342")),
|
| 183 |
+
("series-", ("NARA-CIA", None)),
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _first_frontmatter(text: str) -> str:
|
| 188 |
+
"""Body of the first YAML frontmatter block (stamp_pages stamp), or ''."""
|
| 189 |
+
m = re.search(r"(?ms)^---\r?\n(.*?)\r?\n---\r?\n", text)
|
| 190 |
+
return m.group(1) if m else ""
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _fm_value(block: str, key: str):
|
| 194 |
+
"""Read a single `key: value` line from a frontmatter block."""
|
| 195 |
+
m = re.search(rf"(?m)^{re.escape(key)}:\s*(.*)$", block)
|
| 196 |
+
if not m:
|
| 197 |
+
return None
|
| 198 |
+
v = m.group(1).strip().strip('"').strip("'")
|
| 199 |
+
return v if v and v.lower() != "null" else None
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def resolve_doc_meta(text: str, document_id: str):
|
| 203 |
+
"""(agency, collection, region) — from the stamped frontmatter (path-derived)
|
| 204 |
+
when present, else from the document slug prefix. Never from the LLM."""
|
| 205 |
+
fm = _first_frontmatter(text)
|
| 206 |
+
agency = _fm_value(fm, "agency")
|
| 207 |
+
subtype = _fm_value(fm, "subtype")
|
| 208 |
+
region = _fm_value(fm, "region")
|
| 209 |
+
if agency:
|
| 210 |
+
return agency, subtype, region
|
| 211 |
+
low = document_id.lower()
|
| 212 |
+
for prefix, (ag, coll) in _AGENCY_PREFIXES:
|
| 213 |
+
if low.startswith(prefix):
|
| 214 |
+
return ag, coll, region
|
| 215 |
+
return None, None, region
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _page_spans(chunk_text: str):
|
| 219 |
+
"""[(page_label, start, end)] character spans for each '## page_XXXX' section."""
|
| 220 |
+
hdrs = [(m.group(1), m.start()) for m in PAGE_HEADER_RE.finditer(chunk_text)]
|
| 221 |
+
spans = []
|
| 222 |
+
for i, (label, start) in enumerate(hdrs):
|
| 223 |
+
end = hdrs[i + 1][1] if i + 1 < len(hdrs) else len(chunk_text)
|
| 224 |
+
spans.append((label, start, end))
|
| 225 |
+
return spans
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def attach_pages(reports: list, chunk_text: str, chunk_label: str) -> list:
|
| 229 |
+
"""Deterministically set each report's 'pages' from the document's
|
| 230 |
+
'## page_XXXX' markers.
|
| 231 |
+
|
| 232 |
+
A multi-page report's raw_text has the page markers/separators stripped, so
|
| 233 |
+
the full block won't match the chunk verbatim. Instead we anchor on the
|
| 234 |
+
report's first and last non-empty *lines* — a single line never spans a
|
| 235 |
+
page break — and map that [start, end] span onto the page markers it
|
| 236 |
+
covers. Reports are assumed to be in document order (the prompt requires it).
|
| 237 |
+
"""
|
| 238 |
+
spans = _page_spans(chunk_text)
|
| 239 |
+
full_range = chunk_label
|
| 240 |
+
if spans:
|
| 241 |
+
full_range = (spans[0][0] if spans[0][0] == spans[-1][0]
|
| 242 |
+
else f"{spans[0][0]}-{spans[-1][0]}")
|
| 243 |
+
cursor = 0
|
| 244 |
+
for rep in reports:
|
| 245 |
+
lines = [ln.strip() for ln in (rep.get("raw_text") or "").splitlines()
|
| 246 |
+
if ln.strip()]
|
| 247 |
+
if not spans or not lines:
|
| 248 |
+
rep["pages"] = None if chunk_label == "all-pages" else full_range
|
| 249 |
+
continue
|
| 250 |
+
first, last = lines[0][:120], lines[-1][:120]
|
| 251 |
+
start_idx = chunk_text.find(first, cursor)
|
| 252 |
+
if start_idx < 0:
|
| 253 |
+
start_idx = chunk_text.find(first, 0)
|
| 254 |
+
if start_idx < 0:
|
| 255 |
+
rep["pages"] = full_range # could not locate — whole chunk
|
| 256 |
+
continue
|
| 257 |
+
end_hit = chunk_text.find(last, start_idx)
|
| 258 |
+
end_idx = (end_hit + len(last)) if end_hit >= 0 else start_idx + 1
|
| 259 |
+
covered = [lbl for (lbl, s, e) in spans if s < end_idx and e > start_idx]
|
| 260 |
+
rep["pages"] = (
|
| 261 |
+
covered[0] if len(covered) == 1
|
| 262 |
+
else f"{covered[0]}-{covered[-1]}" if covered
|
| 263 |
+
else full_range
|
| 264 |
+
)
|
| 265 |
+
cursor = start_idx + 1
|
| 266 |
+
return reports
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def _slug_from_title(title: str) -> str:
|
| 270 |
+
"""Normalise a CSV Title to a filesystem slug — matches reconcile.py."""
|
| 271 |
+
s = (title or "").lower().strip()
|
| 272 |
+
s = re.sub(r"[\s,]+", "-", s)
|
| 273 |
+
s = re.sub(r"[^\w\-]", "", s)
|
| 274 |
+
s = re.sub(r"-+", "-", s).strip("-")
|
| 275 |
+
return s
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def load_manifest(csv_path: Path) -> dict:
|
| 279 |
+
"""Index the war.gov release CSV by document slug → row dict."""
|
| 280 |
+
index = {}
|
| 281 |
+
with open(csv_path, encoding="utf-8-sig", newline="") as fh:
|
| 282 |
+
for row in csv.DictReader(fh):
|
| 283 |
+
slug = _slug_from_title((row.get("Title") or "").strip())
|
| 284 |
+
if slug:
|
| 285 |
+
index.setdefault(slug, row)
|
| 286 |
+
return index
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def manifest_block(manifest_index, document_id: str):
|
| 290 |
+
"""Deterministic slug-join of one document to its war.gov manifest row."""
|
| 291 |
+
if manifest_index is None:
|
| 292 |
+
return None
|
| 293 |
+
row = manifest_index.get(_slug_from_title(document_id))
|
| 294 |
+
if not row:
|
| 295 |
+
return {"matched": False}
|
| 296 |
+
g = lambda k: (row.get(k) or "").strip()
|
| 297 |
+
return {
|
| 298 |
+
"matched": True,
|
| 299 |
+
"csv_title": " ".join(g("Title").split()),
|
| 300 |
+
"release_date": g("Release Date"),
|
| 301 |
+
"redacted": g("Redaction").upper() == "TRUE",
|
| 302 |
+
"csv_agency": g("Agency"),
|
| 303 |
+
"incident_date": g("Incident Date"),
|
| 304 |
+
"incident_location": g("Incident Location"),
|
| 305 |
+
"pdf_link": g("PDF | Image Link"),
|
| 306 |
+
"dvids_video_id": g("DVIDS Video ID"),
|
| 307 |
+
"type": g("Type"),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
# ── API call ───────────────────────────────────────────────────────────────────
|
| 312 |
+
|
| 313 |
+
def _call_gemini(client: genai.Client, model: str,
|
| 314 |
+
filename: str, chunk_label: str, chunk_text: str,
|
| 315 |
+
verbose: bool = True) -> dict:
|
| 316 |
+
"""
|
| 317 |
+
Send one chunk to Gemini using structured JSON output (response_mime_type).
|
| 318 |
+
The API guarantees valid JSON matching RESPONSE_SCHEMA — no manual parsing needed.
|
| 319 |
+
Thinking is disabled (budget=0) — verbatim extraction needs no reasoning.
|
| 320 |
+
|
| 321 |
+
Returns dict with key 'reports' (list), guaranteed by the schema.
|
| 322 |
+
On API error returns {'reports': [], 'api_error': str}.
|
| 323 |
+
"""
|
| 324 |
+
prompt = (
|
| 325 |
+
f"Document filename: {filename}\n"
|
| 326 |
+
f"Pages in this chunk: {chunk_label}\n\n"
|
| 327 |
+
f"Document content:\n\n{chunk_text}"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
# Blocking call — response is returned in full once complete.
|
| 331 |
+
# Retries with exponential backoff on 429 (rate limit) and 503 (overload).
|
| 332 |
+
full_response = ""
|
| 333 |
+
for attempt in range(1, MAX_RETRIES + 1):
|
| 334 |
+
try:
|
| 335 |
+
response = client.models.generate_content(
|
| 336 |
+
model=model,
|
| 337 |
+
contents=[
|
| 338 |
+
types.Content(role="user", parts=[types.Part.from_text(text=prompt)])
|
| 339 |
+
],
|
| 340 |
+
config=types.GenerateContentConfig(
|
| 341 |
+
system_instruction=SYSTEM_PROMPT,
|
| 342 |
+
response_mime_type="application/json",
|
| 343 |
+
response_json_schema=RESPONSE_SCHEMA,
|
| 344 |
+
thinking_config=types.ThinkingConfig(thinking_budget=-1),
|
| 345 |
+
),
|
| 346 |
+
)
|
| 347 |
+
full_response = response.text or ""
|
| 348 |
+
|
| 349 |
+
# Success — break out of retry loop
|
| 350 |
+
break
|
| 351 |
+
|
| 352 |
+
except google_exceptions.ResourceExhausted as exc:
|
| 353 |
+
# 429 — rate limited; back off and retry
|
| 354 |
+
wait = RETRY_BASE_SECS * (2 ** (attempt - 1))
|
| 355 |
+
with _print_lock:
|
| 356 |
+
print(f"\n ⏳ 429 rate limit ({filename} chunk {chunk_label}) "
|
| 357 |
+
f"attempt {attempt}/{MAX_RETRIES} — waiting {wait}s …")
|
| 358 |
+
if attempt == MAX_RETRIES:
|
| 359 |
+
return {"reports": [], "api_error": f"429 after {MAX_RETRIES} retries: {exc}"}
|
| 360 |
+
time.sleep(wait)
|
| 361 |
+
|
| 362 |
+
except google_exceptions.ServiceUnavailable as exc:
|
| 363 |
+
# 503 — transient overload; same backoff
|
| 364 |
+
wait = RETRY_BASE_SECS * (2 ** (attempt - 1))
|
| 365 |
+
with _print_lock:
|
| 366 |
+
print(f"\n ⏳ 503 unavailable ({filename} chunk {chunk_label}) "
|
| 367 |
+
f"attempt {attempt}/{MAX_RETRIES} — waiting {wait}s …")
|
| 368 |
+
if attempt == MAX_RETRIES:
|
| 369 |
+
return {"reports": [], "api_error": f"503 after {MAX_RETRIES} retries: {exc}"}
|
| 370 |
+
time.sleep(wait)
|
| 371 |
+
|
| 372 |
+
except Exception as exc:
|
| 373 |
+
# Non-retryable error
|
| 374 |
+
with _print_lock:
|
| 375 |
+
print(f"\n ✗ API error ({filename} chunk {chunk_label}): {exc}")
|
| 376 |
+
return {"reports": [], "api_error": str(exc)}
|
| 377 |
+
|
| 378 |
+
if verbose:
|
| 379 |
+
with _print_lock:
|
| 380 |
+
print(f" {filename} [{chunk_label}] ✓ {len(full_response)} chars")
|
| 381 |
+
|
| 382 |
+
# Guaranteed valid JSON from the API; defensive parse handles edge cases
|
| 383 |
+
try:
|
| 384 |
+
return json.loads(full_response)
|
| 385 |
+
except json.JSONDecodeError as e:
|
| 386 |
+
with _print_lock:
|
| 387 |
+
print(f"\n ⚠ Unexpected JSON parse error ({filename} chunk {chunk_label}): {e}")
|
| 388 |
+
return {"reports": [], "parse_error": str(e), "raw_response": full_response}
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# ── per-file processing ────────────────────────────────────────────────────────
|
| 392 |
+
|
| 393 |
+
def files_to_process(concat_dir: Path, single: str | None,
|
| 394 |
+
out_dir: Path, skip_existing: bool) -> list[Path]:
|
| 395 |
+
if single:
|
| 396 |
+
p = concat_dir / single
|
| 397 |
+
return [p] if p.exists() else []
|
| 398 |
+
all_md = sorted(concat_dir.glob("*.md"))
|
| 399 |
+
# Exclude only the combined output file and other underscore-prefixed files.
|
| 400 |
+
# Do NOT exclude digit-prefixed files (65_hs1-..., 18_..., 255_... are valid docs).
|
| 401 |
+
candidates = [f for f in all_md if not f.name.startswith("_")]
|
| 402 |
+
if skip_existing:
|
| 403 |
+
candidates = [f for f in candidates
|
| 404 |
+
if not (out_dir / (f.stem + ".json")).exists()]
|
| 405 |
+
return candidates
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def extract_file(client: genai.Client, model: str,
|
| 409 |
+
md_path: Path, out_dir: Path, chunk_pages: int,
|
| 410 |
+
file_index: int, file_total: int,
|
| 411 |
+
manifest_index: dict | None = None,
|
| 412 |
+
multi_worker: bool = False) -> dict:
|
| 413 |
+
"""
|
| 414 |
+
Process one concat .md file: chunk it, call Gemini per chunk, merge reports.
|
| 415 |
+
Writes the per-file JSON immediately and returns the result dict.
|
| 416 |
+
|
| 417 |
+
multi_worker=True suppresses per-chunk noise and prints only one summary line
|
| 418 |
+
per file — keeps output readable when many workers run concurrently.
|
| 419 |
+
"""
|
| 420 |
+
t0 = time.time()
|
| 421 |
+
|
| 422 |
+
# Announce start only in single-worker or low-worker mode
|
| 423 |
+
if not multi_worker:
|
| 424 |
+
with _print_lock:
|
| 425 |
+
print(f"\n{'─'*70}")
|
| 426 |
+
print(f" [{file_index:>3}/{file_total}] {md_path.name}")
|
| 427 |
+
|
| 428 |
+
text = md_path.read_text(encoding="utf-8", errors="replace")
|
| 429 |
+
chunks = split_into_chunks(text, chunk_pages)
|
| 430 |
+
document_id = md_path.stem
|
| 431 |
+
agency, collection, region = resolve_doc_meta(text, document_id)
|
| 432 |
+
|
| 433 |
+
if not multi_worker:
|
| 434 |
+
with _print_lock:
|
| 435 |
+
print(f" Chunks: {len(chunks)} (chunk_pages={chunk_pages})")
|
| 436 |
+
print(f"{'─'*70}\n")
|
| 437 |
+
|
| 438 |
+
all_reports = []
|
| 439 |
+
parse_errors = []
|
| 440 |
+
|
| 441 |
+
for ci, (label, chunk_text) in enumerate(chunks, 1):
|
| 442 |
+
if not multi_worker and len(chunks) > 1:
|
| 443 |
+
with _print_lock:
|
| 444 |
+
print(f"\n ── chunk {ci}/{len(chunks)} [{label}] ──\n")
|
| 445 |
+
|
| 446 |
+
# Pass verbose=False when running multi-worker to suppress per-call noise
|
| 447 |
+
inner = _call_gemini(client, model, md_path.name, label, chunk_text,
|
| 448 |
+
verbose=not multi_worker)
|
| 449 |
+
# Page numbers + agency/collection are derived from the path, not the LLM.
|
| 450 |
+
# document_id / agency / collection are NOT repeated here — they already
|
| 451 |
+
# live in the document-level envelope. Repeating them causes a pandas
|
| 452 |
+
# column-overlap error when json_normalize flattens records + meta together.
|
| 453 |
+
for _rep in attach_pages(inner.get("reports", []), chunk_text, label):
|
| 454 |
+
all_reports.append({
|
| 455 |
+
"pages": _rep.get("pages"),
|
| 456 |
+
"raw_text": _rep.get("raw_text", ""),
|
| 457 |
+
"assessment": _rep.get("assessment"),
|
| 458 |
+
})
|
| 459 |
+
if "parse_error" in inner:
|
| 460 |
+
parse_errors.append({"chunk": label, "error": inner["parse_error"]})
|
| 461 |
+
if "api_error" in inner:
|
| 462 |
+
parse_errors.append({"chunk": label, "error": inner["api_error"]})
|
| 463 |
+
|
| 464 |
+
result = {
|
| 465 |
+
"source_file": md_path.name,
|
| 466 |
+
"document_id": document_id,
|
| 467 |
+
"agency": agency,
|
| 468 |
+
"collection": collection,
|
| 469 |
+
"region": region,
|
| 470 |
+
"chunk_count": len(chunks),
|
| 471 |
+
"manifest": manifest_block(manifest_index, document_id),
|
| 472 |
+
"reports": all_reports,
|
| 473 |
+
}
|
| 474 |
+
if parse_errors:
|
| 475 |
+
result["parse_errors"] = parse_errors
|
| 476 |
+
|
| 477 |
+
out_file = out_dir / (md_path.stem + ".json")
|
| 478 |
+
out_file.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 479 |
+
|
| 480 |
+
with _print_lock:
|
| 481 |
+
n = len(all_reports)
|
| 482 |
+
errs = f" ⚠ {len(parse_errors)} chunk error(s)" if parse_errors else ""
|
| 483 |
+
print(f"\n ✓ {n} report(s) → {out_file.name}{errs}")
|
| 484 |
+
|
| 485 |
+
return result
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
# ── main ───────────────────────────────────────────────────────────────────────
|
| 489 |
+
|
| 490 |
+
def main():
|
| 491 |
+
ap = argparse.ArgumentParser(
|
| 492 |
+
description="Extract UAP reports from concat .md files via Gemini"
|
| 493 |
+
)
|
| 494 |
+
ap.add_argument("--concat", default=DEFAULT_CONCAT,
|
| 495 |
+
help="Path to concat/ folder")
|
| 496 |
+
ap.add_argument("--out", default=DEFAULT_OUT,
|
| 497 |
+
help="Output folder for JSON files")
|
| 498 |
+
ap.add_argument("--file", default=None,
|
| 499 |
+
help="Process a single file by name")
|
| 500 |
+
ap.add_argument("--model", default=MODEL,
|
| 501 |
+
help=f"Gemini model (default: {MODEL})")
|
| 502 |
+
ap.add_argument("--chunk-pages", type=int, default=DEFAULT_CHUNK_PAGES,
|
| 503 |
+
help=f"Max pages per API call (default: {DEFAULT_CHUNK_PAGES}). "
|
| 504 |
+
f"Reduce to 20-25 if still truncating.")
|
| 505 |
+
ap.add_argument("--workers", type=int, default=DEFAULT_WORKERS,
|
| 506 |
+
help=f"Concurrent files (default: {DEFAULT_WORKERS}). "
|
| 507 |
+
f"Increase for throughput; mind rate limits.")
|
| 508 |
+
ap.add_argument("--no-skip", action="store_true",
|
| 509 |
+
help="Re-process files that already have a JSON output")
|
| 510 |
+
ap.add_argument("--csv", default=None,
|
| 511 |
+
help="war.gov manifest CSV (e.g. uap-csv.csv). When given, each "
|
| 512 |
+
"document's release metadata (agency, release date, "
|
| 513 |
+
"redaction, PDF link, incident date/location) is joined in "
|
| 514 |
+
"deterministically by document slug.")
|
| 515 |
+
args = ap.parse_args()
|
| 516 |
+
|
| 517 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
| 518 |
+
if not api_key:
|
| 519 |
+
raise SystemExit(" ✗ GEMINI_API_KEY environment variable not set.")
|
| 520 |
+
|
| 521 |
+
concat_dir = Path(args.concat)
|
| 522 |
+
out_dir = Path(args.out)
|
| 523 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 524 |
+
|
| 525 |
+
manifest_index = None
|
| 526 |
+
if args.csv:
|
| 527 |
+
try:
|
| 528 |
+
manifest_index = load_manifest(Path(args.csv))
|
| 529 |
+
except Exception as exc:
|
| 530 |
+
raise SystemExit(f" ✗ Could not read manifest CSV {args.csv}: {exc}")
|
| 531 |
+
|
| 532 |
+
files = files_to_process(concat_dir, args.file, out_dir,
|
| 533 |
+
skip_existing=not args.no_skip)
|
| 534 |
+
if not files:
|
| 535 |
+
already = len(list(out_dir.glob("*.json")))
|
| 536 |
+
print(f" No new files to process. "
|
| 537 |
+
f"({already} already extracted — use --no-skip to reprocess)")
|
| 538 |
+
return
|
| 539 |
+
|
| 540 |
+
print(f"\n Files to process : {len(files)}")
|
| 541 |
+
print(f" Model : {args.model}")
|
| 542 |
+
print(f" Chunk size : {args.chunk_pages} pages / call")
|
| 543 |
+
print(f" Workers : {args.workers}")
|
| 544 |
+
print(f" Thinking : none (budget=-1)")
|
| 545 |
+
if manifest_index is not None:
|
| 546 |
+
print(f" Manifest CSV : {len(manifest_index)} documents indexed")
|
| 547 |
+
print(f" Output : {out_dir}\n")
|
| 548 |
+
|
| 549 |
+
client = genai.Client(api_key=api_key)
|
| 550 |
+
total = len(files)
|
| 551 |
+
all_results = [None] * total
|
| 552 |
+
|
| 553 |
+
if args.workers <= 1:
|
| 554 |
+
for i, md_path in enumerate(files, 1):
|
| 555 |
+
all_results[i - 1] = extract_file(
|
| 556 |
+
client, args.model, md_path, out_dir, args.chunk_pages, i, total,
|
| 557 |
+
manifest_index=manifest_index,
|
| 558 |
+
)
|
| 559 |
+
else:
|
| 560 |
+
futures = {}
|
| 561 |
+
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
| 562 |
+
for i, md_path in enumerate(files, 1):
|
| 563 |
+
fut = pool.submit(
|
| 564 |
+
extract_file,
|
| 565 |
+
client, args.model, md_path, out_dir, args.chunk_pages, i, total,
|
| 566 |
+
manifest_index,
|
| 567 |
+
)
|
| 568 |
+
futures[fut] = i - 1
|
| 569 |
+
for fut in as_completed(futures):
|
| 570 |
+
idx = futures[fut]
|
| 571 |
+
try:
|
| 572 |
+
all_results[idx] = fut.result()
|
| 573 |
+
except Exception as exc:
|
| 574 |
+
with _print_lock:
|
| 575 |
+
print(f"\n ✗ Worker error for {files[idx].name}: {exc}")
|
| 576 |
+
all_results[idx] = {"source_file": files[idx].name,
|
| 577 |
+
"reports": [], "error": str(exc)}
|
| 578 |
+
|
| 579 |
+
# Combined output (merge with any pre-existing entries)
|
| 580 |
+
combined_path = out_dir / "_all_reports.json"
|
| 581 |
+
combined = [r for r in all_results if r is not None]
|
| 582 |
+
combined_path.write_text(
|
| 583 |
+
json.dumps(combined, indent=2, ensure_ascii=False), encoding="utf-8"
|
| 584 |
+
)
|
| 585 |
+
total_reports = sum(len(r.get("reports", [])) for r in combined)
|
| 586 |
+
print(f"\n{'═'*70}")
|
| 587 |
+
print(f" Done. {len(combined)} files → {total_reports} total reports")
|
| 588 |
+
print(f" Combined → {combined_path}")
|
| 589 |
+
print(f"{'═'*70}\n")
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
if __name__ == "__main__":
|
| 593 |
+
main()
|
pipeline/find_missing_concat.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
find_missing_concat.py
|
| 3 |
+
──────────────────────
|
| 4 |
+
Compares document folders (those with page_XXXX subfolders) against the
|
| 5 |
+
files already written to concat/, and reports what's missing and why.
|
| 6 |
+
|
| 7 |
+
Usage
|
| 8 |
+
-----
|
| 9 |
+
python find_missing_concat.py
|
| 10 |
+
python find_missing_concat.py --root D:/divided
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
import os
|
| 15 |
+
import argparse
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
DEFAULT_ROOT = "D:/divided"
|
| 19 |
+
|
| 20 |
+
PAGE_DIR_RE = re.compile(r"^page_(\d+)$", re.IGNORECASE)
|
| 21 |
+
SKIP_NAMES = {
|
| 22 |
+
"reorganize.py", "restructure_pages.py", "stamp_pages.py",
|
| 23 |
+
"reconcile.py", "pdf_to_reports.py", "concat_pages.py",
|
| 24 |
+
"find_missing_concat.py", "audit.py",
|
| 25 |
+
"uap_record_schema.yaml", "uap-csv.csv",
|
| 26 |
+
"move_log.json", "page_move_log.json",
|
| 27 |
+
"records", "reports_out", "pages_out", "wiki",
|
| 28 |
+
"MISC", "DOD", "NASA", "FBI", "DOS", "NARA-CIA",
|
| 29 |
+
"CLAUDE.md", "Untitled.md", "README.md",
|
| 30 |
+
"scripts", "raw", "concat",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def find_document_dirs(root: Path) -> list[Path]:
|
| 35 |
+
doc_dirs = []
|
| 36 |
+
for dirpath, dirnames, _ in os.walk(str(root)):
|
| 37 |
+
# Prune skip names in-place so os.walk doesn't descend into them
|
| 38 |
+
dirnames[:] = [d for d in dirnames if d not in SKIP_NAMES]
|
| 39 |
+
p = Path(dirpath)
|
| 40 |
+
if p == root:
|
| 41 |
+
continue
|
| 42 |
+
try:
|
| 43 |
+
page_subs = [
|
| 44 |
+
sub for sub in p.iterdir()
|
| 45 |
+
if sub.is_dir() and PAGE_DIR_RE.match(sub.name)
|
| 46 |
+
]
|
| 47 |
+
except (PermissionError, OSError):
|
| 48 |
+
continue
|
| 49 |
+
if page_subs:
|
| 50 |
+
doc_dirs.append(p)
|
| 51 |
+
return sorted(doc_dirs)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def count_md_pages(doc_dir: Path) -> int:
|
| 55 |
+
count = 0
|
| 56 |
+
try:
|
| 57 |
+
for sub in doc_dir.iterdir():
|
| 58 |
+
if sub.is_dir() and PAGE_DIR_RE.match(sub.name):
|
| 59 |
+
md = sub / f"{sub.name}.md"
|
| 60 |
+
if md.exists():
|
| 61 |
+
count += 1
|
| 62 |
+
except (PermissionError, OSError):
|
| 63 |
+
pass
|
| 64 |
+
return count
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
ap = argparse.ArgumentParser(description="Find document folders missing from concat/")
|
| 69 |
+
ap.add_argument("--root", default=DEFAULT_ROOT)
|
| 70 |
+
args = ap.parse_args()
|
| 71 |
+
|
| 72 |
+
root = Path(args.root)
|
| 73 |
+
concat_dir = root / "concat"
|
| 74 |
+
|
| 75 |
+
print(f"\n Scanning {root} …")
|
| 76 |
+
doc_dirs = find_document_dirs(root)
|
| 77 |
+
print(f" Found {len(doc_dirs)} document folders with page_XXXX subfolders")
|
| 78 |
+
|
| 79 |
+
existing = set()
|
| 80 |
+
if concat_dir.exists():
|
| 81 |
+
existing = {p.stem for p in concat_dir.glob("*.md")}
|
| 82 |
+
print(f" Files in concat/: {len(existing)}")
|
| 83 |
+
|
| 84 |
+
# Categorise missing docs
|
| 85 |
+
no_md = [] # page dirs exist but no .md files in them
|
| 86 |
+
has_md = [] # has .md pages but not in concat/
|
| 87 |
+
|
| 88 |
+
for d in doc_dirs:
|
| 89 |
+
if d.name in existing:
|
| 90 |
+
continue
|
| 91 |
+
md_count = count_md_pages(d)
|
| 92 |
+
if md_count == 0:
|
| 93 |
+
no_md.append((d.name, str(d.relative_to(root))))
|
| 94 |
+
else:
|
| 95 |
+
has_md.append((d.name, md_count, str(d.relative_to(root))))
|
| 96 |
+
|
| 97 |
+
print(f"\n{'─'*65}")
|
| 98 |
+
print(f" Missing from concat/: {len(no_md) + len(has_md)}")
|
| 99 |
+
print(f"{'─'*65}")
|
| 100 |
+
|
| 101 |
+
if has_md:
|
| 102 |
+
print(f"\n ❌ Have .md pages but NOT in concat/ ({len(has_md)}):")
|
| 103 |
+
print(f" (concat_pages.py should have caught these — investigate)")
|
| 104 |
+
for name, cnt, rel in has_md:
|
| 105 |
+
print(f" [{cnt:>3d} pages] {rel}")
|
| 106 |
+
|
| 107 |
+
if no_md:
|
| 108 |
+
print(f"\n ⚠️ No .md files in any page subfolder ({len(no_md)}):")
|
| 109 |
+
print(f" (OCR not yet run, or pages are PDF-only)")
|
| 110 |
+
for name, rel in no_md:
|
| 111 |
+
print(f" {rel}")
|
| 112 |
+
|
| 113 |
+
if not has_md and not no_md:
|
| 114 |
+
print("\n ✅ All document folders are represented in concat/.")
|
| 115 |
+
|
| 116 |
+
print()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
main()
|
pipeline/find_ocr_targets.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
find_ocr_targets.py
|
| 3 |
+
───────────────────
|
| 4 |
+
Walks every document folder and finds page_XXXX subfolders where a .pdf
|
| 5 |
+
exists but the matching .md does not — these are your OCR targets.
|
| 6 |
+
|
| 7 |
+
Output
|
| 8 |
+
------
|
| 9 |
+
stdout — grouped list per document
|
| 10 |
+
ocr_targets.txt — one absolute PDF path per line (pipe-friendly)
|
| 11 |
+
ocr_targets.json — structured: {doc, page, pdf_path, expected_md_path}
|
| 12 |
+
|
| 13 |
+
Usage
|
| 14 |
+
-----
|
| 15 |
+
python find_ocr_targets.py
|
| 16 |
+
python find_ocr_targets.py --root D:/divided
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import os
|
| 21 |
+
import json
|
| 22 |
+
import argparse
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
DEFAULT_ROOT = "D:/divided"
|
| 26 |
+
|
| 27 |
+
PAGE_DIR_RE = re.compile(r"^page_(\d+)$", re.IGNORECASE)
|
| 28 |
+
|
| 29 |
+
# Folders to skip entirely during traversal (never descend into them)
|
| 30 |
+
SKIP_TRAVERSE = {
|
| 31 |
+
"records", "reports_out", "pages_out", "wiki",
|
| 32 |
+
"concat", "extracted", "scripts", "raw",
|
| 33 |
+
".git", "__pycache__",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# Folder/file names that are not document dirs (skip as candidates only)
|
| 37 |
+
SKIP_CANDIDATE = {
|
| 38 |
+
"reorganize.py", "restructure_pages.py", "stamp_pages.py",
|
| 39 |
+
"reconcile.py", "pdf_to_reports.py", "concat_pages.py",
|
| 40 |
+
"find_missing_concat.py", "audit.py", "page_coverage.py",
|
| 41 |
+
"find_ocr_targets.py", "extract_reports.py",
|
| 42 |
+
"uap_record_schema.yaml", "uap-csv.csv",
|
| 43 |
+
"move_log.json", "page_move_log.json",
|
| 44 |
+
"CLAUDE.md", "Untitled.md", "README.md",
|
| 45 |
+
# agency folders are valid traversal roots — don't skip them here
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def find_document_dirs(root: Path) -> list[Path]:
|
| 50 |
+
doc_dirs = []
|
| 51 |
+
for dirpath, dirnames, _ in os.walk(str(root)):
|
| 52 |
+
# Prune traversal — never descend into output/tool folders
|
| 53 |
+
dirnames[:] = [d for d in dirnames if d not in SKIP_TRAVERSE]
|
| 54 |
+
p = Path(dirpath)
|
| 55 |
+
if p == root or p.name in SKIP_CANDIDATE:
|
| 56 |
+
continue
|
| 57 |
+
try:
|
| 58 |
+
has_pages = any(
|
| 59 |
+
PAGE_DIR_RE.match(sub.name)
|
| 60 |
+
for sub in p.iterdir() if sub.is_dir()
|
| 61 |
+
)
|
| 62 |
+
except (PermissionError, OSError):
|
| 63 |
+
continue
|
| 64 |
+
if has_pages:
|
| 65 |
+
doc_dirs.append(p)
|
| 66 |
+
return sorted(doc_dirs)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def main():
|
| 70 |
+
ap = argparse.ArgumentParser(description="Find pages with PDF but no .md (OCR targets)")
|
| 71 |
+
ap.add_argument("--root", default=DEFAULT_ROOT)
|
| 72 |
+
args = ap.parse_args()
|
| 73 |
+
|
| 74 |
+
root = Path(args.root)
|
| 75 |
+
print(f"\n Scanning {root} …\n")
|
| 76 |
+
|
| 77 |
+
doc_dirs = find_document_dirs(root)
|
| 78 |
+
|
| 79 |
+
targets = [] # {doc, page, pdf_path, expected_md_path}
|
| 80 |
+
docs_with_gaps = []
|
| 81 |
+
|
| 82 |
+
for doc_dir in doc_dirs:
|
| 83 |
+
doc_targets = []
|
| 84 |
+
try:
|
| 85 |
+
subs = sorted(
|
| 86 |
+
(sub for sub in doc_dir.iterdir()
|
| 87 |
+
if sub.is_dir() and PAGE_DIR_RE.match(sub.name)),
|
| 88 |
+
key=lambda s: s.name
|
| 89 |
+
)
|
| 90 |
+
except (PermissionError, OSError):
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
for sub in subs:
|
| 94 |
+
md = sub / f"{sub.name}.md"
|
| 95 |
+
if md.exists():
|
| 96 |
+
continue # OCR already done — skip
|
| 97 |
+
|
| 98 |
+
# .md is missing — find whatever source file is present
|
| 99 |
+
# Check for PDF with matching name, then any PDF, then any file at all
|
| 100 |
+
pdf_match = sub / f"{sub.name}.pdf"
|
| 101 |
+
if pdf_match.exists():
|
| 102 |
+
source = pdf_match
|
| 103 |
+
else:
|
| 104 |
+
# Look for any PDF or image in this page folder
|
| 105 |
+
others = [f for f in sub.iterdir() if f.is_file()
|
| 106 |
+
and f.suffix.lower() in {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}]
|
| 107 |
+
source = others[0] if others else None
|
| 108 |
+
|
| 109 |
+
doc_targets.append({
|
| 110 |
+
"doc": doc_dir.name,
|
| 111 |
+
"page": sub.name,
|
| 112 |
+
"pdf_path": str(source) if source else "NOT FOUND",
|
| 113 |
+
"expected_md_path": str(md),
|
| 114 |
+
"has_source_file": source is not None,
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
if doc_targets:
|
| 118 |
+
docs_with_gaps.append((doc_dir.name, doc_targets))
|
| 119 |
+
targets.extend(doc_targets)
|
| 120 |
+
|
| 121 |
+
# ── stdout ────────────────────────────────────────────────────────────────
|
| 122 |
+
if not targets:
|
| 123 |
+
print(" ✅ No gaps — every page_XXXX.pdf has a matching .md file.\n")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
no_source = sum(1 for t in targets if not t["has_source_file"])
|
| 127 |
+
|
| 128 |
+
print(f" {'─'*65}")
|
| 129 |
+
print(f" Documents with missing .md : {len(docs_with_gaps)}")
|
| 130 |
+
print(f" Total pages needing OCR : {len(targets)}")
|
| 131 |
+
print(f" — have a source PDF/image : {len(targets) - no_source}")
|
| 132 |
+
print(f" — NO source file found : {no_source} ← page folder is empty")
|
| 133 |
+
print(f" {'─'*65}\n")
|
| 134 |
+
|
| 135 |
+
for doc_name, doc_targets in docs_with_gaps:
|
| 136 |
+
pages = [t["page"] for t in doc_targets]
|
| 137 |
+
nums = sorted(int(re.search(r"\d+", p).group()) for p in pages)
|
| 138 |
+
runs = []
|
| 139 |
+
start = prev = nums[0]
|
| 140 |
+
for n in nums[1:]:
|
| 141 |
+
if n == prev + 1:
|
| 142 |
+
prev = n
|
| 143 |
+
else:
|
| 144 |
+
runs.append(f"{start}" if start == prev else f"{start}–{prev}")
|
| 145 |
+
start = prev = n
|
| 146 |
+
runs.append(f"{start}" if start == prev else f"{start}–{prev}")
|
| 147 |
+
|
| 148 |
+
missing_src = sum(1 for t in doc_targets if not t["has_source_file"])
|
| 149 |
+
src_note = f" ⚠ {missing_src} page folder(s) have no source file" if missing_src else ""
|
| 150 |
+
print(f" {doc_name}")
|
| 151 |
+
print(f" Missing {len(doc_targets)} page(s): {', '.join(runs)}{src_note}")
|
| 152 |
+
for t in doc_targets:
|
| 153 |
+
src_flag = " ← NO SOURCE FILE" if not t["has_source_file"] else ""
|
| 154 |
+
print(f" → {t['pdf_path']}{src_flag}")
|
| 155 |
+
print()
|
| 156 |
+
|
| 157 |
+
# ── ocr_targets.txt ───────────────────────────────────────────────────────
|
| 158 |
+
txt_path = root / "ocr_targets.txt"
|
| 159 |
+
txt_path.write_text(
|
| 160 |
+
"\n".join(t["pdf_path"] for t in targets) + "\n",
|
| 161 |
+
encoding="utf-8"
|
| 162 |
+
)
|
| 163 |
+
print(f" Flat list → {txt_path}")
|
| 164 |
+
|
| 165 |
+
# ── ocr_targets.json ──────────────────────────────────────────────────────
|
| 166 |
+
json_path = root / "ocr_targets.json"
|
| 167 |
+
json_path.write_text(
|
| 168 |
+
json.dumps(targets, indent=2, ensure_ascii=False),
|
| 169 |
+
encoding="utf-8"
|
| 170 |
+
)
|
| 171 |
+
print(f" JSON list → {json_path}\n")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|
pipeline/join_reports.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
join_reports.py
|
| 3 |
+
───────────────
|
| 4 |
+
Left-join parsed_reports_with_agency (left) onto raw_reports_table (right)
|
| 5 |
+
on: left["Unnamed: 0"] == right["raw_text"]
|
| 6 |
+
|
| 7 |
+
All rows from the parsed file are kept; matching columns from raw_reports_table
|
| 8 |
+
(source_file, chunk_count, pages, parse_errors, assessment) are appended.
|
| 9 |
+
The redundant raw_text column from the right side is dropped after the join.
|
| 10 |
+
|
| 11 |
+
Usage
|
| 12 |
+
-----
|
| 13 |
+
python join_reports.py
|
| 14 |
+
python join_reports.py --left parsed_reports_with_agency.xlsx \
|
| 15 |
+
--right raw_reports_table.csv \
|
| 16 |
+
--out joined_reports.xlsx
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def read_any(path: Path) -> pd.DataFrame:
|
| 27 |
+
"""Read CSV or Excel, sniffing format from magic bytes."""
|
| 28 |
+
with open(path, "rb") as fh:
|
| 29 |
+
magic = fh.read(4)
|
| 30 |
+
is_excel = magic[:4] in (b"\xd0\xcf\x11\xe0", b"PK\x03\x04")
|
| 31 |
+
if is_excel or path.suffix.lower() in (".xlsx", ".xls", ".xlsm"):
|
| 32 |
+
return pd.read_excel(path, dtype=str)
|
| 33 |
+
for sep in (",", ";", "\t", "|"):
|
| 34 |
+
try:
|
| 35 |
+
df = pd.read_csv(path, sep=sep, dtype=str, encoding="utf-8-sig")
|
| 36 |
+
if len(df.columns) > 1:
|
| 37 |
+
return df
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
return pd.read_csv(path, dtype=str, encoding="utf-8-sig")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main():
|
| 44 |
+
ap = argparse.ArgumentParser(description="Left-join parsed reports with raw reports table")
|
| 45 |
+
ap.add_argument("--left", default="parsed_reports_with_agency.xlsx")
|
| 46 |
+
ap.add_argument("--right", default="raw_reports_table.csv")
|
| 47 |
+
ap.add_argument("--out", default="joined_reports.xlsx")
|
| 48 |
+
args = ap.parse_args()
|
| 49 |
+
|
| 50 |
+
left_path = Path(args.left)
|
| 51 |
+
right_path = Path(args.right)
|
| 52 |
+
out_path = Path(args.out)
|
| 53 |
+
|
| 54 |
+
for p in (left_path, right_path):
|
| 55 |
+
if not p.exists():
|
| 56 |
+
sys.exit(f"ERROR: file not found: {p}")
|
| 57 |
+
|
| 58 |
+
print(f"Reading left : {left_path}")
|
| 59 |
+
df_left = read_any(left_path)
|
| 60 |
+
print(f" {len(df_left):,} rows × {len(df_left.columns)} cols")
|
| 61 |
+
print(f" Columns: {df_left.columns.tolist()}\n")
|
| 62 |
+
|
| 63 |
+
print(f"Reading right : {right_path}")
|
| 64 |
+
df_right = read_any(right_path)
|
| 65 |
+
print(f" {len(df_right):,} rows × {len(df_right.columns)} cols")
|
| 66 |
+
print(f" Columns: {df_right.columns.tolist()}\n")
|
| 67 |
+
|
| 68 |
+
# Validate join keys
|
| 69 |
+
for col, df, label in [
|
| 70 |
+
("Unnamed: 0", df_left, "left (parsed_reports_with_agency)"),
|
| 71 |
+
("raw_text", df_right, "right (raw_reports_table)"),
|
| 72 |
+
]:
|
| 73 |
+
if col not in df.columns:
|
| 74 |
+
sys.exit(
|
| 75 |
+
f"ERROR: column '{col}' not found in {label}.\n"
|
| 76 |
+
f" Available: {df.columns.tolist()}"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# Strip whitespace from join keys to avoid invisible mismatches
|
| 80 |
+
df_left["Unnamed: 0"] = df_left["Unnamed: 0"].str.strip()
|
| 81 |
+
df_right["raw_text"] = df_right["raw_text"].str.strip()
|
| 82 |
+
|
| 83 |
+
# Left join
|
| 84 |
+
merged = df_left.merge(
|
| 85 |
+
df_right,
|
| 86 |
+
left_on="Unnamed: 0",
|
| 87 |
+
right_on="raw_text",
|
| 88 |
+
how="left",
|
| 89 |
+
suffixes=("", "_right"),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Drop the redundant raw_text column from the right side
|
| 93 |
+
if "raw_text" in merged.columns:
|
| 94 |
+
merged = merged.drop(columns=["raw_text"])
|
| 95 |
+
|
| 96 |
+
# Drop any duplicate unnamed index columns brought in from the right CSV
|
| 97 |
+
unnamed_right = [c for c in merged.columns if c.startswith("Unnamed:") and c != "Unnamed: 0"]
|
| 98 |
+
if unnamed_right:
|
| 99 |
+
merged = merged.drop(columns=unnamed_right)
|
| 100 |
+
|
| 101 |
+
# Rename the key column to something meaningful
|
| 102 |
+
merged = merged.rename(columns={"Unnamed: 0": "raw_text"})
|
| 103 |
+
|
| 104 |
+
# Stats
|
| 105 |
+
matched = merged["source_file"].notna().sum() if "source_file" in merged.columns else "?"
|
| 106 |
+
unmatched = len(merged) - (matched if isinstance(matched, int) else 0)
|
| 107 |
+
print(f"Join complete: {len(merged):,} rows total | matched: {matched} | unmatched: {unmatched}")
|
| 108 |
+
|
| 109 |
+
if unmatched and isinstance(unmatched, int) and unmatched > 0:
|
| 110 |
+
miss = merged[merged["source_file"].isna()]["raw_text"].str[:80]
|
| 111 |
+
print(f"\nFirst 5 unmatched raw_text previews:")
|
| 112 |
+
for s in miss.head(5):
|
| 113 |
+
print(f" {s!r}")
|
| 114 |
+
|
| 115 |
+
# Save
|
| 116 |
+
if out_path.suffix.lower() in (".xlsx", ".xls", ".xlsm"):
|
| 117 |
+
merged.to_excel(out_path, index=False)
|
| 118 |
+
else:
|
| 119 |
+
merged.to_csv(out_path, index=False)
|
| 120 |
+
print(f"\nSaved → {out_path.resolve()}")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
main()
|
pipeline/map_yaml_to_reports.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
map_yaml_to_reports.py
|
| 3 |
+
──────────────────────
|
| 4 |
+
Joins YAML records in records/ with rows in raw_reports_table.csv.
|
| 5 |
+
|
| 6 |
+
Normalization rule (both sides):
|
| 7 |
+
source_file → strip .md → lowercase → spaces → hyphens → remove apostrophes
|
| 8 |
+
record_id → already normalized (but may differ by apostrophe / trailing underscore)
|
| 9 |
+
|
| 10 |
+
Three output groups:
|
| 11 |
+
A matched — YAML record + CSV report row joined
|
| 12 |
+
B yaml-only — YAML record with no CSV counterpart (e.g. dow-uap-*)
|
| 13 |
+
C csv-only — CSV row with no YAML record (e.g. 059uap* UPDB files)
|
| 14 |
+
|
| 15 |
+
Usage
|
| 16 |
+
-----
|
| 17 |
+
python map_yaml_to_reports.py
|
| 18 |
+
python map_yaml_to_reports.py --records records --csv raw_reports_table.csv --out enriched_reports.xlsx
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import argparse
|
| 22 |
+
import re
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import yaml
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── Normalization ─────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def normalize_key(s: str) -> str:
|
| 33 |
+
"""
|
| 34 |
+
Canonical join key:
|
| 35 |
+
1. Strip extension (.md, .yaml, …)
|
| 36 |
+
2. Lowercase
|
| 37 |
+
3. Collapse runs of whitespace → single hyphen
|
| 38 |
+
4. Remove apostrophes
|
| 39 |
+
5. Collapse multiple consecutive hyphens or underscores into one hyphen
|
| 40 |
+
"""
|
| 41 |
+
s = Path(s).stem # drop extension
|
| 42 |
+
s = s.lower()
|
| 43 |
+
s = re.sub(r"[\s]+", "-", s) # whitespace → hyphen
|
| 44 |
+
s = s.replace("'", "") # apostrophes gone
|
| 45 |
+
s = re.sub(r"[_]+", "_", s) # keep underscores single
|
| 46 |
+
s = re.sub(r"[-]+", "-", s) # keep hyphens single
|
| 47 |
+
return s.strip("-_")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── YAML loading ──────────────────────────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
# Fields we want to pull out of each YAML record
|
| 53 |
+
_YAML_FIELDS = [
|
| 54 |
+
("record_id", lambda y: y.get("record_id")),
|
| 55 |
+
("agency_yaml", lambda y: y.get("csv", {}).get("agency")),
|
| 56 |
+
("report_subtype", lambda y: y.get("csv", {}).get("report_subtype")),
|
| 57 |
+
("release_date", lambda y: y.get("csv", {}).get("release_date")),
|
| 58 |
+
("incident_date", lambda y: y.get("csv", {}).get("incident_date")),
|
| 59 |
+
("location_csv", lambda y: y.get("csv", {}).get("location_csv")),
|
| 60 |
+
("uap_shape", lambda y: y.get("observation", {}).get("morphology", {}).get("shape")),
|
| 61 |
+
("uap_maneuver", lambda y: y.get("observation", {}).get("kinematics", {}).get("maneuver_type")),
|
| 62 |
+
("uap_speed_mph", lambda y: y.get("observation", {}).get("kinematics", {}).get("speed_mph")),
|
| 63 |
+
("uap_altitude_ft", lambda y: y.get("observation", {}).get("kinematics", {}).get("altitude_ft")),
|
| 64 |
+
("sensor_types", lambda y: "; ".join(y.get("observation", {}).get("sensor_types") or []) or None),
|
| 65 |
+
("platform", lambda y: y.get("observation", {}).get("platform")),
|
| 66 |
+
("threat_assessment",lambda y: y.get("observation", {}).get("threat_assessment")),
|
| 67 |
+
("region", lambda y: y.get("csv", {}).get("region")),
|
| 68 |
+
("page_count", lambda y: len(y.get("files", {}).get("pages") or [])),
|
| 69 |
+
("has_nim_report", lambda y: bool(y.get("reports"))),
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_yaml_records(records_dir: Path) -> pd.DataFrame:
|
| 74 |
+
rows = []
|
| 75 |
+
for yaml_path in sorted(records_dir.glob("*.yaml")):
|
| 76 |
+
try:
|
| 77 |
+
with open(yaml_path, encoding="utf-8") as fh:
|
| 78 |
+
data = yaml.safe_load(fh)
|
| 79 |
+
except Exception as exc:
|
| 80 |
+
print(f" WARN: could not parse {yaml_path.name}: {exc}")
|
| 81 |
+
continue
|
| 82 |
+
if not isinstance(data, dict):
|
| 83 |
+
continue
|
| 84 |
+
row = {"yaml_file": yaml_path.name}
|
| 85 |
+
for field_name, extractor in _YAML_FIELDS:
|
| 86 |
+
try:
|
| 87 |
+
row[field_name] = extractor(data)
|
| 88 |
+
except Exception:
|
| 89 |
+
row[field_name] = None
|
| 90 |
+
rows.append(row)
|
| 91 |
+
|
| 92 |
+
if not rows:
|
| 93 |
+
sys.exit(f"ERROR: no .yaml files found in {records_dir}")
|
| 94 |
+
|
| 95 |
+
df = pd.DataFrame(rows)
|
| 96 |
+
df["join_key"] = df["record_id"].fillna("").apply(normalize_key)
|
| 97 |
+
return df
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ── CSV loading ───────────────────────────────────────────────────────────────
|
| 101 |
+
|
| 102 |
+
def load_csv_reports(csv_path: Path) -> pd.DataFrame:
|
| 103 |
+
for sep in (",", ";", "\t", "|"):
|
| 104 |
+
try:
|
| 105 |
+
df = pd.read_csv(csv_path, sep=sep, dtype=str, encoding="utf-8-sig")
|
| 106 |
+
if len(df.columns) > 1:
|
| 107 |
+
break
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
| 110 |
+
else:
|
| 111 |
+
df = pd.read_csv(csv_path, dtype=str, encoding="utf-8-sig")
|
| 112 |
+
|
| 113 |
+
if "source_file" not in df.columns:
|
| 114 |
+
sys.exit(
|
| 115 |
+
f"ERROR: 'source_file' column not found in {csv_path}.\n"
|
| 116 |
+
f" Available: {df.columns.tolist()}"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
df["join_key"] = df["source_file"].fillna("").apply(normalize_key)
|
| 120 |
+
return df
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
| 124 |
+
|
| 125 |
+
def main():
|
| 126 |
+
ap = argparse.ArgumentParser(
|
| 127 |
+
description="Join YAML records with raw_reports_table.csv"
|
| 128 |
+
)
|
| 129 |
+
ap.add_argument("--records", default="records", help="records/ directory")
|
| 130 |
+
ap.add_argument("--csv", default="raw_reports_table.csv")
|
| 131 |
+
ap.add_argument("--out", default="enriched_reports.xlsx")
|
| 132 |
+
args = ap.parse_args()
|
| 133 |
+
|
| 134 |
+
records_dir = Path(args.records)
|
| 135 |
+
csv_path = Path(args.csv)
|
| 136 |
+
out_path = Path(args.out)
|
| 137 |
+
|
| 138 |
+
for p in (records_dir, csv_path):
|
| 139 |
+
if not p.exists():
|
| 140 |
+
sys.exit(f"ERROR: path not found: {p}")
|
| 141 |
+
|
| 142 |
+
# ── Load ──────────────────────────────────────────────────────────────────
|
| 143 |
+
print(f"Loading YAML records from {records_dir} …")
|
| 144 |
+
df_yaml = load_yaml_records(records_dir)
|
| 145 |
+
print(f" {len(df_yaml):,} YAML records | {df_yaml['join_key'].nunique():,} unique keys")
|
| 146 |
+
|
| 147 |
+
print(f"\nLoading CSV from {csv_path} …")
|
| 148 |
+
df_csv = load_csv_reports(csv_path)
|
| 149 |
+
print(f" {len(df_csv):,} CSV rows | {df_csv['join_key'].nunique():,} unique keys")
|
| 150 |
+
|
| 151 |
+
# ── Join ──────────────────────────────────────────────────────────────────
|
| 152 |
+
# Outer join so we can see all three groups
|
| 153 |
+
merged = df_csv.merge(
|
| 154 |
+
df_yaml,
|
| 155 |
+
on="join_key",
|
| 156 |
+
how="outer",
|
| 157 |
+
suffixes=("_csv", "_yaml"),
|
| 158 |
+
indicator=True,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Group labels
|
| 162 |
+
merged["group"] = merged["_merge"].map({
|
| 163 |
+
"both": "A_matched",
|
| 164 |
+
"right_only": "B_yaml_only",
|
| 165 |
+
"left_only": "C_csv_only",
|
| 166 |
+
})
|
| 167 |
+
merged = merged.drop(columns=["_merge"])
|
| 168 |
+
|
| 169 |
+
# ── Stats ─────────────────────────────────────────────────────────────────
|
| 170 |
+
counts = merged["group"].value_counts().sort_index()
|
| 171 |
+
total = len(merged)
|
| 172 |
+
|
| 173 |
+
print("\n" + "─" * 60)
|
| 174 |
+
print(f"{'Group':<20} {'Count':>6} {'%':>6}")
|
| 175 |
+
print("─" * 60)
|
| 176 |
+
for grp, cnt in counts.items():
|
| 177 |
+
print(f"{grp:<20} {cnt:>6,} {cnt/total*100:>5.1f}%")
|
| 178 |
+
print("─" * 60)
|
| 179 |
+
print(f"{'TOTAL':<20} {total:>6,}")
|
| 180 |
+
|
| 181 |
+
# Breakdown of yaml-only by agency
|
| 182 |
+
yaml_only = merged[merged["group"] == "B_yaml_only"]
|
| 183 |
+
if len(yaml_only):
|
| 184 |
+
print(f"\nYAML-only agency breakdown ({len(yaml_only):,} records):")
|
| 185 |
+
for agency, cnt in yaml_only["agency_yaml"].value_counts(dropna=False).items():
|
| 186 |
+
print(f" {agency!s:<20} {cnt:>4,}")
|
| 187 |
+
|
| 188 |
+
# Breakdown of csv-only by prefix
|
| 189 |
+
csv_only = merged[merged["group"] == "C_csv_only"]
|
| 190 |
+
if len(csv_only):
|
| 191 |
+
print(f"\nCSV-only source_file examples (first 15):")
|
| 192 |
+
for sf in csv_only["source_file"].dropna().head(15):
|
| 193 |
+
print(f" {sf}")
|
| 194 |
+
|
| 195 |
+
# ── Duplicate key check ───────────────────────────────────────────────────
|
| 196 |
+
dup_yaml = df_yaml[df_yaml.duplicated("join_key", keep=False)]
|
| 197 |
+
if len(dup_yaml):
|
| 198 |
+
print(f"\nWARN: {len(dup_yaml):,} YAML records share a join_key with another YAML record:")
|
| 199 |
+
print(dup_yaml[["yaml_file", "record_id", "join_key"]].to_string(index=False))
|
| 200 |
+
|
| 201 |
+
dup_csv = df_csv[df_csv.duplicated("join_key", keep=False)]
|
| 202 |
+
if len(dup_csv):
|
| 203 |
+
print(f"\nWARN: {len(dup_csv):,} CSV rows share a join_key with another CSV row:")
|
| 204 |
+
print(dup_csv[["source_file", "join_key"]].head(10).to_string(index=False))
|
| 205 |
+
|
| 206 |
+
# ── Save ──────────────────────────────────────────────────────────────────
|
| 207 |
+
# Sort: matched first, then yaml-only, then csv-only; alpha within each group
|
| 208 |
+
merged = merged.sort_values(["group", "join_key"]).reset_index(drop=True)
|
| 209 |
+
|
| 210 |
+
if out_path.suffix.lower() in (".xlsx", ".xls", ".xlsm"):
|
| 211 |
+
merged.to_excel(out_path, index=False)
|
| 212 |
+
else:
|
| 213 |
+
merged.to_csv(out_path, index=False)
|
| 214 |
+
|
| 215 |
+
print(f"\nSaved → {out_path.resolve()}")
|
| 216 |
+
print("Columns in output:")
|
| 217 |
+
for col in merged.columns:
|
| 218 |
+
print(f" {col}")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
main()
|
pipeline/page_coverage.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
page_coverage.py
|
| 3 |
+
────────────────
|
| 4 |
+
For every document folder in the archive, counts:
|
| 5 |
+
- Total page_XXXX subfolders (pages that exist as folders)
|
| 6 |
+
- Pages WITH a matching .md file (OCR complete)
|
| 7 |
+
- Pages WITHOUT a .md file (gap — PDF only or missing)
|
| 8 |
+
|
| 9 |
+
Outputs:
|
| 10 |
+
- Summary table to stdout
|
| 11 |
+
- page_coverage.json for downstream use / graphing
|
| 12 |
+
- page_coverage.csv for spreadsheet / manual review
|
| 13 |
+
|
| 14 |
+
Usage
|
| 15 |
+
-----
|
| 16 |
+
python page_coverage.py
|
| 17 |
+
python page_coverage.py --root D:/divided
|
| 18 |
+
python page_coverage.py --root D:/divided --out-dir .
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
import os
|
| 23 |
+
import csv
|
| 24 |
+
import json
|
| 25 |
+
import argparse
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
DEFAULT_ROOT = "D:/divided"
|
| 29 |
+
DEFAULT_OUT_DIR = "D:/divided"
|
| 30 |
+
|
| 31 |
+
PAGE_DIR_RE = re.compile(r"^page_(\d+)$", re.IGNORECASE)
|
| 32 |
+
|
| 33 |
+
SKIP_TRAVERSE = {
|
| 34 |
+
"records", "reports_out", "pages_out", "wiki",
|
| 35 |
+
"concat", "extracted", "scripts", "raw",
|
| 36 |
+
".git", "__pycache__",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
SKIP_CANDIDATE = {
|
| 40 |
+
"reorganize.py", "restructure_pages.py", "stamp_pages.py",
|
| 41 |
+
"reconcile.py", "pdf_to_reports.py", "concat_pages.py",
|
| 42 |
+
"find_missing_concat.py", "audit.py", "page_coverage.py",
|
| 43 |
+
"uap_record_schema.yaml", "uap-csv.csv",
|
| 44 |
+
"move_log.json", "page_move_log.json",
|
| 45 |
+
"CLAUDE.md", "Untitled.md", "README.md",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def find_document_dirs(root: Path) -> list[Path]:
|
| 50 |
+
doc_dirs = []
|
| 51 |
+
for dirpath, dirnames, _ in os.walk(str(root)):
|
| 52 |
+
dirnames[:] = [d for d in dirnames if d not in SKIP_TRAVERSE]
|
| 53 |
+
p = Path(dirpath)
|
| 54 |
+
if p == root or p.name in SKIP_CANDIDATE:
|
| 55 |
+
continue
|
| 56 |
+
try:
|
| 57 |
+
page_subs = [
|
| 58 |
+
sub for sub in p.iterdir()
|
| 59 |
+
if sub.is_dir() and PAGE_DIR_RE.match(sub.name)
|
| 60 |
+
]
|
| 61 |
+
except (PermissionError, OSError):
|
| 62 |
+
continue
|
| 63 |
+
if page_subs:
|
| 64 |
+
doc_dirs.append(p)
|
| 65 |
+
return sorted(doc_dirs)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def analyse_doc(doc_dir: Path) -> dict:
|
| 69 |
+
total_pages = 0
|
| 70 |
+
pages_with_md = 0
|
| 71 |
+
missing_pages = []
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
subs = [sub for sub in doc_dir.iterdir() if sub.is_dir()]
|
| 75 |
+
except (PermissionError, OSError):
|
| 76 |
+
subs = []
|
| 77 |
+
|
| 78 |
+
for sub in subs:
|
| 79 |
+
m = PAGE_DIR_RE.match(sub.name)
|
| 80 |
+
if not m:
|
| 81 |
+
continue
|
| 82 |
+
total_pages += 1
|
| 83 |
+
md = sub / f"{sub.name}.md"
|
| 84 |
+
if md.exists():
|
| 85 |
+
pages_with_md += 1
|
| 86 |
+
else:
|
| 87 |
+
missing_pages.append(sub.name)
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"doc": doc_dir.name,
|
| 91 |
+
"total_pages": total_pages,
|
| 92 |
+
"with_md": pages_with_md,
|
| 93 |
+
"missing_md": total_pages - pages_with_md,
|
| 94 |
+
"coverage_pct": round(100 * pages_with_md / total_pages, 1) if total_pages else 0,
|
| 95 |
+
"missing_list": missing_pages,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def main():
|
| 100 |
+
ap = argparse.ArgumentParser(description="Count page coverage (.md vs total) per document")
|
| 101 |
+
ap.add_argument("--root", default=DEFAULT_ROOT)
|
| 102 |
+
ap.add_argument("--out-dir", default=DEFAULT_OUT_DIR)
|
| 103 |
+
args = ap.parse_args()
|
| 104 |
+
|
| 105 |
+
root = Path(args.root)
|
| 106 |
+
out_dir = Path(args.out_dir)
|
| 107 |
+
|
| 108 |
+
print(f"\n Scanning {root} …")
|
| 109 |
+
doc_dirs = find_document_dirs(root)
|
| 110 |
+
print(f" Found {len(doc_dirs)} document folders\n")
|
| 111 |
+
|
| 112 |
+
results = [analyse_doc(d) for d in doc_dirs]
|
| 113 |
+
|
| 114 |
+
# ── stdout summary ────────────────────────────────────────────────────────
|
| 115 |
+
total_pages = sum(r["total_pages"] for r in results)
|
| 116 |
+
total_with_md = sum(r["with_md"] for r in results)
|
| 117 |
+
total_missing = sum(r["missing_md"] for r in results)
|
| 118 |
+
full_coverage = sum(1 for r in results if r["missing_md"] == 0)
|
| 119 |
+
partial = sum(1 for r in results if 0 < r["missing_md"] < r["total_pages"])
|
| 120 |
+
no_md = sum(1 for r in results if r["with_md"] == 0)
|
| 121 |
+
|
| 122 |
+
print(f"{'─'*72}")
|
| 123 |
+
print(f" {'Document':<55} {'Pages':>5} {'MD':>5} {'Gap':>5} {'Cov%':>5}")
|
| 124 |
+
print(f"{'─'*72}")
|
| 125 |
+
for r in results:
|
| 126 |
+
gap_flag = " ⚠" if r["missing_md"] > 0 else ""
|
| 127 |
+
print(f" {r['doc'][:55]:<55} {r['total_pages']:>5} {r['with_md']:>5} {r['missing_md']:>5}{gap_flag}")
|
| 128 |
+
print(f"{'─'*72}")
|
| 129 |
+
print(f" {'TOTAL':<55} {total_pages:>5} {total_with_md:>5} {total_missing:>5}")
|
| 130 |
+
print(f"{'─'*72}")
|
| 131 |
+
print(f"\n Docs with full MD coverage : {full_coverage}")
|
| 132 |
+
print(f" Docs with partial coverage : {partial} ← pages missing .md")
|
| 133 |
+
print(f" Docs with NO .md at all : {no_md}")
|
| 134 |
+
print()
|
| 135 |
+
|
| 136 |
+
# ── JSON output ───────────────────────────────────────────────────────────
|
| 137 |
+
json_path = out_dir / "page_coverage.json"
|
| 138 |
+
with open(json_path, "w", encoding="utf-8") as f:
|
| 139 |
+
json.dump(results, f, indent=2)
|
| 140 |
+
print(f" JSON → {json_path}")
|
| 141 |
+
|
| 142 |
+
# ── CSV output ────────────────────────────────────────────────────────────
|
| 143 |
+
csv_path = out_dir / "page_coverage.csv"
|
| 144 |
+
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
| 145 |
+
writer = csv.DictWriter(
|
| 146 |
+
f,
|
| 147 |
+
fieldnames=["doc", "total_pages", "with_md", "missing_md", "coverage_pct"],
|
| 148 |
+
extrasaction="ignore",
|
| 149 |
+
)
|
| 150 |
+
writer.writeheader()
|
| 151 |
+
writer.writerows(results)
|
| 152 |
+
print(f" CSV → {csv_path}\n")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
main()
|
pipeline/pdf_to_reports.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pdf_to_reports.py
|
| 3 |
+
-----------------
|
| 4 |
+
Multithreaded PDF → per-report .md extractor via NVIDIA NIM.
|
| 5 |
+
|
| 6 |
+
TWO-PASS PIPELINE
|
| 7 |
+
Pass 1 (parallel) — every non-blank page is sent to NIM concurrently for text
|
| 8 |
+
clean-up and metadata extraction.
|
| 9 |
+
Pass 2 (single) — a compact "page manifest" (page number + header snippet +
|
| 10 |
+
detected signals) is sent to NIM in ONE call so it can
|
| 11 |
+
see the full document structure and authoritatively decide
|
| 12 |
+
which pages start new reports. For PDFs > SEGMENT_CHUNK
|
| 13 |
+
pages the manifest is split into overlapping chunks and
|
| 14 |
+
the boundary lists are merged.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
NVIDIA_API_KEY=<key> python pdf_to_reports.py path/to/file.pdf
|
| 18 |
+
NVIDIA_API_KEY=<key> python pdf_to_reports.py path/to/file.pdf --out my_reports --workers 6
|
| 19 |
+
NVIDIA_API_KEY=<key> python pdf_to_reports.py path/to/file.pdf --no-segment
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import re
|
| 24 |
+
import time
|
| 25 |
+
import json
|
| 26 |
+
import random
|
| 27 |
+
import argparse
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 30 |
+
from textwrap import shorten
|
| 31 |
+
|
| 32 |
+
import pdfplumber
|
| 33 |
+
from openai import OpenAI, RateLimitError, APIStatusError
|
| 34 |
+
|
| 35 |
+
# ── client ────────────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
client = OpenAI(
|
| 38 |
+
base_url="https://integrate.api.nvidia.com/v1",
|
| 39 |
+
api_key=os.getenv("NVIDIA_API_KEY"),
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
MODEL = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"
|
| 43 |
+
MAX_RETRIES = 6
|
| 44 |
+
BASE_WAIT = 1.0 # seconds; doubles per retry attempt
|
| 45 |
+
MAX_CHARS_PAGE = 8_000 # chars sent to NIM per page in pass 1
|
| 46 |
+
HEADER_CHARS = 300 # chars per page sent in the pass-2 manifest
|
| 47 |
+
SEGMENT_CHUNK = 80 # pages per segmentation call (fits well within context)
|
| 48 |
+
SEGMENT_OVERLAP = 5 # pages of overlap between chunks to catch cross-boundary reports
|
| 49 |
+
|
| 50 |
+
# ── NIM call with exponential backoff ─────────────────────────────────────────
|
| 51 |
+
|
| 52 |
+
def call_nim(messages: list[dict], *, max_retries: int = MAX_RETRIES) -> str:
|
| 53 |
+
"""
|
| 54 |
+
Call the NIM chat endpoint. Retries on 429 and 5xx with
|
| 55 |
+
exponential back-off + full jitter to prevent thundering herd.
|
| 56 |
+
"""
|
| 57 |
+
for attempt in range(max_retries):
|
| 58 |
+
try:
|
| 59 |
+
resp = client.chat.completions.create(
|
| 60 |
+
model=MODEL,
|
| 61 |
+
messages=messages,
|
| 62 |
+
temperature=0.3,
|
| 63 |
+
top_p=0.95,
|
| 64 |
+
max_tokens=8192,
|
| 65 |
+
stream=False,
|
| 66 |
+
extra_body={
|
| 67 |
+
"chat_template_kwargs": {"enable_thinking": True},
|
| 68 |
+
"reasoning_budget": 4096,
|
| 69 |
+
},
|
| 70 |
+
)
|
| 71 |
+
return resp.choices[0].message.content or ""
|
| 72 |
+
|
| 73 |
+
except RateLimitError:
|
| 74 |
+
wait = BASE_WAIT * (2 ** attempt) + random.uniform(0, BASE_WAIT)
|
| 75 |
+
print(f" [rate-limit] attempt {attempt + 1}/{max_retries} — waiting {wait:.1f}s …")
|
| 76 |
+
time.sleep(wait)
|
| 77 |
+
|
| 78 |
+
except APIStatusError as exc:
|
| 79 |
+
if exc.status_code >= 500:
|
| 80 |
+
wait = BASE_WAIT * (2 ** attempt) + random.uniform(0, BASE_WAIT)
|
| 81 |
+
print(f" [server-error {exc.status_code}] attempt {attempt + 1}/{max_retries} — waiting {wait:.1f}s …")
|
| 82 |
+
time.sleep(wait)
|
| 83 |
+
else:
|
| 84 |
+
raise # 4xx other than 429 are not retryable
|
| 85 |
+
|
| 86 |
+
raise RuntimeError(f"NIM API failed after {max_retries} attempts")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _parse_json(raw: str) -> dict | list:
|
| 90 |
+
"""Strip markdown fences and parse JSON; raise on failure."""
|
| 91 |
+
raw = re.sub(r"^```(?:json)?\s*", "", raw.strip())
|
| 92 |
+
raw = re.sub(r"\s*```$", "", raw)
|
| 93 |
+
return json.loads(raw)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 97 |
+
# PASS 1 — per-page extraction (parallel)
|
| 98 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 99 |
+
|
| 100 |
+
PAGE_SYSTEM = (
|
| 101 |
+
"You are a precise document-analysis assistant. "
|
| 102 |
+
"Output ONLY valid JSON — no prose, no markdown fences."
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
PAGE_PROMPT = """\
|
| 106 |
+
Analyse the page below (from a government / agency report PDF).
|
| 107 |
+
|
| 108 |
+
Return valid JSON matching this exact schema (no extra keys):
|
| 109 |
+
{{
|
| 110 |
+
"report_id": <str|null>,
|
| 111 |
+
"metadata": {{
|
| 112 |
+
"date": <str|null>,
|
| 113 |
+
"location": <str|null>,
|
| 114 |
+
"agency": <str|null>,
|
| 115 |
+
"classification": <str|null>,
|
| 116 |
+
"object_description": <str|null>,
|
| 117 |
+
"witnesses": <str|null>,
|
| 118 |
+
"redacted_sections": [<str>]
|
| 119 |
+
}},
|
| 120 |
+
"page_text": <str>,
|
| 121 |
+
"header_line": <str> // first meaningful heading or first 120 chars of text
|
| 122 |
+
}}
|
| 123 |
+
|
| 124 |
+
[PAGE {page_num}]
|
| 125 |
+
{text}
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def process_page(page_num: int, raw_text: str) -> dict:
|
| 130 |
+
"""Send one page to NIM; return structured dict."""
|
| 131 |
+
print(f" → page {page_num:>4d}")
|
| 132 |
+
messages = [
|
| 133 |
+
{"role": "system", "content": PAGE_SYSTEM},
|
| 134 |
+
{"role": "user", "content": PAGE_PROMPT.format(
|
| 135 |
+
page_num=page_num,
|
| 136 |
+
text=raw_text[:MAX_CHARS_PAGE],
|
| 137 |
+
)},
|
| 138 |
+
]
|
| 139 |
+
raw = call_nim(messages)
|
| 140 |
+
try:
|
| 141 |
+
result = _parse_json(raw)
|
| 142 |
+
except (json.JSONDecodeError, ValueError):
|
| 143 |
+
result = {
|
| 144 |
+
"report_id": None,
|
| 145 |
+
"metadata": {"redacted_sections": []},
|
| 146 |
+
"page_text": raw_text,
|
| 147 |
+
"header_line": raw_text[:120],
|
| 148 |
+
}
|
| 149 |
+
result["page_num"] = page_num
|
| 150 |
+
return result
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 154 |
+
# PASS 2 — document-level segmentation (single / chunked call)
|
| 155 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 156 |
+
|
| 157 |
+
SEGMENT_SYSTEM = (
|
| 158 |
+
"You are a document-segmentation assistant. "
|
| 159 |
+
"Output ONLY valid JSON — no prose, no markdown fences."
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
SEGMENT_PROMPT = """\
|
| 163 |
+
Below is a page manifest for a multi-report PDF. Each entry shows the page
|
| 164 |
+
number, the report/case ID found on that page (if any), and the opening text.
|
| 165 |
+
|
| 166 |
+
Your task: identify which pages START a new, distinct report or case.
|
| 167 |
+
A new report typically begins with a new case number, incident number, new
|
| 168 |
+
header/title, or a clear section break. Continuation pages (cover sheets,
|
| 169 |
+
attachments, exhibits that belong to the same report) are NOT new reports.
|
| 170 |
+
|
| 171 |
+
Return a JSON array — one object per page — in page order:
|
| 172 |
+
[
|
| 173 |
+
{{"page": <int>, "starts_report": <bool>, "report_id": <str|null>}},
|
| 174 |
+
...
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
PAGE MANIFEST:
|
| 178 |
+
{manifest}
|
| 179 |
+
"""
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _build_manifest(pages: list[dict]) -> str:
|
| 183 |
+
"""Build a compact text manifest for the segmentation call."""
|
| 184 |
+
lines = []
|
| 185 |
+
for p in sorted(pages, key=lambda x: x["page_num"]):
|
| 186 |
+
rid = p.get("report_id") or "—"
|
| 187 |
+
header = shorten(p.get("header_line") or p.get("page_text") or "", HEADER_CHARS, placeholder="…")
|
| 188 |
+
lines.append(f"p{p['page_num']:>4d} id={rid:<20s} {header}")
|
| 189 |
+
return "\n".join(lines)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _segment_chunk(pages: list[dict]) -> list[dict]:
|
| 193 |
+
"""
|
| 194 |
+
Call NIM once with a manifest for `pages` and return boundary info.
|
| 195 |
+
Returns: [{"page": int, "starts_report": bool, "report_id": str|None}, ...]
|
| 196 |
+
"""
|
| 197 |
+
manifest = _build_manifest(pages)
|
| 198 |
+
messages = [
|
| 199 |
+
{"role": "system", "content": SEGMENT_SYSTEM},
|
| 200 |
+
{"role": "user", "content": SEGMENT_PROMPT.format(manifest=manifest)},
|
| 201 |
+
]
|
| 202 |
+
raw = call_nim(messages)
|
| 203 |
+
try:
|
| 204 |
+
result = _parse_json(raw)
|
| 205 |
+
if isinstance(result, list):
|
| 206 |
+
return result
|
| 207 |
+
except (json.JSONDecodeError, ValueError):
|
| 208 |
+
pass
|
| 209 |
+
# fallback: first page of chunk starts a report, rest continue
|
| 210 |
+
return [
|
| 211 |
+
{"page": p["page_num"], "starts_report": (i == 0), "report_id": p.get("report_id")}
|
| 212 |
+
for i, p in enumerate(sorted(pages, key=lambda x: x["page_num"]))
|
| 213 |
+
]
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def segment_document(pages: list[dict]) -> dict[int, dict]:
|
| 217 |
+
"""
|
| 218 |
+
Run pass-2 segmentation over the full page list, chunking if needed.
|
| 219 |
+
|
| 220 |
+
Returns a dict keyed by page_num:
|
| 221 |
+
{page_num: {"starts_report": bool, "report_id": str|None}}
|
| 222 |
+
"""
|
| 223 |
+
sorted_pages = sorted(pages, key=lambda p: p["page_num"])
|
| 224 |
+
n = len(sorted_pages)
|
| 225 |
+
boundary_map: dict[int, dict] = {}
|
| 226 |
+
|
| 227 |
+
if n <= SEGMENT_CHUNK:
|
| 228 |
+
# entire document fits in one call
|
| 229 |
+
print(" → segmentation: single call")
|
| 230 |
+
results = _segment_chunk(sorted_pages)
|
| 231 |
+
for r in results:
|
| 232 |
+
boundary_map[r["page"]] = r
|
| 233 |
+
else:
|
| 234 |
+
# chunk with overlap so reports that straddle chunk edges are detected
|
| 235 |
+
step = SEGMENT_CHUNK - SEGMENT_OVERLAP
|
| 236 |
+
start = 0
|
| 237 |
+
chunk_idx = 1
|
| 238 |
+
seen: set[int] = set()
|
| 239 |
+
|
| 240 |
+
while start < n:
|
| 241 |
+
end = min(start + SEGMENT_CHUNK, n)
|
| 242 |
+
chunk = sorted_pages[start:end]
|
| 243 |
+
print(f" → segmentation chunk {chunk_idx} (pages {chunk[0]['page_num']}–{chunk[-1]['page_num']})")
|
| 244 |
+
results = _segment_chunk(chunk)
|
| 245 |
+
for r in results:
|
| 246 |
+
pn = r["page"]
|
| 247 |
+
if pn not in seen:
|
| 248 |
+
boundary_map[pn] = r
|
| 249 |
+
seen.add(pn)
|
| 250 |
+
else:
|
| 251 |
+
# overlap zone: OR the starts_report flags
|
| 252 |
+
# (if either pass said "new report", trust it)
|
| 253 |
+
boundary_map[pn]["starts_report"] = (
|
| 254 |
+
boundary_map[pn]["starts_report"] or r["starts_report"]
|
| 255 |
+
)
|
| 256 |
+
# prefer the more specific report_id
|
| 257 |
+
if not boundary_map[pn].get("report_id") and r.get("report_id"):
|
| 258 |
+
boundary_map[pn]["report_id"] = r["report_id"]
|
| 259 |
+
start += step
|
| 260 |
+
chunk_idx += 1
|
| 261 |
+
|
| 262 |
+
return boundary_map
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 266 |
+
# Grouping, metadata merge, markdown output
|
| 267 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 268 |
+
|
| 269 |
+
def apply_segmentation(pages: list[dict], boundary_map: dict[int, dict]) -> list[dict]:
|
| 270 |
+
"""
|
| 271 |
+
Override each page's report_id with the authoritative value from pass 2,
|
| 272 |
+
and set a 'new_report_start' flag according to the boundary map.
|
| 273 |
+
"""
|
| 274 |
+
for p in pages:
|
| 275 |
+
pn = p["page_num"]
|
| 276 |
+
info = boundary_map.get(pn, {})
|
| 277 |
+
p["new_report_start"] = info.get("starts_report", False)
|
| 278 |
+
if info.get("report_id"):
|
| 279 |
+
p["report_id"] = info["report_id"]
|
| 280 |
+
return pages
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def group_into_reports(pages: list[dict]) -> list[list[dict]]:
|
| 284 |
+
"""Split sorted pages into report groups on new_report_start boundaries."""
|
| 285 |
+
groups: list[list[dict]] = []
|
| 286 |
+
current: list[dict] = []
|
| 287 |
+
|
| 288 |
+
for page in sorted(pages, key=lambda p: p["page_num"]):
|
| 289 |
+
if page.get("new_report_start") and current:
|
| 290 |
+
groups.append(current)
|
| 291 |
+
current = [page]
|
| 292 |
+
else:
|
| 293 |
+
current.append(page)
|
| 294 |
+
|
| 295 |
+
if current:
|
| 296 |
+
groups.append(current)
|
| 297 |
+
|
| 298 |
+
return groups
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def merge_metadata(pages: list[dict]) -> dict:
|
| 302 |
+
"""
|
| 303 |
+
Merge metadata across all pages: first non-null scalar value wins;
|
| 304 |
+
redacted_sections lists are concatenated and de-duplicated.
|
| 305 |
+
"""
|
| 306 |
+
merged: dict = {"redacted_sections": []}
|
| 307 |
+
seen_redacted: set[str] = set()
|
| 308 |
+
|
| 309 |
+
for page in pages:
|
| 310 |
+
m = page.get("metadata") or {}
|
| 311 |
+
for key, val in m.items():
|
| 312 |
+
if key == "redacted_sections":
|
| 313 |
+
for item in val or []:
|
| 314 |
+
if item not in seen_redacted:
|
| 315 |
+
merged["redacted_sections"].append(item)
|
| 316 |
+
seen_redacted.add(item)
|
| 317 |
+
elif val and key not in merged:
|
| 318 |
+
merged[key] = val
|
| 319 |
+
|
| 320 |
+
merged["report_id"] = next(
|
| 321 |
+
(p["report_id"] for p in pages if p.get("report_id")),
|
| 322 |
+
f"report_p{pages[0]['page_num']}",
|
| 323 |
+
)
|
| 324 |
+
merged["pages"] = [p["page_num"] for p in pages]
|
| 325 |
+
return merged
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def write_report_md(report_pages: list[dict], out_dir: Path, index: int) -> Path:
|
| 329 |
+
"""Write one .md file for a single logical report."""
|
| 330 |
+
meta = merge_metadata(report_pages)
|
| 331 |
+
slug = re.sub(r"[^\w\-]", "_", str(meta["report_id"]))
|
| 332 |
+
path = out_dir / f"{slug}.md"
|
| 333 |
+
|
| 334 |
+
lines = [
|
| 335 |
+
f"# {meta['report_id']}",
|
| 336 |
+
"",
|
| 337 |
+
"## Metadata",
|
| 338 |
+
"",
|
| 339 |
+
"| Field | Value |",
|
| 340 |
+
"|-------|-------|",
|
| 341 |
+
f"| Pages | {meta['pages']} |",
|
| 342 |
+
f"| Date | {meta.get('date', '—')} |",
|
| 343 |
+
f"| Location | {meta.get('location', '—')} |",
|
| 344 |
+
f"| Agency | {meta.get('agency', '—')} |",
|
| 345 |
+
f"| Classification | {meta.get('classification', '—')} |",
|
| 346 |
+
f"| Object / Phenomenon | {meta.get('object_description', '—')} |",
|
| 347 |
+
f"| Witnesses | {meta.get('witnesses', '—')} |",
|
| 348 |
+
]
|
| 349 |
+
if meta["redacted_sections"]:
|
| 350 |
+
lines.append(f"| Redacted sections | {'; '.join(meta['redacted_sections'])} |")
|
| 351 |
+
|
| 352 |
+
lines += ["", "---", "", "## Content", ""]
|
| 353 |
+
|
| 354 |
+
for page in sorted(report_pages, key=lambda p: p["page_num"]):
|
| 355 |
+
lines.append(f"### Page {page['page_num']}")
|
| 356 |
+
lines.append("")
|
| 357 |
+
lines.append((page.get("page_text") or "").strip())
|
| 358 |
+
lines.append("")
|
| 359 |
+
|
| 360 |
+
path.write_text("\n".join(lines), encoding="utf-8")
|
| 361 |
+
return path
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 365 |
+
# Main pipeline
|
| 366 |
+
# ══════════════════════════════════════════════════════════════════════════════
|
| 367 |
+
|
| 368 |
+
def parse_pdf_to_reports(
|
| 369 |
+
pdf_path: str | Path,
|
| 370 |
+
out_dir: str | Path = "reports_out",
|
| 371 |
+
max_workers: int = 4,
|
| 372 |
+
use_segment: bool = True,
|
| 373 |
+
) -> list[Path]:
|
| 374 |
+
"""
|
| 375 |
+
Full two-pass pipeline.
|
| 376 |
+
|
| 377 |
+
Pass 1 (parallel): extract + clean every page via NIM.
|
| 378 |
+
Pass 2 (single): segment the full document via NIM to find report
|
| 379 |
+
boundaries, chunking every SEGMENT_CHUNK pages for
|
| 380 |
+
very long PDFs and merging results across overlaps.
|
| 381 |
+
|
| 382 |
+
Set use_segment=False to skip pass 2 and rely on per-page heuristics only.
|
| 383 |
+
"""
|
| 384 |
+
pdf_path = Path(pdf_path)
|
| 385 |
+
out_dir = Path(out_dir)
|
| 386 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 387 |
+
|
| 388 |
+
# ── 0. extract raw text ───────────────────────────────────────────────────
|
| 389 |
+
print(f"\n📄 {pdf_path.name}")
|
| 390 |
+
with pdfplumber.open(pdf_path) as pdf:
|
| 391 |
+
pages_raw = [
|
| 392 |
+
(i + 1, page.extract_text() or "")
|
| 393 |
+
for i, page in enumerate(pdf.pages)
|
| 394 |
+
]
|
| 395 |
+
non_blank = [(n, t) for n, t in pages_raw if t.strip()]
|
| 396 |
+
print(f" {len(pages_raw)} pages total, {len(non_blank)} non-blank")
|
| 397 |
+
|
| 398 |
+
# ── 1. per-page NIM calls (threaded) ─────────────────────────────────────
|
| 399 |
+
print(f"\n🧵 Pass 1 — page extraction (workers={max_workers}) …")
|
| 400 |
+
results: list[dict] = []
|
| 401 |
+
errors: list[int] = []
|
| 402 |
+
|
| 403 |
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
| 404 |
+
futures = {pool.submit(process_page, num, text): num for num, text in non_blank}
|
| 405 |
+
for fut in as_completed(futures):
|
| 406 |
+
pn = futures[fut]
|
| 407 |
+
try:
|
| 408 |
+
results.append(fut.result())
|
| 409 |
+
except Exception as exc:
|
| 410 |
+
print(f" ✗ page {pn} permanently failed: {exc}")
|
| 411 |
+
errors.append(pn)
|
| 412 |
+
|
| 413 |
+
if errors:
|
| 414 |
+
print(f" ⚠ {len(errors)} page(s) failed: {errors}")
|
| 415 |
+
|
| 416 |
+
# ── 2. document-level segmentation (single / chunked) ────────────────────
|
| 417 |
+
if use_segment:
|
| 418 |
+
print(f"\n🔍 Pass 2 — document segmentation …")
|
| 419 |
+
boundary_map = segment_document(results)
|
| 420 |
+
results = apply_segmentation(results, boundary_map)
|
| 421 |
+
else:
|
| 422 |
+
print("\n⏭ Skipping pass 2 (--no-segment)")
|
| 423 |
+
|
| 424 |
+
# ── 3. group + write ──────────────────────────────────────────────────────
|
| 425 |
+
groups = group_into_reports(results)
|
| 426 |
+
print(f"\n📊 {len(groups)} report(s) found across {len(results)} pages\n")
|
| 427 |
+
|
| 428 |
+
written: list[Path] = []
|
| 429 |
+
for i, group in enumerate(groups, start=1):
|
| 430 |
+
path = write_report_md(group, out_dir, i)
|
| 431 |
+
print(f" ✓ {path.name} ({len(group)} page(s))")
|
| 432 |
+
written.append(path)
|
| 433 |
+
|
| 434 |
+
print(f"\n✅ Done — {len(written)} file(s) → {out_dir}/\n")
|
| 435 |
+
return written
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
| 439 |
+
|
| 440 |
+
if __name__ == "__main__":
|
| 441 |
+
ap = argparse.ArgumentParser(
|
| 442 |
+
description="Parse a PDF of reports into per-report .md files via NVIDIA NIM (two-pass)"
|
| 443 |
+
)
|
| 444 |
+
ap.add_argument("pdf", help="Path to input PDF")
|
| 445 |
+
ap.add_argument("--out", default="reports_out", help="Output directory [reports_out]")
|
| 446 |
+
ap.add_argument("--workers", type=int, default=4, help="Parallel NIM threads for pass 1 [4]")
|
| 447 |
+
ap.add_argument("--no-segment", dest="segment",
|
| 448 |
+
action="store_false", default=True,
|
| 449 |
+
help="Skip pass-2 segmentation (use per-page heuristics only)")
|
| 450 |
+
args = ap.parse_args()
|
| 451 |
+
|
| 452 |
+
parse_pdf_to_reports(
|
| 453 |
+
args.pdf,
|
| 454 |
+
out_dir=args.out,
|
| 455 |
+
max_workers=args.workers,
|
| 456 |
+
use_segment=args.segment,
|
| 457 |
+
)
|
pipeline/reconcile.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reconcile.py
|
| 3 |
+
------------
|
| 4 |
+
Reads the three independent layers of the UAP document archive and emits
|
| 5 |
+
one YAML record file per document into --out-dir.
|
| 6 |
+
|
| 7 |
+
Layers joined:
|
| 8 |
+
1. CSV row ← uap-csv.csv
|
| 9 |
+
2. Page files ← D:/divided/<slug>/page_XXXX/page_XXXX.{md,pdf}
|
| 10 |
+
3. Extracted JSON ← extracted/*.json (extract_reports.py output) ← primary
|
| 11 |
+
NIM report .md ← reports_out/*.md (pdf_to_reports.py output) ← fallback
|
| 12 |
+
4. Blurb regex ← speed / altitude / heading / count parsed inline
|
| 13 |
+
|
| 14 |
+
extract_reports.py replaces pdf_to_reports.py as the Tier 3 source.
|
| 15 |
+
If an extracted/*.json file exists for a record, it is used in preference
|
| 16 |
+
to any matching reports_out/*.md file.
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python reconcile.py
|
| 20 |
+
python reconcile.py --csv uap-csv.csv --divided D:/divided --out-dir records
|
| 21 |
+
python reconcile.py --csv uap-csv.csv --divided D:/divided --extracted extracted --out-dir records
|
| 22 |
+
python reconcile.py --csv uap-csv.csv --divided D:/divided --reports reports_out --out-dir records
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import re
|
| 26 |
+
import csv
|
| 27 |
+
import json
|
| 28 |
+
import argparse
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from datetime import datetime
|
| 31 |
+
|
| 32 |
+
import yaml # pip install pyyaml --break-system-packages
|
| 33 |
+
|
| 34 |
+
# ── tuneable paths ────────────────────────────────────────────────────────────
|
| 35 |
+
DEFAULT_CSV = "uap-csv.csv"
|
| 36 |
+
DEFAULT_DIVIDED = "D:/divided"
|
| 37 |
+
DEFAULT_REPORTS = "reports_out"
|
| 38 |
+
DEFAULT_EXTRACTED = "extracted"
|
| 39 |
+
DEFAULT_OUT = "records"
|
| 40 |
+
|
| 41 |
+
SCHEMA_VERSION = "1.0"
|
| 42 |
+
|
| 43 |
+
# ── regex patterns (all blurb-parseable without NIM) ─────────────────────────
|
| 44 |
+
RE_SPEED_KNOTS = re.compile(r"(\d[\d,]+)\s*kn(?:ots?)?", re.I)
|
| 45 |
+
RE_SPEED_MPH = re.compile(r"(\d[\d,]*)\s*mph", re.I)
|
| 46 |
+
RE_ALT_FEET = re.compile(r"([\d,]+)\s*(?:ft|feet)", re.I)
|
| 47 |
+
RE_HEADING_DEG = re.compile(r"(\d{1,3})\s*degrees?", re.I)
|
| 48 |
+
RE_ZULU = re.compile(r"\b(\d{4}Z)\b")
|
| 49 |
+
RE_OBJ_COUNT = re.compile(
|
| 50 |
+
r"(\d+)\s*(?:x\s*)?UAP|"
|
| 51 |
+
r"(?:one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:separate\s+)?UAP|"
|
| 52 |
+
r"a\s+(?:formation|group)\s+of\s+(\d+)|"
|
| 53 |
+
r"(two|three|four|five)\s+UAP",
|
| 54 |
+
re.I
|
| 55 |
+
)
|
| 56 |
+
COUNT_WORDS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
|
| 57 |
+
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10}
|
| 58 |
+
|
| 59 |
+
# Shape vocabulary – order matters (more specific first)
|
| 60 |
+
SHAPE_PATTERNS = [
|
| 61 |
+
("diamond", re.compile(r"diamond", re.I)),
|
| 62 |
+
("triangle", re.compile(r"triangul", re.I)),
|
| 63 |
+
("sphere", re.compile(r"sphere|spherical|round|bouncy ball", re.I)),
|
| 64 |
+
("disc", re.compile(r"\bdisc\b|\bsaucer\b", re.I)),
|
| 65 |
+
("balloon", re.compile(r"balloon", re.I)),
|
| 66 |
+
("cylinder", re.compile(r"cylindr", re.I)),
|
| 67 |
+
("orb", re.compile(r"\borb\b", re.I)),
|
| 68 |
+
("elongated", re.compile(r"elongated|cigar", re.I)),
|
| 69 |
+
("amorphous", re.compile(r"ball of (?:white |bright )?light|glare|halo", re.I)),
|
| 70 |
+
("formation", re.compile(r"formation|line of dots", re.I)),
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
MANEUVER_PATTERNS = [
|
| 74 |
+
("right_angle_turns", re.compile(r"90.degree|right.angle", re.I)),
|
| 75 |
+
("circling", re.compile(r"circling|orbiting", re.I)),
|
| 76 |
+
("erratic", re.compile(r"erratic|irregular", re.I)),
|
| 77 |
+
("sea_skim", re.compile(r"sea.skim|surface.*skim", re.I)),
|
| 78 |
+
("accelerating", re.compile(r"increased speed|accelerat", re.I)),
|
| 79 |
+
("straight", re.compile(r"straight|consistent.*course|same.*altitude", re.I)),
|
| 80 |
+
("abrupt_turns", re.compile(r"abrupt.*direction|directional change", re.I)),
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
THERMAL_PATTERNS = [
|
| 84 |
+
("white_hot", re.compile(r"white.hot", re.I)),
|
| 85 |
+
("black_hot", re.compile(r"black.hot", re.I)),
|
| 86 |
+
("bright_white",re.compile(r"bright white", re.I)),
|
| 87 |
+
("cold", re.compile(r"\bcold\b", re.I)),
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
SUBTYPE_MAP = {
|
| 91 |
+
"MISREP": re.compile(r"Mission Report|MISREP", re.I),
|
| 92 |
+
"Range_Fouler_Debrief": re.compile(r"Range Fouler Debrief", re.I),
|
| 93 |
+
"Range_Fouler_Reporting_Form": re.compile(r"Range Fouler Reporting Form", re.I),
|
| 94 |
+
"Email_Correspondence": re.compile(r"email correspondence", re.I),
|
| 95 |
+
"Mission_Briefing": re.compile(r"mission briefing", re.I),
|
| 96 |
+
"Intelligence_Report": re.compile(r"intelligence report|air intelligence", re.I),
|
| 97 |
+
"Transcript": re.compile(r"transcript", re.I),
|
| 98 |
+
"Crew_Debriefing": re.compile(r"crew.debriefing|technical.debriefing", re.I),
|
| 99 |
+
"Case_File": re.compile(r"case file", re.I),
|
| 100 |
+
"Launch_Summary": re.compile(r"launch summary", re.I),
|
| 101 |
+
"Cable": re.compile(r"\bcable\b", re.I),
|
| 102 |
+
"Policy_Memo": re.compile(r"memorandum|memo\b", re.I),
|
| 103 |
+
"Photo_Collection": re.compile(r"photo|image|picture", re.I),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ── helpers ────────────────────���──────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
def _first_int(pattern: re.Pattern, text: str) -> int | None:
|
| 110 |
+
m = pattern.search(text)
|
| 111 |
+
if not m:
|
| 112 |
+
return None
|
| 113 |
+
raw = m.group(1).replace(",", "")
|
| 114 |
+
try:
|
| 115 |
+
return int(raw)
|
| 116 |
+
except ValueError:
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _all_matches(pattern: re.Pattern, text: str) -> list[str]:
|
| 121 |
+
return pattern.findall(text)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _first_vocab(pairs: list[tuple[str, re.Pattern]], text: str) -> str | None:
|
| 125 |
+
for label, pat in pairs:
|
| 126 |
+
if pat.search(text):
|
| 127 |
+
return label
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _parse_object_count(blurb: str) -> int | None:
|
| 132 |
+
"""Extract explicit UAP count from blurb."""
|
| 133 |
+
m = RE_OBJ_COUNT.search(blurb)
|
| 134 |
+
if not m:
|
| 135 |
+
return None
|
| 136 |
+
for g in m.groups():
|
| 137 |
+
if g:
|
| 138 |
+
word = g.lower()
|
| 139 |
+
if word.isdigit():
|
| 140 |
+
return int(word)
|
| 141 |
+
return COUNT_WORDS.get(word)
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _parse_subtype(blurb: str) -> str:
|
| 146 |
+
for label, pat in SUBTYPE_MAP.items():
|
| 147 |
+
if pat.search(blurb):
|
| 148 |
+
return label
|
| 149 |
+
return "Other"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _parse_date(raw: str) -> str | None:
|
| 153 |
+
"""Normalise messy CSV dates to ISO 8601 (best effort)."""
|
| 154 |
+
if not raw or raw.strip().upper() in ("N/A", ""):
|
| 155 |
+
return None
|
| 156 |
+
raw = raw.strip()
|
| 157 |
+
# Handle ranges like "4/10/2025-4/11/2025" → take first
|
| 158 |
+
raw = raw.split("-")[0].strip()
|
| 159 |
+
for fmt in ("%m/%d/%y", "%m/%d/%Y", "%Y-%m-%d"):
|
| 160 |
+
try:
|
| 161 |
+
return datetime.strptime(raw, fmt).strftime("%Y-%m-%d")
|
| 162 |
+
except ValueError:
|
| 163 |
+
pass
|
| 164 |
+
return raw # fallback: return as-is
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _slug_from_title(title: str) -> str:
|
| 168 |
+
"""Derive a lowercase filesystem slug from a CSV title."""
|
| 169 |
+
s = title.lower().strip()
|
| 170 |
+
s = re.sub(r"[\s,]+", "-", s)
|
| 171 |
+
s = re.sub(r"[^\w\-]", "", s)
|
| 172 |
+
s = re.sub(r"-+", "-", s).strip("-")
|
| 173 |
+
return s
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _index_document_dirs(divided_root: Path) -> list[Path]:
|
| 177 |
+
"""
|
| 178 |
+
Return every directory under divided_root that looks like a document folder
|
| 179 |
+
(contains at least one page_XXXX/ subfolder). Builds once, reused for all rows.
|
| 180 |
+
"""
|
| 181 |
+
doc_dirs = []
|
| 182 |
+
for d in divided_root.rglob("*"):
|
| 183 |
+
if not d.is_dir():
|
| 184 |
+
continue
|
| 185 |
+
if any(re.match(r"page_\d+$", sub.name, re.I)
|
| 186 |
+
for sub in d.iterdir() if sub.is_dir()):
|
| 187 |
+
doc_dirs.append(d)
|
| 188 |
+
return doc_dirs
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _find_document_dir(divided_root: Path, title: str,
|
| 192 |
+
doc_index: list[Path] | None = None) -> Path | None:
|
| 193 |
+
"""
|
| 194 |
+
Match a CSV title to a document folder anywhere under divided_root.
|
| 195 |
+
Pass a pre-built doc_index (from _index_document_dirs) to avoid
|
| 196 |
+
re-walking the tree for every row.
|
| 197 |
+
"""
|
| 198 |
+
slug = _slug_from_title(title)
|
| 199 |
+
|
| 200 |
+
if doc_index is None:
|
| 201 |
+
doc_index = _index_document_dirs(divided_root)
|
| 202 |
+
|
| 203 |
+
# 1. Exact slug match
|
| 204 |
+
for d in doc_index:
|
| 205 |
+
if d.name.lower() == slug.lower():
|
| 206 |
+
return d
|
| 207 |
+
|
| 208 |
+
# 2. Prefix match (handles abbreviated folder names)
|
| 209 |
+
prefix = slug[:20].lower()
|
| 210 |
+
for d in doc_index:
|
| 211 |
+
if d.name.lower().startswith(prefix):
|
| 212 |
+
return d
|
| 213 |
+
|
| 214 |
+
# 3. Word-intersection fallback
|
| 215 |
+
words = set(slug.split("-")[:5])
|
| 216 |
+
for d in doc_index:
|
| 217 |
+
dwords = set(d.name.lower().split("-")[:5])
|
| 218 |
+
if len(words & dwords) >= min(3, len(words)):
|
| 219 |
+
return d
|
| 220 |
+
|
| 221 |
+
return None
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _scan_pages(doc_dir: Path) -> list[dict]:
|
| 225 |
+
"""Return sorted list of page dicts from the document folder."""
|
| 226 |
+
pages = []
|
| 227 |
+
for sub in sorted(doc_dir.iterdir()):
|
| 228 |
+
if not sub.is_dir():
|
| 229 |
+
continue
|
| 230 |
+
m = re.match(r"page_(\d+)$", sub.name, re.I)
|
| 231 |
+
if not m:
|
| 232 |
+
continue
|
| 233 |
+
idx = int(m.group(1))
|
| 234 |
+
md_file = sub / f"{sub.name}.md"
|
| 235 |
+
pdf_file = sub / f"{sub.name}.pdf"
|
| 236 |
+
|
| 237 |
+
md_text = ""
|
| 238 |
+
if md_file.exists():
|
| 239 |
+
md_text = md_file.read_text(encoding="utf-8", errors="replace")
|
| 240 |
+
|
| 241 |
+
has_images = bool(re.search(r"!\[", md_text))
|
| 242 |
+
image_assets = re.findall(r"!\[.*?\]\((.*?)\)", md_text)
|
| 243 |
+
|
| 244 |
+
pages.append({
|
| 245 |
+
"index": idx,
|
| 246 |
+
"subdir": sub.name,
|
| 247 |
+
"md": str(md_file.relative_to(doc_dir)) if md_file.exists() else None,
|
| 248 |
+
"pdf": str(pdf_file.relative_to(doc_dir)) if pdf_file.exists() else None,
|
| 249 |
+
"has_images": has_images,
|
| 250 |
+
"image_assets": image_assets,
|
| 251 |
+
})
|
| 252 |
+
return pages
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _find_report_md(reports_root: Path, record_id: str) -> Path | None:
|
| 256 |
+
"""Locate the best matching .md file in reports_out for this record."""
|
| 257 |
+
if not reports_root.exists():
|
| 258 |
+
return None
|
| 259 |
+
slug_upper = record_id.upper().replace("-", "_")
|
| 260 |
+
for md in reports_root.glob("*.md"):
|
| 261 |
+
name = md.stem.upper().replace("-", "_")
|
| 262 |
+
if name == slug_upper or name.startswith(slug_upper[:20]):
|
| 263 |
+
return md
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _parse_nim_md(md_path: Path) -> dict:
|
| 268 |
+
"""Extract the metadata table from a pdf_to_reports.py .md file."""
|
| 269 |
+
text = md_path.read_text(encoding="utf-8", errors="replace")
|
| 270 |
+
result = {k: None for k in
|
| 271 |
+
["date", "location", "agency", "classification",
|
| 272 |
+
"object_description", "witnesses", "redacted_sections"]}
|
| 273 |
+
result["redacted_sections"] = []
|
| 274 |
+
|
| 275 |
+
for line in text.splitlines():
|
| 276 |
+
m = re.match(r"\|\s*(.+?)\s*\|\s*(.+?)\s*\|", line)
|
| 277 |
+
if not m:
|
| 278 |
+
continue
|
| 279 |
+
key, val = m.group(1).strip().lower(), m.group(2).strip()
|
| 280 |
+
if val in ("—", "N/A", ""):
|
| 281 |
+
val = None
|
| 282 |
+
if "date" in key: result["date"] = val
|
| 283 |
+
elif "location" in key: result["location"] = val
|
| 284 |
+
elif "agency" in key: result["agency"] = val
|
| 285 |
+
elif "classif" in key: result["classification"] = val
|
| 286 |
+
elif "object" in key: result["object_description"] = val
|
| 287 |
+
elif "witness" in key: result["witnesses"] = val
|
| 288 |
+
elif "redacted" in key and val:
|
| 289 |
+
result["redacted_sections"] = [s.strip() for s in val.split(";")]
|
| 290 |
+
|
| 291 |
+
return result
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _find_extracted_json(extracted_root: Path, record_id: str) -> Path | None:
|
| 295 |
+
"""
|
| 296 |
+
Locate the best matching .json file in extracted/ for this record.
|
| 297 |
+
Mirrors _find_report_md but for extract_reports.py JSON output.
|
| 298 |
+
Skips the combined _all_reports.json file.
|
| 299 |
+
"""
|
| 300 |
+
if not extracted_root.exists():
|
| 301 |
+
return None
|
| 302 |
+
slug_upper = record_id.upper().replace("-", "_")
|
| 303 |
+
for jf in sorted(extracted_root.glob("*.json")):
|
| 304 |
+
if jf.name.startswith("_"): # skip _all_reports.json
|
| 305 |
+
continue
|
| 306 |
+
name = jf.stem.upper().replace("-", "_")
|
| 307 |
+
if name == slug_upper or name.startswith(slug_upper[:20]):
|
| 308 |
+
return jf
|
| 309 |
+
return None
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# Date patterns used by _parse_extracted_json
|
| 313 |
+
_RE_DATE_ISO = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
|
| 314 |
+
_RE_DATE_US = re.compile(r"\b(\d{1,2}/\d{1,2}/\d{2,4})\b")
|
| 315 |
+
_RE_DATE_LONG = re.compile(
|
| 316 |
+
r"\b((?:January|February|March|April|May|June|July|August|"
|
| 317 |
+
r"September|October|November|December)\s+\d{1,2},?\s+\d{4})\b", re.I
|
| 318 |
+
)
|
| 319 |
+
_RE_CLASSIF = re.compile(
|
| 320 |
+
r"\b(UNCLASSIFIED|SECRET|CONFIDENTIAL|TOP\s+SECRET|FOUO|"
|
| 321 |
+
r"FOR\s+OFFICIAL\s+USE\s+ONLY)\b", re.I
|
| 322 |
+
)
|
| 323 |
+
_RE_REDACTED = re.compile(r"\[REDACTED\]|\(b\)\(\d\)", re.I)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _parse_extracted_json(json_path: Path) -> list[dict]:
|
| 327 |
+
"""
|
| 328 |
+
Read an extract_reports.py JSON file and return a list of nim_extracted
|
| 329 |
+
dicts — one per report entry found in the file.
|
| 330 |
+
|
| 331 |
+
Strategy:
|
| 332 |
+
1. Try markdown-table parsing first (handles docs where verbatim text
|
| 333 |
+
preserved a table layout from pdf_to_reports.py output).
|
| 334 |
+
2. Fall back to regex patterns for date, classification, and redactions.
|
| 335 |
+
|
| 336 |
+
Each dict has the standard nim_extracted keys plus a bonus 'pages' key
|
| 337 |
+
(the page-range label from the JSON). The caller pops 'pages' before
|
| 338 |
+
storing into the YAML record.
|
| 339 |
+
"""
|
| 340 |
+
try:
|
| 341 |
+
data = json.loads(json_path.read_text(encoding="utf-8", errors="replace"))
|
| 342 |
+
except Exception as exc:
|
| 343 |
+
return [] # unreadable JSON — skip silently
|
| 344 |
+
|
| 345 |
+
results = []
|
| 346 |
+
for rep in data.get("reports", []):
|
| 347 |
+
raw_text = rep.get("raw_text") or ""
|
| 348 |
+
assessment = rep.get("assessment") or ""
|
| 349 |
+
combined = raw_text + "\n" + assessment
|
| 350 |
+
|
| 351 |
+
nim = {k: None for k in
|
| 352 |
+
["date", "location", "agency", "classification",
|
| 353 |
+
"object_description", "witnesses", "redacted_sections"]}
|
| 354 |
+
nim["redacted_sections"] = []
|
| 355 |
+
|
| 356 |
+
# ── pass 1: markdown table rows (verbatim tables from NIM-style docs) ──
|
| 357 |
+
for line in combined.splitlines():
|
| 358 |
+
m = re.match(r"\|\s*(.+?)\s*\|\s*(.+?)\s*\|", line)
|
| 359 |
+
if not m:
|
| 360 |
+
continue
|
| 361 |
+
key, val = m.group(1).strip().lower(), m.group(2).strip()
|
| 362 |
+
if val in ("—", "N/A", ""):
|
| 363 |
+
val = None
|
| 364 |
+
if "date" in key: nim["date"] = val
|
| 365 |
+
elif "location" in key: nim["location"] = val
|
| 366 |
+
elif "agency" in key: nim["agency"] = val
|
| 367 |
+
elif "classif" in key: nim["classification"] = val
|
| 368 |
+
elif "object" in key: nim["object_description"] = val
|
| 369 |
+
elif "witness" in key: nim["witnesses"] = val
|
| 370 |
+
elif "redacted" in key and val:
|
| 371 |
+
nim["redacted_sections"] = [s.strip() for s in val.split(";")]
|
| 372 |
+
|
| 373 |
+
# ── pass 2: plain-text regex fallbacks ────────────────────────────────
|
| 374 |
+
if nim["date"] is None:
|
| 375 |
+
for pat in (_RE_DATE_ISO, _RE_DATE_US, _RE_DATE_LONG):
|
| 376 |
+
dm = pat.search(combined)
|
| 377 |
+
if dm:
|
| 378 |
+
nim["date"] = dm.group(1)
|
| 379 |
+
break
|
| 380 |
+
|
| 381 |
+
if nim["classification"] is None:
|
| 382 |
+
cm = _RE_CLASSIF.search(combined)
|
| 383 |
+
if cm:
|
| 384 |
+
nim["classification"] = cm.group(1).upper()
|
| 385 |
+
|
| 386 |
+
n_redacted = len(_RE_REDACTED.findall(combined))
|
| 387 |
+
if n_redacted > 0 and not nim["redacted_sections"]:
|
| 388 |
+
nim["redacted_sections"] = [f"{n_redacted} redaction marker(s) detected"]
|
| 389 |
+
|
| 390 |
+
nim["pages"] = rep.get("pages") # bonus field — popped by caller
|
| 391 |
+
results.append(nim)
|
| 392 |
+
|
| 393 |
+
return results
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# ── per-row record builder ────────────────────────────────────────────────────
|
| 397 |
+
|
| 398 |
+
def build_record(row: dict, divided_root: Path, reports_root: Path,
|
| 399 |
+
doc_index: list[Path] | None = None,
|
| 400 |
+
extracted_root: Path | None = None) -> dict:
|
| 401 |
+
title = row.get("Title", "").replace("\n", " ").strip()
|
| 402 |
+
blurb = row.get("Description Blurb", "").replace("\n", " ").strip()
|
| 403 |
+
agency = row.get("Agency", "").strip()
|
| 404 |
+
loc = row.get("Incident Location", "").strip()
|
| 405 |
+
if loc.upper() == "N/A":
|
| 406 |
+
loc = None
|
| 407 |
+
|
| 408 |
+
record_id = _slug_from_title(title) or "unknown"
|
| 409 |
+
|
| 410 |
+
# ── Tier 1: CSV ──────────────────────────────────────────────────────────
|
| 411 |
+
csv_block = {
|
| 412 |
+
"title": title,
|
| 413 |
+
"agency": agency,
|
| 414 |
+
"release_date": _parse_date(row.get("Release Date", "")),
|
| 415 |
+
"redacted": row.get("Redaction", "").strip().upper() == "TRUE",
|
| 416 |
+
"document_type": row.get("Type", "PDF").strip(),
|
| 417 |
+
"report_subtype": _parse_subtype(blurb),
|
| 418 |
+
"incident_date": _parse_date(row.get("Incident Date", "")),
|
| 419 |
+
"incident_date_precision": "day", # refined by enrich.py
|
| 420 |
+
"location_csv": loc,
|
| 421 |
+
"dvids_video_id": row.get("DVIDS Video ID", "").strip() or None,
|
| 422 |
+
"video_pairing": row.get("Video Pairing", "").strip() or None,
|
| 423 |
+
"pdf_pairing": row.get("PDF Pairing", "").strip() or None,
|
| 424 |
+
"pdf_url": row.get("PDF | Image Link", "").strip() or None,
|
| 425 |
+
"thumbnail_url": row.get("Modal Image", "").strip() or None,
|
| 426 |
+
"description_blurb": blurb,
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
# ── Tier 2: Page files ───────────────────────────────────────────────────
|
| 430 |
+
doc_dir = _find_document_dir(divided_root, title, doc_index)
|
| 431 |
+
if doc_dir:
|
| 432 |
+
pages = _scan_pages(doc_dir)
|
| 433 |
+
else:
|
| 434 |
+
pages = []
|
| 435 |
+
|
| 436 |
+
files_block = {
|
| 437 |
+
"document_dir": str(doc_dir) if doc_dir else None,
|
| 438 |
+
"page_count": len(pages),
|
| 439 |
+
"pages": pages,
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
# ── Tier 3: Extracted JSON (primary) or NIM report .md (fallback) ───────
|
| 443 |
+
json_path = (
|
| 444 |
+
_find_extracted_json(extracted_root, record_id)
|
| 445 |
+
if extracted_root else None
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
if json_path:
|
| 449 |
+
# ── primary: extract_reports.py JSON ──────────────────────────────────
|
| 450 |
+
extracted_reports = _parse_extracted_json(json_path)
|
| 451 |
+
|
| 452 |
+
if extracted_reports:
|
| 453 |
+
reports_block = []
|
| 454 |
+
for idx, nim_data in enumerate(extracted_reports, 1):
|
| 455 |
+
page_label = nim_data.pop("pages", None) # remove bonus field
|
| 456 |
+
reports_block.append({
|
| 457 |
+
"report_id": f"{record_id}_r{idx:02d}" if len(extracted_reports) > 1
|
| 458 |
+
else record_id,
|
| 459 |
+
"json_path": str(json_path),
|
| 460 |
+
"page_range": page_label or ([1, len(pages)] if pages else [1, None]),
|
| 461 |
+
"nim_extracted": nim_data,
|
| 462 |
+
})
|
| 463 |
+
else:
|
| 464 |
+
# JSON found but empty / parse error
|
| 465 |
+
reports_block = [{
|
| 466 |
+
"report_id": record_id,
|
| 467 |
+
"json_path": str(json_path),
|
| 468 |
+
"page_range": [1, len(pages)] if pages else [1, None],
|
| 469 |
+
"nim_extracted": {k: None for k in
|
| 470 |
+
["date", "location", "agency", "classification",
|
| 471 |
+
"object_description", "witnesses", "redacted_sections"]},
|
| 472 |
+
}]
|
| 473 |
+
else:
|
| 474 |
+
# ── fallback: pdf_to_reports.py .md ───────────────────────────────────
|
| 475 |
+
report_md = _find_report_md(reports_root, record_id)
|
| 476 |
+
nim_data = _parse_nim_md(report_md) if report_md else {
|
| 477 |
+
k: None for k in ["date", "location", "agency",
|
| 478 |
+
"classification", "object_description",
|
| 479 |
+
"witnesses", "redacted_sections"]
|
| 480 |
+
}
|
| 481 |
+
if "redacted_sections" not in nim_data:
|
| 482 |
+
nim_data["redacted_sections"] = []
|
| 483 |
+
|
| 484 |
+
reports_block = [{
|
| 485 |
+
"report_id": record_id,
|
| 486 |
+
"md_path": str(report_md) if report_md else None,
|
| 487 |
+
"page_range": [1, len(pages)] if pages else [1, None],
|
| 488 |
+
"nim_extracted": nim_data,
|
| 489 |
+
}]
|
| 490 |
+
|
| 491 |
+
# ── Tier 4: Observation (regex from blurb) ───────────────────────────────
|
| 492 |
+
observation_block = {
|
| 493 |
+
"location_precise": loc,
|
| 494 |
+
"morphology": {
|
| 495 |
+
"shape": _first_vocab(SHAPE_PATTERNS, blurb),
|
| 496 |
+
"color": None,
|
| 497 |
+
"thermal_appearance": _first_vocab(THERMAL_PATTERNS, blurb),
|
| 498 |
+
"material": "metallic" if re.search(r"metallic", blurb, re.I) else None,
|
| 499 |
+
"size_estimate": None,
|
| 500 |
+
},
|
| 501 |
+
"kinematics": {
|
| 502 |
+
"object_count": _parse_object_count(blurb),
|
| 503 |
+
"speed_knots": _first_int(RE_SPEED_KNOTS, blurb),
|
| 504 |
+
"speed_mph": _first_int(RE_SPEED_MPH, blurb),
|
| 505 |
+
"altitude_ft": _first_int(RE_ALT_FEET, blurb),
|
| 506 |
+
"heading_degrees": _first_int(RE_HEADING_DEG, blurb),
|
| 507 |
+
"direction_cardinal": None,
|
| 508 |
+
"duration_seconds": None,
|
| 509 |
+
"maneuver_type": _first_vocab(MANEUVER_PATTERNS, blurb),
|
| 510 |
+
"flight_profile": None,
|
| 511 |
+
},
|
| 512 |
+
"detection": {
|
| 513 |
+
"sensor_types": [],
|
| 514 |
+
"platform": "P8A" if re.search(r"P-8A", blurb) else None,
|
| 515 |
+
"tracking_outcome": None,
|
| 516 |
+
"environmental_factors": (
|
| 517 |
+
["cloud_cover"] if re.search(r"cloud", blurb, re.I) else []
|
| 518 |
+
),
|
| 519 |
+
},
|
| 520 |
+
"assessment": {
|
| 521 |
+
"observer_label": None,
|
| 522 |
+
"threat_assessment": "benign" if re.search(r"\bbenign\b", blurb, re.I) else None,
|
| 523 |
+
"pursuit_status": (
|
| 524 |
+
"not_pursued" if re.search(r"did not pursue", blurb, re.I) else None
|
| 525 |
+
),
|
| 526 |
+
"zulu_timestamps": list(set(RE_ZULU.findall(blurb))),
|
| 527 |
+
},
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
# ── Tier 5: Relations ────────────────────────────────────────────────────
|
| 531 |
+
# Detect parent case / sibling logic for HS1 records
|
| 532 |
+
case_match = re.search(r"(\d{2}-HQ-\d+|\d{2}-[A-Z]+-\d+)", title)
|
| 533 |
+
relations_block = {
|
| 534 |
+
"part_of_case": case_match.group(1) if case_match else None,
|
| 535 |
+
"parent_document": None,
|
| 536 |
+
"sibling_documents": [],
|
| 537 |
+
"paired_video": csv_block["video_pairing"],
|
| 538 |
+
"paired_pdf": csv_block["pdf_pairing"],
|
| 539 |
+
"related_incidents": [],
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
return {
|
| 543 |
+
"schema_version": SCHEMA_VERSION,
|
| 544 |
+
"record_id": record_id,
|
| 545 |
+
"csv": csv_block,
|
| 546 |
+
"files": files_block,
|
| 547 |
+
"reports": reports_block,
|
| 548 |
+
"observation": observation_block,
|
| 549 |
+
"relations": relations_block,
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
# ── main ──────────────────────────────────────────────────────────────────────
|
| 554 |
+
|
| 555 |
+
def main():
|
| 556 |
+
ap = argparse.ArgumentParser(description="Reconcile CSV + pages + reports → YAML records")
|
| 557 |
+
ap.add_argument("--csv", default=DEFAULT_CSV, help="Path to uap-csv.csv")
|
| 558 |
+
ap.add_argument("--divided", default=DEFAULT_DIVIDED, help="Root of divided/ folder")
|
| 559 |
+
ap.add_argument("--extracted", default=DEFAULT_EXTRACTED,
|
| 560 |
+
help="extracted/ directory with JSON from extract_reports.py (primary Tier 3)")
|
| 561 |
+
ap.add_argument("--reports", default=DEFAULT_REPORTS,
|
| 562 |
+
help="reports_out/ directory with .md from pdf_to_reports.py (fallback Tier 3)")
|
| 563 |
+
ap.add_argument("--out-dir", default=DEFAULT_OUT, help="Output directory for YAML files")
|
| 564 |
+
ap.add_argument("--limit", type=int, default=0, help="Process only first N rows (0 = all)")
|
| 565 |
+
args = ap.parse_args()
|
| 566 |
+
|
| 567 |
+
divided_root = Path(args.divided)
|
| 568 |
+
extracted_root = Path(args.extracted)
|
| 569 |
+
reports_root = Path(args.reports)
|
| 570 |
+
out_dir = Path(args.out_dir)
|
| 571 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 572 |
+
|
| 573 |
+
# Informative summary of which Tier 3 sources are present
|
| 574 |
+
ext_count = len([f for f in extracted_root.glob("*.json")
|
| 575 |
+
if not f.name.startswith("_")]) if extracted_root.exists() else 0
|
| 576 |
+
rpt_count = len(list(reports_root.glob("*.md"))) if reports_root.exists() else 0
|
| 577 |
+
print(f" Tier 3 — extracted JSON : {ext_count} files ({extracted_root})")
|
| 578 |
+
print(f" Tier 3 — reports .md : {rpt_count} files ({reports_root}) [fallback]")
|
| 579 |
+
|
| 580 |
+
with open(args.csv, newline="", encoding="utf-8-sig") as f:
|
| 581 |
+
reader = csv.DictReader(f)
|
| 582 |
+
rows = list(reader)
|
| 583 |
+
|
| 584 |
+
print(f"\n {len(rows)} CSV rows → {out_dir}/")
|
| 585 |
+
print(f" Indexing document folders under {divided_root} …")
|
| 586 |
+
doc_index = _index_document_dirs(divided_root)
|
| 587 |
+
print(f" Found {len(doc_index)} document folders\n")
|
| 588 |
+
|
| 589 |
+
written, skipped = 0, 0
|
| 590 |
+
for i, row in enumerate(rows):
|
| 591 |
+
if args.limit and i >= args.limit:
|
| 592 |
+
break
|
| 593 |
+
title = row.get("Title", "").replace("\n", " ").strip()
|
| 594 |
+
if not title:
|
| 595 |
+
skipped += 1
|
| 596 |
+
continue
|
| 597 |
+
|
| 598 |
+
record = build_record(row, divided_root, reports_root, doc_index,
|
| 599 |
+
extracted_root=extracted_root)
|
| 600 |
+
slug = record["record_id"]
|
| 601 |
+
out_path = out_dir / f"{slug}.yaml"
|
| 602 |
+
|
| 603 |
+
with open(out_path, "w", encoding="utf-8") as f:
|
| 604 |
+
yaml.dump(record, f, allow_unicode=True, sort_keys=False,
|
| 605 |
+
default_flow_style=False, width=100)
|
| 606 |
+
|
| 607 |
+
written += 1
|
| 608 |
+
if written <= 5 or written % 20 == 0:
|
| 609 |
+
print(f" ✓ {out_path.name}")
|
| 610 |
+
|
| 611 |
+
print(f"\n✅ {written} records written, {skipped} skipped → {out_dir}/")
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
if __name__ == "__main__":
|
| 615 |
+
main()
|
pipeline/reorganize.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
reorganize.py
|
| 3 |
+
─────────────
|
| 4 |
+
Classifies every folder in D:/divided by name pattern and moves it into
|
| 5 |
+
a logical agency/type subfolder hierarchy.
|
| 6 |
+
|
| 7 |
+
Step 1 — dry run (default): prints every planned move, nothing changes.
|
| 8 |
+
Step 2 — execute: pass --execute to actually move folders.
|
| 9 |
+
Step 3 — undo: pass --undo to reverse (reads the move log).
|
| 10 |
+
|
| 11 |
+
Usage
|
| 12 |
+
-----
|
| 13 |
+
python reorganize.py # dry-run, print plan
|
| 14 |
+
python reorganize.py --execute # move everything
|
| 15 |
+
python reorganize.py --undo # reverse all moves (uses move_log.json)
|
| 16 |
+
python reorganize.py --execute --root D:/somewhere_else
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import json
|
| 21 |
+
import shutil
|
| 22 |
+
import argparse
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from datetime import datetime
|
| 25 |
+
|
| 26 |
+
# ── Target root (all new subfolders are created inside here) ─────────────────
|
| 27 |
+
DEFAULT_ROOT = "D:/divided"
|
| 28 |
+
|
| 29 |
+
# ── Classification rules (first match wins — order matters) ─────────────────
|
| 30 |
+
# Each tuple: (regex_on_folder_name_lowercase, destination_relative_to_root)
|
| 31 |
+
RULES: list[tuple[str, str]] = [
|
| 32 |
+
|
| 33 |
+
# ── Duplicates (catch before anything else) ───────────────────────────────
|
| 34 |
+
(r"- copy$", "MISC/_duplicates"),
|
| 35 |
+
|
| 36 |
+
# ── DOD / Department of War ───────────────────────────────────────────────
|
| 37 |
+
# Range Fouler reports (name contains "range-fouler" or "range_fouler")
|
| 38 |
+
(r"dow-uap-d\d+.*range.fouler.*arabian", "DOD/range-fouler-debriefs/arabian"),
|
| 39 |
+
(r"dow-uap-d\d+.*range.fouler.*japan", "DOD/range-fouler-debriefs/japan"),
|
| 40 |
+
(r"dow-uap-d\d+.*range.fouler.*aden", "DOD/range-fouler-debriefs/gulf-of-aden"),
|
| 41 |
+
(r"dow-uap-d\d+.*range.fouler.*middle", "DOD/range-fouler-debriefs/middle-east"),
|
| 42 |
+
(r"dow-uap-d\d+.*range.fouler", "DOD/range-fouler-debriefs/other"),
|
| 43 |
+
|
| 44 |
+
# Email correspondence
|
| 45 |
+
(r"dow-uap-d\d+.*email", "DOD/email-correspondence"),
|
| 46 |
+
|
| 47 |
+
# Mission reports by region
|
| 48 |
+
(r"dow-uap-d\d+.*mission.*arabian", "DOD/mission-reports/arabian-gulf"),
|
| 49 |
+
(r"dow-uap-d\d+.*mission.*iraq", "DOD/mission-reports/iraq"),
|
| 50 |
+
(r"dow-uap-d\d+.*mission.*syria", "DOD/mission-reports/syria"),
|
| 51 |
+
(r"dow-uap-d\d+.*mission.*persian", "DOD/mission-reports/persian-gulf"),
|
| 52 |
+
(r"dow-uap-d\d+.*mission.*hormuz", "DOD/mission-reports/strait-of-hormuz"),
|
| 53 |
+
(r"dow-uap-d\d+.*mission.*greece", "DOD/mission-reports/greece"),
|
| 54 |
+
(r"dow-uap-d\d+.*mission.*emirates", "DOD/mission-reports/uae"),
|
| 55 |
+
(r"dow-uap-d\d+.*mission.*aden", "DOD/mission-reports/gulf-of-aden"),
|
| 56 |
+
(r"dow-uap-d\d+.*mission.*mediterranean", "DOD/mission-reports/mediterranean"),
|
| 57 |
+
(r"dow-uap-d\d+.*mission.*iran", "DOD/mission-reports/iran"),
|
| 58 |
+
(r"dow-uap-d\d+.*mission.*djibouti", "DOD/mission-reports/djibouti"),
|
| 59 |
+
(r"dow-uap-d\d+.*mission.*southern", "DOD/mission-reports/southern-us"),
|
| 60 |
+
(r"dow-uap-d\d+.*mission.*middle", "DOD/mission-reports/middle-east"),
|
| 61 |
+
(r"dow-uap-d\d+.*mission.*china", "DOD/mission-reports/east-china-sea"),
|
| 62 |
+
(r"dow-uap-d\d+.*mission.*gulf.of.aden", "DOD/mission-reports/gulf-of-aden"),
|
| 63 |
+
(r"dow-uap-d\d+.*mission", "DOD/mission-reports/other"),
|
| 64 |
+
|
| 65 |
+
# DOD catch-alls
|
| 66 |
+
(r"dow-uap-pr\d+", "DOD/reports-other"),
|
| 67 |
+
(r"dow-uap-d\d+", "DOD/reports-other"),
|
| 68 |
+
|
| 69 |
+
# ── NASA ──────────────────────────────────────────────────────────────────
|
| 70 |
+
(r"nasa-uap-d\d+.*transcript", "NASA/transcripts"),
|
| 71 |
+
(r"nasa-uap-d\d+.*debriefing", "NASA/crew-debriefings"),
|
| 72 |
+
(r"nasa-uap", "NASA/other"),
|
| 73 |
+
|
| 74 |
+
# ── FBI ───────────────────────────────────────────────────────────────────
|
| 75 |
+
(r"fbi-photo", "FBI/photo-collections"),
|
| 76 |
+
|
| 77 |
+
# ── Department of State ───────────────────────────────────────────────────
|
| 78 |
+
(r"dos-uap", "DOS/cables"),
|
| 79 |
+
|
| 80 |
+
# ── NARA / CIA archives ───────────────────────────────────────────────────
|
| 81 |
+
(r"65_hs1-834228961", "NARA-CIA/hs1-834228961"),
|
| 82 |
+
(r"65_hs1-101634279", "NARA-CIA/hs1-101634279"),
|
| 83 |
+
(r"^341_", "NARA-CIA/series-341"),
|
| 84 |
+
(r"^342_", "NARA-CIA/series-342"),
|
| 85 |
+
(r"^331_", "NARA-CIA/series-331"),
|
| 86 |
+
(r"^38_", "NARA-CIA/series-38"),
|
| 87 |
+
(r"^59_", "NARA-CIA/series-59"),
|
| 88 |
+
(r"^18_", "NARA-CIA/series-18"),
|
| 89 |
+
(r"^255_", "NARA-CIA/series-255"),
|
| 90 |
+
|
| 91 |
+
# ── MISC ──────────────────────────────────────────────────────────────────
|
| 92 |
+
(r"serial.*redacted|usper.*statement", "MISC/statements-redacted"),
|
| 93 |
+
(r"sketch|composite", "MISC/visuals"),
|
| 94 |
+
(r"slides", "MISC/visuals"),
|
| 95 |
+
(r"059uap", "MISC/unclassified"),
|
| 96 |
+
(r"western_us", "MISC/presentations"),
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
# Folders (and files) that should never be moved
|
| 100 |
+
SKIP_NAMES = {
|
| 101 |
+
"reorganize.py",
|
| 102 |
+
"reconcile.py",
|
| 103 |
+
"pdf_to_reports.py",
|
| 104 |
+
"uap_record_schema.yaml",
|
| 105 |
+
"uap-csv.csv",
|
| 106 |
+
"move_log.json",
|
| 107 |
+
"records",
|
| 108 |
+
"reports_out",
|
| 109 |
+
"MISC",
|
| 110 |
+
"DOD",
|
| 111 |
+
"NASA",
|
| 112 |
+
"FBI",
|
| 113 |
+
"DOS",
|
| 114 |
+
"NARA-CIA",
|
| 115 |
+
"Untitled.md",
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ── core logic ────────────────────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
def classify(name: str) -> str | None:
|
| 122 |
+
"""Return destination subpath (relative to root) for a folder name, or None to skip."""
|
| 123 |
+
lower = name.lower()
|
| 124 |
+
for pattern, dest in RULES:
|
| 125 |
+
if re.search(pattern, lower):
|
| 126 |
+
return dest
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def plan_moves(root: Path) -> list[dict]:
|
| 131 |
+
"""
|
| 132 |
+
Walk the immediate children of root (folders only) and return a list of
|
| 133 |
+
planned move operations: {src, dst, dest_category}.
|
| 134 |
+
"""
|
| 135 |
+
moves = []
|
| 136 |
+
unmatched = []
|
| 137 |
+
|
| 138 |
+
for item in sorted(root.iterdir()):
|
| 139 |
+
if not item.is_dir():
|
| 140 |
+
continue
|
| 141 |
+
if item.name in SKIP_NAMES:
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
dest_rel = classify(item.name)
|
| 145 |
+
if dest_rel is None:
|
| 146 |
+
unmatched.append(item.name)
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
dst_dir = root / dest_rel
|
| 150 |
+
dst = dst_dir / item.name
|
| 151 |
+
|
| 152 |
+
moves.append({
|
| 153 |
+
"src": str(item),
|
| 154 |
+
"dst": str(dst),
|
| 155 |
+
"dest_category": dest_rel,
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
return moves, unmatched
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def print_plan(moves: list[dict], unmatched: list[str]) -> None:
|
| 162 |
+
current_cat = None
|
| 163 |
+
for m in moves:
|
| 164 |
+
cat = m["dest_category"]
|
| 165 |
+
if cat != current_cat:
|
| 166 |
+
print(f"\n 📁 {cat}/")
|
| 167 |
+
current_cat = cat
|
| 168 |
+
src_name = Path(m["src"]).name
|
| 169 |
+
print(f" {src_name}")
|
| 170 |
+
|
| 171 |
+
if unmatched:
|
| 172 |
+
print(f"\n ⚠ {len(unmatched)} folder(s) did not match any rule:")
|
| 173 |
+
for u in unmatched:
|
| 174 |
+
print(f" {u}")
|
| 175 |
+
|
| 176 |
+
print(f"\n Total: {len(moves)} moves planned, {len(unmatched)} unmatched\n")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def execute_moves(moves: list[dict], root: Path, log_path: Path) -> None:
|
| 180 |
+
done = []
|
| 181 |
+
errors = []
|
| 182 |
+
|
| 183 |
+
for m in moves:
|
| 184 |
+
src = Path(m["src"])
|
| 185 |
+
dst = Path(m["dst"])
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 189 |
+
if dst.exists():
|
| 190 |
+
print(f" ⚠ destination exists, skipping: {dst.name}")
|
| 191 |
+
continue
|
| 192 |
+
shutil.move(str(src), str(dst))
|
| 193 |
+
done.append(m)
|
| 194 |
+
print(f" ✓ {src.name} → {m['dest_category']}/")
|
| 195 |
+
except Exception as exc:
|
| 196 |
+
errors.append({"move": m, "error": str(exc)})
|
| 197 |
+
print(f" ✗ {src.name} ERROR: {exc}")
|
| 198 |
+
|
| 199 |
+
# Write undo log
|
| 200 |
+
log = {
|
| 201 |
+
"timestamp": datetime.now().isoformat(),
|
| 202 |
+
"root": str(root),
|
| 203 |
+
"moves": done,
|
| 204 |
+
}
|
| 205 |
+
log_path.write_text(json.dumps(log, indent=2), encoding="utf-8")
|
| 206 |
+
print(f"\n ✅ {len(done)} moved, {len(errors)} errors")
|
| 207 |
+
print(f" 📝 Undo log written to {log_path}")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def undo_moves(log_path: Path) -> None:
|
| 211 |
+
if not log_path.exists():
|
| 212 |
+
print(f" ✗ No undo log found at {log_path}")
|
| 213 |
+
return
|
| 214 |
+
|
| 215 |
+
log = json.loads(log_path.read_text(encoding="utf-8"))
|
| 216 |
+
moves = log.get("moves", [])
|
| 217 |
+
|
| 218 |
+
print(f" Reversing {len(moves)} moves from {log['timestamp']} …\n")
|
| 219 |
+
errors = 0
|
| 220 |
+
for m in reversed(moves):
|
| 221 |
+
src = Path(m["dst"]) # swap: dst is where it ended up
|
| 222 |
+
dst = Path(m["src"]) # src is where it came from
|
| 223 |
+
try:
|
| 224 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 225 |
+
shutil.move(str(src), str(dst))
|
| 226 |
+
print(f" ↩ {src.name} → {dst.parent.name}/")
|
| 227 |
+
except Exception as exc:
|
| 228 |
+
print(f" ✗ {src.name} ERROR: {exc}")
|
| 229 |
+
errors += 1
|
| 230 |
+
|
| 231 |
+
print(f"\n ✅ Undo complete. {len(moves) - errors} restored, {errors} errors")
|
| 232 |
+
log_path.unlink(missing_ok=True)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
| 236 |
+
|
| 237 |
+
def main():
|
| 238 |
+
ap = argparse.ArgumentParser(
|
| 239 |
+
description="Reorganise D:/divided into a logical folder hierarchy"
|
| 240 |
+
)
|
| 241 |
+
ap.add_argument("--root", default=DEFAULT_ROOT, help="Root folder to reorganise")
|
| 242 |
+
ap.add_argument("--execute", action="store_true", help="Actually move folders (default: dry-run)")
|
| 243 |
+
ap.add_argument("--undo", action="store_true", help="Reverse previous run using move_log.json")
|
| 244 |
+
args = ap.parse_args()
|
| 245 |
+
|
| 246 |
+
root = Path(args.root)
|
| 247 |
+
log_path = root / "move_log.json"
|
| 248 |
+
|
| 249 |
+
if args.undo:
|
| 250 |
+
undo_moves(log_path)
|
| 251 |
+
return
|
| 252 |
+
|
| 253 |
+
moves, unmatched = plan_moves(root)
|
| 254 |
+
|
| 255 |
+
if not args.execute:
|
| 256 |
+
print(f"\n{'─'*60}")
|
| 257 |
+
print(f" DRY RUN — nothing will be moved")
|
| 258 |
+
print(f" Root: {root}")
|
| 259 |
+
print(f"{'─'*60}")
|
| 260 |
+
print_plan(moves, unmatched)
|
| 261 |
+
print(" Run with --execute to apply.\n")
|
| 262 |
+
else:
|
| 263 |
+
print(f"\n Moving folders under {root} …\n")
|
| 264 |
+
execute_moves(moves, root, log_path)
|
| 265 |
+
|
| 266 |
+
if unmatched:
|
| 267 |
+
print(f"\n ⚠ {len(unmatched)} folder(s) left in place (no rule matched):")
|
| 268 |
+
for u in unmatched:
|
| 269 |
+
print(f" {u}")
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
if __name__ == "__main__":
|
| 273 |
+
main()
|
pipeline/restructure_pages.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
restructure_pages.py
|
| 3 |
+
────────────────────
|
| 4 |
+
Migrates every document folder in D:/divided from Variant B to Variant A.
|
| 5 |
+
|
| 6 |
+
Variant B (current):
|
| 7 |
+
<doc-slug>/
|
| 8 |
+
page_0001.pdf ← PDF at document root
|
| 9 |
+
page_0001/
|
| 10 |
+
page_0001.md ← OCR in subfolder
|
| 11 |
+
|
| 12 |
+
Variant A (target):
|
| 13 |
+
<doc-slug>/
|
| 14 |
+
page_0001/
|
| 15 |
+
page_0001.pdf ← PDF moved into subfolder
|
| 16 |
+
page_0001.md ← OCR already here
|
| 17 |
+
|
| 18 |
+
Usage
|
| 19 |
+
-----
|
| 20 |
+
python restructure_pages.py # dry-run, print plan
|
| 21 |
+
python restructure_pages.py --execute # move PDFs
|
| 22 |
+
python restructure_pages.py --undo # reverse (reads page_move_log.json)
|
| 23 |
+
python restructure_pages.py --root D:/somewhere # different root
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
import re
|
| 27 |
+
import json
|
| 28 |
+
import shutil
|
| 29 |
+
import argparse
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
|
| 33 |
+
DEFAULT_ROOT = "D:/divided"
|
| 34 |
+
LOG_FILENAME = "page_move_log.json"
|
| 35 |
+
|
| 36 |
+
# Names that are not document folders
|
| 37 |
+
SKIP_NAMES = {
|
| 38 |
+
"reorganize.py",
|
| 39 |
+
"restructure_pages.py",
|
| 40 |
+
"reconcile.py",
|
| 41 |
+
"pdf_to_reports.py",
|
| 42 |
+
"uap_record_schema.yaml",
|
| 43 |
+
"uap-csv.csv",
|
| 44 |
+
"move_log.json",
|
| 45 |
+
"page_move_log.json",
|
| 46 |
+
"records",
|
| 47 |
+
"reports_out",
|
| 48 |
+
"MISC",
|
| 49 |
+
"DOD",
|
| 50 |
+
"NASA",
|
| 51 |
+
"FBI",
|
| 52 |
+
"DOS",
|
| 53 |
+
"NARA-CIA",
|
| 54 |
+
"CLAUDE.md",
|
| 55 |
+
"Untitled.md",
|
| 56 |
+
"README.md",
|
| 57 |
+
"wiki",
|
| 58 |
+
"raw",
|
| 59 |
+
"scripts",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
PAGE_PDF_RE = re.compile(r"^(page_\d+)\.pdf$", re.IGNORECASE)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def find_moves(root: Path) -> list[dict]:
|
| 66 |
+
"""
|
| 67 |
+
Walk every document folder and find root-level page PDFs that need
|
| 68 |
+
to move into their matching page subfolder.
|
| 69 |
+
"""
|
| 70 |
+
moves = []
|
| 71 |
+
|
| 72 |
+
for doc_dir in sorted(root.iterdir()):
|
| 73 |
+
if not doc_dir.is_dir():
|
| 74 |
+
continue
|
| 75 |
+
if doc_dir.name in SKIP_NAMES:
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
for item in sorted(doc_dir.iterdir()):
|
| 79 |
+
if not item.is_file():
|
| 80 |
+
continue
|
| 81 |
+
m = PAGE_PDF_RE.match(item.name)
|
| 82 |
+
if not m:
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
page_slug = m.group(1) # e.g. "page_0001"
|
| 86 |
+
subfolder = doc_dir / page_slug # e.g. .../page_0001/
|
| 87 |
+
dst = subfolder / item.name # e.g. .../page_0001/page_0001.pdf
|
| 88 |
+
|
| 89 |
+
moves.append({
|
| 90 |
+
"doc": doc_dir.name,
|
| 91 |
+
"src": str(item),
|
| 92 |
+
"dst": str(dst),
|
| 93 |
+
"subfolder_exists": subfolder.is_dir(),
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
return moves
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def print_plan(moves: list[dict]) -> None:
|
| 100 |
+
current_doc = None
|
| 101 |
+
missing_subfolder = []
|
| 102 |
+
|
| 103 |
+
for m in moves:
|
| 104 |
+
if m["doc"] != current_doc:
|
| 105 |
+
print(f"\n 📁 {m['doc']}/")
|
| 106 |
+
current_doc = m["doc"]
|
| 107 |
+
src_name = Path(m["src"]).name
|
| 108 |
+
note = "" if m["subfolder_exists"] else " ⚠ subfolder will be created"
|
| 109 |
+
print(f" {src_name} → {Path(m['dst']).parent.name}/{src_name}{note}")
|
| 110 |
+
if not m["subfolder_exists"]:
|
| 111 |
+
missing_subfolder.append(m["src"])
|
| 112 |
+
|
| 113 |
+
print(f"\n Total: {len(moves)} PDFs to move")
|
| 114 |
+
if missing_subfolder:
|
| 115 |
+
print(f" ⚠ {len(missing_subfolder)} subfolder(s) will be created (no .md yet):")
|
| 116 |
+
for s in missing_subfolder:
|
| 117 |
+
print(f" {Path(s).parent.name}/{Path(s).name}")
|
| 118 |
+
print()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def execute_moves(moves: list[dict], log_path: Path) -> None:
|
| 122 |
+
done = []
|
| 123 |
+
errors = []
|
| 124 |
+
|
| 125 |
+
for m in moves:
|
| 126 |
+
src = Path(m["src"])
|
| 127 |
+
dst = Path(m["dst"])
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 131 |
+
if dst.exists():
|
| 132 |
+
print(f" ⚠ already in place, skipping: {dst}")
|
| 133 |
+
continue
|
| 134 |
+
shutil.move(str(src), str(dst))
|
| 135 |
+
done.append(m)
|
| 136 |
+
print(f" ✓ {src.parent.name}/{src.name} → {dst.parent.name}/")
|
| 137 |
+
except Exception as exc:
|
| 138 |
+
errors.append({"move": m, "error": str(exc)})
|
| 139 |
+
print(f" ✗ {src.name} ERROR: {exc}")
|
| 140 |
+
|
| 141 |
+
log = {
|
| 142 |
+
"timestamp": datetime.now().isoformat(),
|
| 143 |
+
"moves": done,
|
| 144 |
+
}
|
| 145 |
+
log_path.write_text(json.dumps(log, indent=2), encoding="utf-8")
|
| 146 |
+
print(f"\n ✅ {len(done)} moved, {len(errors)} errors")
|
| 147 |
+
print(f" 📝 Undo log → {log_path}\n")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def undo_moves(log_path: Path) -> None:
|
| 151 |
+
if not log_path.exists():
|
| 152 |
+
print(f" ✗ No undo log found at {log_path}")
|
| 153 |
+
return
|
| 154 |
+
|
| 155 |
+
log = json.loads(log_path.read_text(encoding="utf-8"))
|
| 156 |
+
moves = log.get("moves", [])
|
| 157 |
+
print(f" Reversing {len(moves)} moves from {log['timestamp']} …\n")
|
| 158 |
+
|
| 159 |
+
errors = 0
|
| 160 |
+
for m in reversed(moves):
|
| 161 |
+
src = Path(m["dst"]) # where it ended up
|
| 162 |
+
dst = Path(m["src"]) # where it came from
|
| 163 |
+
try:
|
| 164 |
+
shutil.move(str(src), str(dst))
|
| 165 |
+
print(f" ↩ {src.name} → {dst.parent.name}/")
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
print(f" ✗ {src.name} ERROR: {exc}")
|
| 168 |
+
errors += 1
|
| 169 |
+
|
| 170 |
+
print(f"\n ✅ Undo complete. {len(moves) - errors} restored, {errors} errors")
|
| 171 |
+
log_path.unlink(missing_ok=True)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def main():
|
| 175 |
+
ap = argparse.ArgumentParser(
|
| 176 |
+
description="Move root-level page PDFs into their page subfolders (Variant B → A)"
|
| 177 |
+
)
|
| 178 |
+
ap.add_argument("--root", default=DEFAULT_ROOT)
|
| 179 |
+
ap.add_argument("--execute", action="store_true", help="Actually move files")
|
| 180 |
+
ap.add_argument("--undo", action="store_true", help="Reverse using page_move_log.json")
|
| 181 |
+
args = ap.parse_args()
|
| 182 |
+
|
| 183 |
+
root = Path(args.root)
|
| 184 |
+
log_path = root / LOG_FILENAME
|
| 185 |
+
|
| 186 |
+
if args.undo:
|
| 187 |
+
undo_moves(log_path)
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
moves = find_moves(root)
|
| 191 |
+
|
| 192 |
+
if not moves:
|
| 193 |
+
print("\n ✅ Nothing to do — all page PDFs are already in their subfolders.\n")
|
| 194 |
+
return
|
| 195 |
+
|
| 196 |
+
if not args.execute:
|
| 197 |
+
print(f"\n{'─'*60}")
|
| 198 |
+
print(f" DRY RUN — nothing will be moved")
|
| 199 |
+
print(f" Root: {root}")
|
| 200 |
+
print(f"{'─'*60}")
|
| 201 |
+
print_plan(moves)
|
| 202 |
+
print(" Run with --execute to apply.\n")
|
| 203 |
+
else:
|
| 204 |
+
print(f"\n Restructuring pages under {root} …\n")
|
| 205 |
+
execute_moves(moves, log_path)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
main()
|
pipeline/run_ocr.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
run_ocr.py
|
| 3 |
+
──────────
|
| 4 |
+
Batch OCR via Mistral with two modes:
|
| 5 |
+
|
| 6 |
+
--mode parallel (default)
|
| 7 |
+
Sends N concurrent requests using ThreadPoolExecutor.
|
| 8 |
+
Results land immediately. Use --workers to tune concurrency.
|
| 9 |
+
|
| 10 |
+
--mode batch
|
| 11 |
+
Builds a JSONL file, submits a Mistral batch job, polls until done,
|
| 12 |
+
then writes all .md files. ~50% cheaper, but async (takes minutes+).
|
| 13 |
+
|
| 14 |
+
Output always goes to the correct location:
|
| 15 |
+
<doc-folder>/page_XXXX/page_XXXX.md
|
| 16 |
+
<doc-folder>/page_XXXX/page_XXXX_img_0.png (if images present)
|
| 17 |
+
|
| 18 |
+
Safe to re-run — pages that already have .md are skipped.
|
| 19 |
+
|
| 20 |
+
Usage
|
| 21 |
+
-----
|
| 22 |
+
pip install mistralai
|
| 23 |
+
set MISTRAL_API_KEY=...
|
| 24 |
+
|
| 25 |
+
python run_ocr.py # parallel, all targets
|
| 26 |
+
python run_ocr.py --mode batch # batch API (cheaper, async)
|
| 27 |
+
python run_ocr.py --limit 20 # test on first 20
|
| 28 |
+
python run_ocr.py --workers 20 # more concurrency
|
| 29 |
+
python run_ocr.py --file path/to/page.pdf # single file
|
| 30 |
+
python run_ocr.py --mode batch --poll # submit + wait for results
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
import base64
|
| 34 |
+
import os
|
| 35 |
+
import re
|
| 36 |
+
import sys
|
| 37 |
+
import time
|
| 38 |
+
import json
|
| 39 |
+
import argparse
|
| 40 |
+
import tempfile
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
from datetime import datetime
|
| 43 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 44 |
+
|
| 45 |
+
DEFAULT_TARGETS = "D:/divided/ocr_targets.txt"
|
| 46 |
+
MODEL = "mistral-ocr-latest"
|
| 47 |
+
MAX_RETRIES = 3
|
| 48 |
+
RETRY_WAIT = 10
|
| 49 |
+
BATCH_POLL_SECS = 30 # how often to poll for batch completion
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
def encode_pdf(pdf_path: Path) -> str:
|
| 55 |
+
with open(pdf_path, "rb") as f:
|
| 56 |
+
return base64.b64encode(f.read()).decode("utf-8")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def save_images(page_dir: Path, page_name: str, images) -> dict:
|
| 60 |
+
saved = {}
|
| 61 |
+
for idx, img in enumerate(images):
|
| 62 |
+
data = getattr(img, "image_base64", None)
|
| 63 |
+
if not data:
|
| 64 |
+
continue
|
| 65 |
+
if "," in data:
|
| 66 |
+
data = data.split(",", 1)[1]
|
| 67 |
+
fname = f"{page_name}_img_{idx}.png"
|
| 68 |
+
(page_dir / fname).write_bytes(base64.b64decode(data))
|
| 69 |
+
saved[getattr(img, "id", str(idx))] = fname
|
| 70 |
+
return saved
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def write_md(pdf_path: Path, response) -> Path:
|
| 74 |
+
"""Write OCR response to the .md file next to the PDF. Returns md_path."""
|
| 75 |
+
page_dir = pdf_path.parent
|
| 76 |
+
page_name = pdf_path.stem
|
| 77 |
+
md_path = page_dir / f"{page_name}.md"
|
| 78 |
+
|
| 79 |
+
chunks = []
|
| 80 |
+
for page in response.pages:
|
| 81 |
+
text = page.markdown or ""
|
| 82 |
+
images = getattr(page, "images", []) or []
|
| 83 |
+
if images:
|
| 84 |
+
saved = save_images(page_dir, page_name, images)
|
| 85 |
+
for img_id, fname in saved.items():
|
| 86 |
+
text = text.replace(img_id, fname)
|
| 87 |
+
chunks.append(text)
|
| 88 |
+
|
| 89 |
+
md_path.write_text("\n\n".join(chunks).strip(), encoding="utf-8")
|
| 90 |
+
return md_path
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def load_targets(targets_file: Path, single: str | None, limit: int | None) -> list[Path]:
|
| 94 |
+
if single:
|
| 95 |
+
return [Path(single)]
|
| 96 |
+
if not targets_file.exists():
|
| 97 |
+
raise SystemExit(
|
| 98 |
+
f" ✗ Targets file not found: {targets_file}\n"
|
| 99 |
+
f" Run: python find_ocr_targets.py"
|
| 100 |
+
)
|
| 101 |
+
lines = targets_file.read_text(encoding="utf-8").splitlines()
|
| 102 |
+
paths = [
|
| 103 |
+
Path(l.strip()) for l in lines
|
| 104 |
+
if l.strip() and l.strip() != "NOT FOUND"
|
| 105 |
+
]
|
| 106 |
+
# Skip already-done
|
| 107 |
+
paths = [p for p in paths if not (p.parent / f"{p.stem}.md").exists()]
|
| 108 |
+
if limit:
|
| 109 |
+
paths = paths[:limit]
|
| 110 |
+
return paths
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ── parallel mode ─────────────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
def ocr_one(client, pdf_path: Path) -> tuple[Path, bool, str]:
|
| 116 |
+
"""OCR a single PDF. Returns (pdf_path, success, message)."""
|
| 117 |
+
for attempt in range(1, MAX_RETRIES + 1):
|
| 118 |
+
try:
|
| 119 |
+
b64 = encode_pdf(pdf_path)
|
| 120 |
+
response = client.ocr.process(
|
| 121 |
+
model=MODEL,
|
| 122 |
+
document={
|
| 123 |
+
"type": "document_url",
|
| 124 |
+
"document_url": f"data:application/pdf;base64,{b64}",
|
| 125 |
+
},
|
| 126 |
+
table_format=None,
|
| 127 |
+
include_image_base64=True,
|
| 128 |
+
)
|
| 129 |
+
md_path = write_md(pdf_path, response)
|
| 130 |
+
return pdf_path, True, f"{md_path.stat().st_size:,} bytes"
|
| 131 |
+
except Exception as e:
|
| 132 |
+
msg = str(e).lower()
|
| 133 |
+
if any(x in msg for x in ("rate", "429", "503", "502")) and attempt < MAX_RETRIES:
|
| 134 |
+
time.sleep(RETRY_WAIT * attempt)
|
| 135 |
+
continue
|
| 136 |
+
return pdf_path, False, str(e)
|
| 137 |
+
return pdf_path, False, "max retries exceeded"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def run_parallel(client, targets: list[Path], workers: int) -> tuple[int, int]:
|
| 141 |
+
ok = failed = 0
|
| 142 |
+
total = len(targets)
|
| 143 |
+
|
| 144 |
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
| 145 |
+
futures = {pool.submit(ocr_one, client, p): p for p in targets}
|
| 146 |
+
for i, future in enumerate(as_completed(futures), 1):
|
| 147 |
+
pdf_path, success, msg = future.result()
|
| 148 |
+
label = "✓" if success else "✗"
|
| 149 |
+
rel = "/".join(pdf_path.parts[-3:])
|
| 150 |
+
print(f" [{i:>4}/{total}] {label} {rel} {msg}")
|
| 151 |
+
if success:
|
| 152 |
+
ok += 1
|
| 153 |
+
else:
|
| 154 |
+
failed += 1
|
| 155 |
+
|
| 156 |
+
return ok, failed
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── batch mode ────────────────────────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
def build_jsonl(targets: list[Path]) -> str:
|
| 162 |
+
"""Build a JSONL string — one OCR request per line."""
|
| 163 |
+
lines = []
|
| 164 |
+
for pdf_path in targets:
|
| 165 |
+
b64 = encode_pdf(pdf_path)
|
| 166 |
+
record = {
|
| 167 |
+
"custom_id": str(pdf_path), # used to map results back to paths
|
| 168 |
+
"body": {
|
| 169 |
+
"model": MODEL,
|
| 170 |
+
"document": {
|
| 171 |
+
"type": "document_url",
|
| 172 |
+
"document_url": f"data:application/pdf;base64,{b64}",
|
| 173 |
+
},
|
| 174 |
+
"table_format": None,
|
| 175 |
+
"include_image_base64": True,
|
| 176 |
+
},
|
| 177 |
+
}
|
| 178 |
+
lines.append(json.dumps(record, ensure_ascii=False))
|
| 179 |
+
return "\n".join(lines)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def run_batch(client, targets: list[Path], poll: bool) -> tuple[int, int]:
|
| 183 |
+
total = len(targets)
|
| 184 |
+
print(f" Building JSONL for {total} files …")
|
| 185 |
+
jsonl_content = build_jsonl(targets)
|
| 186 |
+
|
| 187 |
+
# Upload JSONL file
|
| 188 |
+
print(" Uploading JSONL to Mistral Files API …")
|
| 189 |
+
with tempfile.NamedTemporaryFile(
|
| 190 |
+
mode="w", suffix=".jsonl", delete=False, encoding="utf-8"
|
| 191 |
+
) as tmp:
|
| 192 |
+
tmp.write(jsonl_content)
|
| 193 |
+
tmp_path = tmp.name
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
with open(tmp_path, "rb") as f:
|
| 197 |
+
uploaded = client.files.upload(
|
| 198 |
+
file={"file_name": "ocr_batch.jsonl", "content": f},
|
| 199 |
+
purpose="batch",
|
| 200 |
+
)
|
| 201 |
+
file_id = uploaded.id
|
| 202 |
+
print(f" Uploaded → file_id: {file_id}")
|
| 203 |
+
|
| 204 |
+
# Submit batch job
|
| 205 |
+
batch_job = client.batch.jobs.create(
|
| 206 |
+
input_files=[file_id],
|
| 207 |
+
model=MODEL,
|
| 208 |
+
endpoint="/v1/ocr",
|
| 209 |
+
metadata={"description": "UAP archive OCR batch"},
|
| 210 |
+
)
|
| 211 |
+
job_id = batch_job.id
|
| 212 |
+
print(f" Batch job submitted → job_id: {job_id}")
|
| 213 |
+
|
| 214 |
+
# Save job ID for later retrieval if not polling
|
| 215 |
+
job_log = Path(DEFAULT_TARGETS).parent / "ocr_batch_jobs.json"
|
| 216 |
+
jobs = []
|
| 217 |
+
if job_log.exists():
|
| 218 |
+
try:
|
| 219 |
+
jobs = json.loads(job_log.read_text(encoding="utf-8"))
|
| 220 |
+
except Exception:
|
| 221 |
+
pass
|
| 222 |
+
jobs.append({
|
| 223 |
+
"job_id": job_id,
|
| 224 |
+
"file_id": file_id,
|
| 225 |
+
"submitted": datetime.now().isoformat(timespec="seconds"),
|
| 226 |
+
"files": [str(p) for p in targets],
|
| 227 |
+
})
|
| 228 |
+
job_log.write_text(json.dumps(jobs, indent=2), encoding="utf-8")
|
| 229 |
+
print(f" Job info saved → {job_log}")
|
| 230 |
+
|
| 231 |
+
if not poll:
|
| 232 |
+
print(
|
| 233 |
+
f"\n Job running asynchronously. To fetch results later:\n"
|
| 234 |
+
f" python run_ocr.py --mode batch-fetch --job-id {job_id}\n"
|
| 235 |
+
)
|
| 236 |
+
return 0, 0
|
| 237 |
+
|
| 238 |
+
# Poll for completion
|
| 239 |
+
print("\n Polling for completion …")
|
| 240 |
+
while True:
|
| 241 |
+
status = client.batch.jobs.get(job_id=job_id)
|
| 242 |
+
pct = ""
|
| 243 |
+
if hasattr(status, "request_counts") and status.request_counts:
|
| 244 |
+
rc = status.request_counts
|
| 245 |
+
done = getattr(rc, "completed", 0) + getattr(rc, "failed", 0)
|
| 246 |
+
pct = f" ({done}/{total})"
|
| 247 |
+
print(f" status: {status.status}{pct}", end="\r")
|
| 248 |
+
if status.status in ("SUCCESS", "FAILED", "TIMEOUT_EXCEEDED", "EXPIRED"):
|
| 249 |
+
print()
|
| 250 |
+
break
|
| 251 |
+
time.sleep(BATCH_POLL_SECS)
|
| 252 |
+
|
| 253 |
+
if status.status != "SUCCESS":
|
| 254 |
+
print(f" ✗ Batch ended with status: {status.status}")
|
| 255 |
+
return 0, total
|
| 256 |
+
|
| 257 |
+
return fetch_batch_results(client, job_id, targets)
|
| 258 |
+
|
| 259 |
+
finally:
|
| 260 |
+
os.unlink(tmp_path)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def fetch_batch_results(client, job_id: str, targets: list[Path] | None = None) -> tuple[int, int]:
|
| 264 |
+
"""Download results for a completed batch job and write .md files."""
|
| 265 |
+
# Build path lookup from job log if targets not provided
|
| 266 |
+
path_map: dict[str, Path] = {}
|
| 267 |
+
if targets:
|
| 268 |
+
path_map = {str(p): p for p in targets}
|
| 269 |
+
else:
|
| 270 |
+
job_log = Path(DEFAULT_TARGETS).parent / "ocr_batch_jobs.json"
|
| 271 |
+
if job_log.exists():
|
| 272 |
+
jobs = json.loads(job_log.read_text(encoding="utf-8"))
|
| 273 |
+
for job in jobs:
|
| 274 |
+
if job["job_id"] == job_id:
|
| 275 |
+
path_map = {p: Path(p) for p in job.get("files", [])}
|
| 276 |
+
break
|
| 277 |
+
|
| 278 |
+
job = client.batch.jobs.get(job_id=job_id)
|
| 279 |
+
out_id = job.output_file
|
| 280 |
+
|
| 281 |
+
print(f" Downloading results (output file: {out_id}) …")
|
| 282 |
+
result_bytes = client.files.download(file_id=out_id)
|
| 283 |
+
results_text = result_bytes.read().decode("utf-8")
|
| 284 |
+
|
| 285 |
+
ok = failed = 0
|
| 286 |
+
for line in results_text.splitlines():
|
| 287 |
+
if not line.strip():
|
| 288 |
+
continue
|
| 289 |
+
record = json.loads(line)
|
| 290 |
+
custom_id = record.get("custom_id", "")
|
| 291 |
+
pdf_path = path_map.get(custom_id)
|
| 292 |
+
|
| 293 |
+
if record.get("error"):
|
| 294 |
+
print(f" ✗ {custom_id}: {record['error']}")
|
| 295 |
+
failed += 1
|
| 296 |
+
continue
|
| 297 |
+
|
| 298 |
+
if pdf_path is None:
|
| 299 |
+
print(f" ⚠ Unknown custom_id: {custom_id}")
|
| 300 |
+
continue
|
| 301 |
+
|
| 302 |
+
# Reconstruct a response-like object from the JSON result
|
| 303 |
+
body = record.get("response", {}).get("body", {})
|
| 304 |
+
pages_data = body.get("pages", [])
|
| 305 |
+
|
| 306 |
+
page_dir = pdf_path.parent
|
| 307 |
+
page_name = pdf_path.stem
|
| 308 |
+
md_path = page_dir / f"{page_name}.md"
|
| 309 |
+
|
| 310 |
+
chunks = []
|
| 311 |
+
for page in pages_data:
|
| 312 |
+
text = page.get("markdown", "") or ""
|
| 313 |
+
images = page.get("images", []) or []
|
| 314 |
+
for idx, img in enumerate(images):
|
| 315 |
+
data = img.get("image_base64", "")
|
| 316 |
+
if data:
|
| 317 |
+
if "," in data:
|
| 318 |
+
data = data.split(",", 1)[1]
|
| 319 |
+
fname = f"{page_name}_img_{idx}.png"
|
| 320 |
+
(page_dir / fname).write_bytes(base64.b64decode(data))
|
| 321 |
+
text = text.replace(img.get("id", str(idx)), fname)
|
| 322 |
+
chunks.append(text)
|
| 323 |
+
|
| 324 |
+
md_path.write_text("\n\n".join(chunks).strip(), encoding="utf-8")
|
| 325 |
+
print(f" ✓ {page_dir.name}/{md_path.name} ({md_path.stat().st_size:,} bytes)")
|
| 326 |
+
ok += 1
|
| 327 |
+
|
| 328 |
+
return ok, failed
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
| 332 |
+
|
| 333 |
+
def main():
|
| 334 |
+
ap = argparse.ArgumentParser(description="OCR page PDFs via Mistral")
|
| 335 |
+
ap.add_argument("--mode", default="parallel",
|
| 336 |
+
choices=["parallel", "batch", "batch-fetch"],
|
| 337 |
+
help="parallel=immediate, batch=async submit, batch-fetch=fetch existing job")
|
| 338 |
+
ap.add_argument("--targets", default=DEFAULT_TARGETS)
|
| 339 |
+
ap.add_argument("--file", default=None, help="Single PDF path")
|
| 340 |
+
ap.add_argument("--limit", type=int, help="Max files to process")
|
| 341 |
+
ap.add_argument("--workers", type=int, default=10,
|
| 342 |
+
help="Concurrent workers for parallel mode (default 10)")
|
| 343 |
+
ap.add_argument("--poll", action="store_true",
|
| 344 |
+
help="In batch mode: wait and download results when done")
|
| 345 |
+
ap.add_argument("--job-id", default=None,
|
| 346 |
+
help="In batch-fetch mode: the job_id to retrieve")
|
| 347 |
+
args = ap.parse_args()
|
| 348 |
+
|
| 349 |
+
# Strip stray whitespace and surrounding quotes — a common cause of 401s
|
| 350 |
+
# (e.g. Windows set MISTRAL_API_KEY="sk-..." keeps the quotes in the value).
|
| 351 |
+
api_key = (os.environ.get("MISTRAL_API_KEY") or "").strip().strip('"').strip("'").strip()
|
| 352 |
+
if not api_key:
|
| 353 |
+
raise SystemExit(" ✗ MISTRAL_API_KEY not set.")
|
| 354 |
+
|
| 355 |
+
from mistralai import Mistral
|
| 356 |
+
client = Mistral(api_key=api_key)
|
| 357 |
+
|
| 358 |
+
# Preflight — verify the key now, so a bad key fails immediately with a clear
|
| 359 |
+
# message instead of after every page returns 401.
|
| 360 |
+
try:
|
| 361 |
+
client.models.list()
|
| 362 |
+
except Exception as exc:
|
| 363 |
+
if "401" in str(exc) or "unauthor" in str(exc).lower():
|
| 364 |
+
raise SystemExit(
|
| 365 |
+
" ✗ MISTRAL_API_KEY rejected by Mistral — 401 Unauthorized.\n"
|
| 366 |
+
" The key is set but not valid. Check that:\n"
|
| 367 |
+
" • it is a current key from https://console.mistral.ai/api-keys\n"
|
| 368 |
+
" • it was set WITHOUT surrounding quotes or stray whitespace\n"
|
| 369 |
+
" (Windows: set MISTRAL_API_KEY=sk-... — no quotes)\n"
|
| 370 |
+
f" • the key in use is {len(api_key)} characters long\n"
|
| 371 |
+
)
|
| 372 |
+
print(f" ⚠ Could not pre-verify the API key ({exc}) — continuing anyway.")
|
| 373 |
+
|
| 374 |
+
# batch-fetch mode — retrieve a previously submitted job
|
| 375 |
+
if args.mode == "batch-fetch":
|
| 376 |
+
if not args.job_id:
|
| 377 |
+
raise SystemExit(" ✗ --job-id required for batch-fetch mode.")
|
| 378 |
+
ok, failed = fetch_batch_results(client, args.job_id)
|
| 379 |
+
print(f"\n Done. ✓ {ok} ✗ {failed}\n")
|
| 380 |
+
return
|
| 381 |
+
|
| 382 |
+
targets = load_targets(Path(args.targets), args.file, args.limit)
|
| 383 |
+
if not targets:
|
| 384 |
+
print(" ✅ Nothing to do — all targets already have .md files.")
|
| 385 |
+
return
|
| 386 |
+
|
| 387 |
+
print(f"\n Mode : {args.mode}")
|
| 388 |
+
print(f" Targets : {len(targets)} pages")
|
| 389 |
+
if args.mode == "parallel":
|
| 390 |
+
print(f" Workers : {args.workers}")
|
| 391 |
+
print()
|
| 392 |
+
|
| 393 |
+
start = time.time()
|
| 394 |
+
|
| 395 |
+
if args.mode == "parallel":
|
| 396 |
+
ok, failed = run_parallel(client, targets, args.workers)
|
| 397 |
+
else:
|
| 398 |
+
ok, failed = run_batch(client, targets, poll=args.poll)
|
| 399 |
+
|
| 400 |
+
elapsed = time.time() - start
|
| 401 |
+
print(f"\n{'─'*60}")
|
| 402 |
+
print(f" Done in {elapsed:.1f}s ✓ {ok} ✗ {failed}")
|
| 403 |
+
print(f"{'─'*60}\n")
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
if __name__ == "__main__":
|
| 407 |
+
main()
|
pipeline/split_pages.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
split_pages.py
|
| 3 |
+
──────────────
|
| 4 |
+
Splits every multi-page PDF in a folder into single-page PDFs, creating the
|
| 5 |
+
standard archive structure:
|
| 6 |
+
|
| 7 |
+
<out>/<doc-slug>/
|
| 8 |
+
page_0001/
|
| 9 |
+
page_0001.pdf
|
| 10 |
+
page_0002/
|
| 11 |
+
page_0002.pdf
|
| 12 |
+
...
|
| 13 |
+
|
| 14 |
+
By default the split folders are created *inside* the source folder, so:
|
| 15 |
+
|
| 16 |
+
FBI/reports/ufo1.pdf → FBI/reports/ufo1/page_0001/page_0001.pdf
|
| 17 |
+
|
| 18 |
+
Safe to re-run — existing page folders are skipped unless --force is used.
|
| 19 |
+
|
| 20 |
+
Install dependency
|
| 21 |
+
------------------
|
| 22 |
+
pip install pypdf
|
| 23 |
+
|
| 24 |
+
Usage
|
| 25 |
+
-----
|
| 26 |
+
python split_pages.py --src D:/divided/FBI/reports
|
| 27 |
+
python split_pages.py --src D:/divided/FBI/reports --out D:/divided/FBI/reports
|
| 28 |
+
python split_pages.py --src D:/divided/FBI/reports --force # re-split existing
|
| 29 |
+
python split_pages.py --file D:/divided/FBI/reports/ufo1.pdf # single file
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import argparse
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
def split_pdf(pdf_path: Path, out_root: Path, force: bool = False) -> tuple[int, int]:
|
| 36 |
+
"""
|
| 37 |
+
Split a single PDF into per-page PDFs.
|
| 38 |
+
Returns (pages_written, pages_skipped).
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
from pypdf import PdfReader, PdfWriter
|
| 42 |
+
except ImportError:
|
| 43 |
+
raise SystemExit(" ✗ pypdf not installed. Run: pip install pypdf")
|
| 44 |
+
|
| 45 |
+
doc_slug = pdf_path.stem # e.g. "ufo1"
|
| 46 |
+
doc_dir = out_root / doc_slug
|
| 47 |
+
|
| 48 |
+
reader = PdfReader(str(pdf_path))
|
| 49 |
+
n = len(reader.pages)
|
| 50 |
+
|
| 51 |
+
written = skipped = 0
|
| 52 |
+
|
| 53 |
+
for i, page in enumerate(reader.pages, start=1):
|
| 54 |
+
page_name = f"page_{i:04d}"
|
| 55 |
+
page_dir = doc_dir / page_name
|
| 56 |
+
page_file = page_dir / f"{page_name}.pdf"
|
| 57 |
+
|
| 58 |
+
if page_file.exists() and not force:
|
| 59 |
+
skipped += 1
|
| 60 |
+
continue
|
| 61 |
+
|
| 62 |
+
page_dir.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
|
| 64 |
+
writer = PdfWriter()
|
| 65 |
+
writer.add_page(page)
|
| 66 |
+
with open(page_file, "wb") as f:
|
| 67 |
+
writer.write(f)
|
| 68 |
+
|
| 69 |
+
written += 1
|
| 70 |
+
|
| 71 |
+
return written, skipped
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def main():
|
| 75 |
+
ap = argparse.ArgumentParser(description="Split multi-page PDFs into per-page archive structure")
|
| 76 |
+
ap.add_argument("--src", default=None,
|
| 77 |
+
help="Folder containing PDFs to split")
|
| 78 |
+
ap.add_argument("--out", default=None,
|
| 79 |
+
help="Output root (default: same as --src)")
|
| 80 |
+
ap.add_argument("--file", default=None,
|
| 81 |
+
help="Split a single PDF instead of a whole folder")
|
| 82 |
+
ap.add_argument("--force", action="store_true",
|
| 83 |
+
help="Re-split even if page folders already exist")
|
| 84 |
+
args = ap.parse_args()
|
| 85 |
+
|
| 86 |
+
if not args.src and not args.file:
|
| 87 |
+
raise SystemExit(" ✗ Provide --src <folder> or --file <pdf>")
|
| 88 |
+
|
| 89 |
+
if args.file:
|
| 90 |
+
pdf_path = Path(args.file)
|
| 91 |
+
if not pdf_path.exists():
|
| 92 |
+
raise SystemExit(f" ✗ File not found: {pdf_path}")
|
| 93 |
+
out_root = Path(args.out) if args.out else pdf_path.parent
|
| 94 |
+
print(f"\n Splitting {pdf_path.name} → {out_root / pdf_path.stem}/\n")
|
| 95 |
+
w, s = split_pdf(pdf_path, out_root, args.force)
|
| 96 |
+
print(f" ✓ {w} pages written, {s} skipped\n")
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
src = Path(args.src)
|
| 100 |
+
if not src.exists():
|
| 101 |
+
raise SystemExit(f" ✗ Source not found: {src}")
|
| 102 |
+
|
| 103 |
+
out_root = Path(args.out) if args.out else src
|
| 104 |
+
pdfs = sorted(src.glob("*.pdf"))
|
| 105 |
+
|
| 106 |
+
if not pdfs:
|
| 107 |
+
print(f" No PDFs found in {src}")
|
| 108 |
+
return
|
| 109 |
+
|
| 110 |
+
print(f"\n Source : {src}")
|
| 111 |
+
print(f" Output : {out_root}")
|
| 112 |
+
print(f" PDFs : {len(pdfs)}\n")
|
| 113 |
+
|
| 114 |
+
total_written = total_skipped = total_errors = 0
|
| 115 |
+
|
| 116 |
+
for pdf_path in pdfs:
|
| 117 |
+
try:
|
| 118 |
+
w, s = split_pdf(pdf_path, out_root, args.force)
|
| 119 |
+
from pypdf import PdfReader
|
| 120 |
+
n = len(PdfReader(str(pdf_path)).pages)
|
| 121 |
+
label = "✓" if w > 0 else "–"
|
| 122 |
+
print(f" {label} {pdf_path.name:<30} {n:>4} pages ({w} written, {s} skipped)")
|
| 123 |
+
total_written += w
|
| 124 |
+
total_skipped += s
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
print(f" ✗ {pdf_path.name} ERROR: {exc}")
|
| 127 |
+
total_errors += 1
|
| 128 |
+
|
| 129 |
+
print(f"\n {'─'*50}")
|
| 130 |
+
print(f" Written : {total_written}")
|
| 131 |
+
print(f" Skipped : {total_skipped}")
|
| 132 |
+
print(f" Errors : {total_errors}")
|
| 133 |
+
print(f" {'─'*50}\n")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
main()
|
pipeline/stamp_pages.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
stamp_pages.py
|
| 3 |
+
──────────────
|
| 4 |
+
Creates enriched copies of every page_XXXX.md, with a minimal YAML frontmatter
|
| 5 |
+
block prepended. The originals in raw/ (or D:/divided) are never modified.
|
| 6 |
+
|
| 7 |
+
Output mirrors the source folder hierarchy under --out-dir (default: pages_out/).
|
| 8 |
+
|
| 9 |
+
Example
|
| 10 |
+
-------
|
| 11 |
+
Source: raw/DOD/mission-reports/greece/dow-uap-d33-.../page_0001/page_0001.md
|
| 12 |
+
Output: pages_out/DOD/mission-reports/greece/dow-uap-d33-.../page_0001/page_0001.md
|
| 13 |
+
|
| 14 |
+
Frontmatter prepended to each copy:
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
document: dow-uap-d33-mission-report-greece-october-2023
|
| 18 |
+
page: 1
|
| 19 |
+
of: 5
|
| 20 |
+
agency: DOD
|
| 21 |
+
subtype: mission-reports
|
| 22 |
+
region: greece
|
| 23 |
+
src_path: raw/DOD/mission-reports/greece/dow-uap-d33-.../page_0001/page_0001.md
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
Usage
|
| 27 |
+
-----
|
| 28 |
+
python stamp_pages.py # uses raw/ as source
|
| 29 |
+
python stamp_pages.py --src D:/divided # pre-reorganize flat layout
|
| 30 |
+
python stamp_pages.py --src raw --out pages_out
|
| 31 |
+
python stamp_pages.py --src raw --out pages_out --force # overwrite existing copies
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
import argparse
|
| 35 |
+
import re
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from collections import defaultdict
|
| 38 |
+
|
| 39 |
+
DEFAULT_SRC = "raw"
|
| 40 |
+
DEFAULT_OUT = "pages_out"
|
| 41 |
+
|
| 42 |
+
# Depth hints for extracting agency / subtype / region from the path
|
| 43 |
+
# Works for both the organised raw/ tree AND the flat D:/divided layout.
|
| 44 |
+
AGENCY_NAMES = {"DOD", "FBI", "NASA", "DOS", "NARA-CIA", "MISC"}
|
| 45 |
+
|
| 46 |
+
PAGE_MD_RE = re.compile(r"^page_(\d+)\.md$", re.IGNORECASE)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── path parsing ──────────────────────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
def parse_path_context(doc_dir: Path, src_root: Path) -> dict:
|
| 52 |
+
"""
|
| 53 |
+
Extract agency / subtype / region from the path relative to src_root.
|
| 54 |
+
|
| 55 |
+
Organised tree: src_root/DOD/mission-reports/greece/<doc-slug>/
|
| 56 |
+
Flat layout: src_root/<doc-slug>/
|
| 57 |
+
|
| 58 |
+
Returns a dict with keys: agency, subtype, region (all may be None).
|
| 59 |
+
"""
|
| 60 |
+
try:
|
| 61 |
+
rel_parts = doc_dir.relative_to(src_root).parts
|
| 62 |
+
except ValueError:
|
| 63 |
+
rel_parts = (doc_dir.name,)
|
| 64 |
+
|
| 65 |
+
agency = None
|
| 66 |
+
subtype = None
|
| 67 |
+
region = None
|
| 68 |
+
|
| 69 |
+
if len(rel_parts) >= 4:
|
| 70 |
+
# organised: agency / subtype / region / doc-slug
|
| 71 |
+
agency = rel_parts[0] if rel_parts[0] in AGENCY_NAMES else None
|
| 72 |
+
subtype = rel_parts[1]
|
| 73 |
+
region = rel_parts[2]
|
| 74 |
+
elif len(rel_parts) >= 2:
|
| 75 |
+
agency = rel_parts[0] if rel_parts[0] in AGENCY_NAMES else None
|
| 76 |
+
|
| 77 |
+
return {"agency": agency, "subtype": subtype, "region": region}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ── discovery ─────────────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
def collect_documents(src_root: Path) -> list[dict]:
|
| 83 |
+
"""
|
| 84 |
+
Walk src_root and group page .md files by document folder.
|
| 85 |
+
Returns a list of dicts, one per document.
|
| 86 |
+
"""
|
| 87 |
+
# Map: doc_dir_path → [page paths sorted]
|
| 88 |
+
doc_pages: dict[Path, list[Path]] = defaultdict(list)
|
| 89 |
+
|
| 90 |
+
for md_file in src_root.rglob("page_*.md"):
|
| 91 |
+
# The document folder is the grandparent: doc_dir/page_XXXX/page_XXXX.md
|
| 92 |
+
if md_file.parent.parent == src_root:
|
| 93 |
+
# flat layout: src_root/doc_dir/page_XXXX/page_XXXX.md
|
| 94 |
+
doc_dir = md_file.parent.parent / md_file.parent.name
|
| 95 |
+
# Actually: md_file.parent IS the page subfolder,
|
| 96 |
+
# its parent IS the doc dir
|
| 97 |
+
doc_dir = md_file.parent.parent
|
| 98 |
+
if doc_dir == src_root:
|
| 99 |
+
continue # skip .md files sitting directly in root
|
| 100 |
+
doc_pages[doc_dir].append(md_file)
|
| 101 |
+
|
| 102 |
+
documents = []
|
| 103 |
+
for doc_dir, pages in sorted(doc_pages.items()):
|
| 104 |
+
pages_sorted = sorted(pages, key=lambda p: p.name)
|
| 105 |
+
ctx = parse_path_context(doc_dir, src_root)
|
| 106 |
+
documents.append({
|
| 107 |
+
"doc_dir": doc_dir,
|
| 108 |
+
"doc_slug": doc_dir.name,
|
| 109 |
+
"page_count": len(pages_sorted),
|
| 110 |
+
"pages": pages_sorted,
|
| 111 |
+
**ctx,
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
return documents
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ── frontmatter builder ───────────────────────────────────────────────────────
|
| 118 |
+
|
| 119 |
+
def build_frontmatter(doc: dict, md_file: Path, page_index: int, src_root: Path) -> str:
|
| 120 |
+
try:
|
| 121 |
+
src_path = md_file.relative_to(src_root).as_posix()
|
| 122 |
+
except ValueError:
|
| 123 |
+
src_path = md_file.as_posix()
|
| 124 |
+
|
| 125 |
+
def _yaml_str(v) -> str:
|
| 126 |
+
return f'"{v}"' if v is not None else "null"
|
| 127 |
+
|
| 128 |
+
lines = [
|
| 129 |
+
"---",
|
| 130 |
+
f"document: {doc['doc_slug']}",
|
| 131 |
+
f"page: {page_index}",
|
| 132 |
+
f"of: {doc['page_count']}",
|
| 133 |
+
f"agency: {_yaml_str(doc['agency'])}",
|
| 134 |
+
f"subtype: {_yaml_str(doc['subtype'])}",
|
| 135 |
+
f"region: {_yaml_str(doc['region'])}",
|
| 136 |
+
f"src_path: {src_path}",
|
| 137 |
+
"---",
|
| 138 |
+
"",
|
| 139 |
+
]
|
| 140 |
+
return "\n".join(lines)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ── stamping ──────────────────────────────────────────────────────────────────
|
| 144 |
+
|
| 145 |
+
def stamp_documents(documents: list[dict], src_root: Path, out_root: Path,
|
| 146 |
+
force: bool = False) -> tuple[int, int, int]:
|
| 147 |
+
written = skipped = errors = 0
|
| 148 |
+
|
| 149 |
+
for doc in documents:
|
| 150 |
+
for idx, md_file in enumerate(doc["pages"], start=1):
|
| 151 |
+
try:
|
| 152 |
+
rel = md_file.relative_to(src_root)
|
| 153 |
+
except ValueError:
|
| 154 |
+
rel = Path(doc["doc_slug"]) / md_file.parent.name / md_file.name
|
| 155 |
+
|
| 156 |
+
dst = out_root / rel
|
| 157 |
+
|
| 158 |
+
if dst.exists() and not force:
|
| 159 |
+
skipped += 1
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
original = md_file.read_text(encoding="utf-8", errors="replace")
|
| 164 |
+
frontmatter = build_frontmatter(doc, md_file, idx, src_root)
|
| 165 |
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
dst.write_text(frontmatter + original, encoding="utf-8")
|
| 167 |
+
written += 1
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
print(f" ✗ {md_file} ERROR: {exc}")
|
| 170 |
+
errors += 1
|
| 171 |
+
|
| 172 |
+
return written, skipped, errors
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
| 176 |
+
|
| 177 |
+
def main():
|
| 178 |
+
ap = argparse.ArgumentParser(
|
| 179 |
+
description="Copy page .md files with YAML frontmatter into pages_out/"
|
| 180 |
+
)
|
| 181 |
+
ap.add_argument("--src", default=DEFAULT_SRC,
|
| 182 |
+
help="Source root (raw/ after reorganize, or D:/divided before)")
|
| 183 |
+
ap.add_argument("--out", default=DEFAULT_OUT,
|
| 184 |
+
help="Output root for stamped copies (default: pages_out/)")
|
| 185 |
+
ap.add_argument("--force", action="store_true",
|
| 186 |
+
help="Overwrite existing output files")
|
| 187 |
+
args = ap.parse_args()
|
| 188 |
+
|
| 189 |
+
src_root = Path(args.src)
|
| 190 |
+
out_root = Path(args.out)
|
| 191 |
+
|
| 192 |
+
if not src_root.exists():
|
| 193 |
+
print(f" ✗ Source not found: {src_root}")
|
| 194 |
+
return
|
| 195 |
+
|
| 196 |
+
print(f"\n Scanning {src_root} …")
|
| 197 |
+
documents = collect_documents(src_root)
|
| 198 |
+
|
| 199 |
+
total_pages = sum(d["page_count"] for d in documents)
|
| 200 |
+
print(f" Found {len(documents)} documents, {total_pages} pages total")
|
| 201 |
+
print(f" Output → {out_root}/")
|
| 202 |
+
if not args.force:
|
| 203 |
+
print(f" (existing files will be skipped — use --force to overwrite)\n")
|
| 204 |
+
|
| 205 |
+
written, skipped, errors = stamp_documents(documents, src_root, out_root, args.force)
|
| 206 |
+
|
| 207 |
+
print(f"\n ✅ {written} written, {skipped} skipped, {errors} errors\n")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
if __name__ == "__main__":
|
| 211 |
+
main()
|
preprocessing.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
Document Preprocessing Pipeline — Streamlit page.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
|
| 9 |
The end product (extracted reports / parsed_reports.xlsx) is what the
|
| 10 |
"UAP Feature Extraction" page consumes — this page is the stage that produces
|
|
@@ -12,6 +14,7 @@ that table from raw government PDFs.
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import os
|
|
|
|
| 15 |
import shlex
|
| 16 |
import subprocess
|
| 17 |
from pathlib import Path
|
|
@@ -48,20 +51,26 @@ def _secret(*names):
|
|
| 48 |
return ""
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
# ── Configuration ──────────────────────────────────────────────────────────
|
| 52 |
with st.expander("⚙️ Configuration", expanded=True):
|
| 53 |
-
|
| 54 |
-
"
|
| 55 |
-
value=st.session_state.get("
|
| 56 |
-
key="
|
| 57 |
-
help="
|
| 58 |
-
"
|
|
|
|
| 59 |
)
|
| 60 |
py_exe = st.text_input(
|
| 61 |
-
"Python executable", value=st.session_state.get("prep_py",
|
| 62 |
key="prep_py",
|
| 63 |
-
help="
|
| 64 |
-
"
|
| 65 |
)
|
| 66 |
k1, k2, k3 = st.columns(3)
|
| 67 |
mistral_key = k1.text_input(
|
|
@@ -77,27 +86,40 @@ with st.expander("⚙️ Configuration", expanded=True):
|
|
| 77 |
help="Optional — used by pdf_to_reports.py (NVIDIA NIM extraction).",
|
| 78 |
)
|
| 79 |
|
| 80 |
-
|
|
|
|
| 81 |
st.code(
|
| 82 |
f"{py_exe or 'python'} -m pip install pyyaml google-genai pypdf "
|
| 83 |
-
"mistralai reportlab pdf2image pillow pymupdf",
|
| 84 |
language="bash",
|
| 85 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
st.
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
if len(_present) == len(PIPELINE_SCRIPTS):
|
| 94 |
-
st.success(f"All {len(PIPELINE_SCRIPTS)} pipeline scripts found in `{pipeline_dir}`.")
|
| 95 |
-
else:
|
| 96 |
-
_missing = [s for s in PIPELINE_SCRIPTS if s not in _present]
|
| 97 |
-
st.warning(
|
| 98 |
-
f"{len(_present)}/{len(PIPELINE_SCRIPTS)} pipeline scripts found. "
|
| 99 |
-
f"Missing: {', '.join(_missing)}"
|
| 100 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
with st.expander("📋 Recommended sequence", expanded=False):
|
| 103 |
st.markdown(
|
|
@@ -133,6 +155,15 @@ def run_command(cmd_str: str, key: str) -> None:
|
|
| 133 |
# Route a leading `python` / `python3` to the configured interpreter.
|
| 134 |
if parts and parts[0] in ("python", "python3") and py_exe:
|
| 135 |
parts[0] = py_exe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
env = os.environ.copy()
|
| 138 |
if mistral_key:
|
|
@@ -147,7 +178,7 @@ def run_command(cmd_str: str, key: str) -> None:
|
|
| 147 |
out_box = st.empty()
|
| 148 |
try:
|
| 149 |
proc = subprocess.Popen(
|
| 150 |
-
parts, cwd=str(
|
| 151 |
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
| 152 |
text=True, bufsize=1,
|
| 153 |
)
|
|
@@ -185,7 +216,9 @@ def step(key: str, label: str, desc: str, default_cmd: str) -> None:
|
|
| 185 |
if run:
|
| 186 |
run_command(cmd, key)
|
| 187 |
elif st.session_state.get(f"prep_log_{key}"):
|
| 188 |
-
|
|
|
|
|
|
|
| 189 |
st.code(st.session_state[f"prep_log_{key}"][-8000:], language="text")
|
| 190 |
st.divider()
|
| 191 |
|
|
@@ -323,7 +356,7 @@ with st.expander("🔍 Audit tools", expanded=False):
|
|
| 323 |
)
|
| 324 |
|
| 325 |
st.caption(
|
| 326 |
-
"Each step
|
| 327 |
-
"
|
| 328 |
-
"until they finish; output streams live above."
|
| 329 |
)
|
|
|
|
| 1 |
"""
|
| 2 |
Document Preprocessing Pipeline — Streamlit page.
|
| 3 |
|
| 4 |
+
Self-contained: the preprocessing scripts (scrape → layout → OCR → concat →
|
| 5 |
+
report extraction → table creation) are vendored in-repo under ``pipeline/`` and
|
| 6 |
+
run with the app's own interpreter, so this page no longer depends on an
|
| 7 |
+
external script directory (the old ``D:/divided``) or a separate Python env.
|
| 8 |
+
Only a *working directory* (where the PDFs and intermediate outputs live) is
|
| 9 |
+
configurable. Each step is run as a subprocess with its output streamed live.
|
| 10 |
|
| 11 |
The end product (extracted reports / parsed_reports.xlsx) is what the
|
| 12 |
"UAP Feature Extraction" page consumes — this page is the stage that produces
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
import os
|
| 17 |
+
import sys
|
| 18 |
import shlex
|
| 19 |
import subprocess
|
| 20 |
from pathlib import Path
|
|
|
|
| 51 |
return ""
|
| 52 |
|
| 53 |
|
| 54 |
+
# Pipeline scripts are vendored beside this page, so there is no external
|
| 55 |
+
# script-directory dependency anymore.
|
| 56 |
+
SCRIPTS_DIR = Path(__file__).resolve().parent / "pipeline"
|
| 57 |
+
DEFAULT_WORKDIR = Path(__file__).resolve().parent / "pipeline_data"
|
| 58 |
+
|
| 59 |
# ── Configuration ──────────────────────────────────────────────────────────
|
| 60 |
with st.expander("⚙️ Configuration", expanded=True):
|
| 61 |
+
work_dir = st.text_input(
|
| 62 |
+
"Working directory (where the PDFs and intermediate outputs live)",
|
| 63 |
+
value=st.session_state.get("prep_workdir", str(DEFAULT_WORKDIR)),
|
| 64 |
+
key="prep_workdir",
|
| 65 |
+
help="Every step runs with this as its working directory — the scripts "
|
| 66 |
+
"read/write raw/, pages_out/, concat/, extracted/, … here. The "
|
| 67 |
+
"pipeline *scripts* themselves now ship with the app in pipeline/.",
|
| 68 |
)
|
| 69 |
py_exe = st.text_input(
|
| 70 |
+
"Python executable", value=st.session_state.get("prep_py", sys.executable),
|
| 71 |
key="prep_py",
|
| 72 |
+
help="Defaults to the app's own interpreter. Override only if the "
|
| 73 |
+
"pipeline dependencies are installed in a different environment.",
|
| 74 |
)
|
| 75 |
k1, k2, k3 = st.columns(3)
|
| 76 |
mistral_key = k1.text_input(
|
|
|
|
| 86 |
help="Optional — used by pdf_to_reports.py (NVIDIA NIM extraction).",
|
| 87 |
)
|
| 88 |
|
| 89 |
+
# Popover (not a nested expander — Streamlit forbids expander-in-expander).
|
| 90 |
+
with st.popover("Pipeline dependencies"):
|
| 91 |
st.code(
|
| 92 |
f"{py_exe or 'python'} -m pip install pyyaml google-genai pypdf "
|
| 93 |
+
"mistralai openai pdfplumber reportlab pdf2image pillow pymupdf requests",
|
| 94 |
language="bash",
|
| 95 |
)
|
| 96 |
+
st.caption(
|
| 97 |
+
"`pdf2image` also needs the system **poppler** binary "
|
| 98 |
+
"(`apt-get install poppler-utils`). These deps are also pinned in "
|
| 99 |
+
"the project's requirements, so the app's own interpreter has them."
|
| 100 |
+
)
|
| 101 |
|
| 102 |
+
# Sanity check on the vendored scripts (should always pass).
|
| 103 |
+
_missing = [s for s in PIPELINE_SCRIPTS if not (SCRIPTS_DIR / s).is_file()]
|
| 104 |
+
if _missing:
|
| 105 |
+
st.error(
|
| 106 |
+
f"{len(PIPELINE_SCRIPTS) - len(_missing)}/{len(PIPELINE_SCRIPTS)} "
|
| 107 |
+
f"bundled scripts found in `pipeline/`. Missing: {', '.join(_missing)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
)
|
| 109 |
+
else:
|
| 110 |
+
st.success(f"All {len(PIPELINE_SCRIPTS)} pipeline scripts bundled in `pipeline/`.")
|
| 111 |
+
|
| 112 |
+
# Resolve (and create) the working directory — no external folder required.
|
| 113 |
+
_wdir = Path(work_dir).expanduser() if work_dir else None
|
| 114 |
+
if _wdir is None:
|
| 115 |
+
st.error("Set a working directory above.")
|
| 116 |
+
st.stop()
|
| 117 |
+
try:
|
| 118 |
+
_wdir.mkdir(parents=True, exist_ok=True)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
st.error(f"Could not create working directory `{work_dir}`: {e}")
|
| 121 |
+
st.stop()
|
| 122 |
+
st.caption(f"Working directory: `{_wdir}`")
|
| 123 |
|
| 124 |
with st.expander("📋 Recommended sequence", expanded=False):
|
| 125 |
st.markdown(
|
|
|
|
| 155 |
# Route a leading `python` / `python3` to the configured interpreter.
|
| 156 |
if parts and parts[0] in ("python", "python3") and py_exe:
|
| 157 |
parts[0] = py_exe
|
| 158 |
+
# Resolve a bare pipeline-script filename (e.g. `run_ocr.py`) to its vendored
|
| 159 |
+
# absolute path so the script runs from pipeline/ while its working dir stays
|
| 160 |
+
# the configured data directory.
|
| 161 |
+
if len(parts) >= 2 and parts[1].endswith(".py") and not any(
|
| 162 |
+
sep in parts[1] for sep in ("/", "\\")
|
| 163 |
+
):
|
| 164 |
+
_candidate = SCRIPTS_DIR / parts[1]
|
| 165 |
+
if _candidate.is_file():
|
| 166 |
+
parts[1] = str(_candidate)
|
| 167 |
|
| 168 |
env = os.environ.copy()
|
| 169 |
if mistral_key:
|
|
|
|
| 178 |
out_box = st.empty()
|
| 179 |
try:
|
| 180 |
proc = subprocess.Popen(
|
| 181 |
+
parts, cwd=str(_wdir), env=env,
|
| 182 |
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
| 183 |
text=True, bufsize=1,
|
| 184 |
)
|
|
|
|
| 216 |
if run:
|
| 217 |
run_command(cmd, key)
|
| 218 |
elif st.session_state.get(f"prep_log_{key}"):
|
| 219 |
+
# Popover, not an expander: step() runs inside a stage expander, and
|
| 220 |
+
# Streamlit forbids nesting an expander inside another expander.
|
| 221 |
+
with st.popover("Last run output"):
|
| 222 |
st.code(st.session_state[f"prep_log_{key}"][-8000:], language="text")
|
| 223 |
st.divider()
|
| 224 |
|
|
|
|
| 356 |
)
|
| 357 |
|
| 358 |
st.caption(
|
| 359 |
+
"Each step runs a bundled `pipeline/` script with the working directory as "
|
| 360 |
+
"its cwd — no external script folder required. Long steps (OCR, extraction) "
|
| 361 |
+
"keep this tab busy until they finish; output streams live above."
|
| 362 |
)
|
pyproject.toml
CHANGED
|
@@ -56,6 +56,7 @@ dependencies = [
|
|
| 56 |
"mistralai<2",
|
| 57 |
"reportlab>=4.5.1",
|
| 58 |
"pdf2image>=1.17.0",
|
|
|
|
| 59 |
"runpod-flash>=1.16.0",
|
| 60 |
"psycopg[binary]>=3.3.4",
|
| 61 |
"pgvector>=0.4.2",
|
|
|
|
| 56 |
"mistralai<2",
|
| 57 |
"reportlab>=4.5.1",
|
| 58 |
"pdf2image>=1.17.0",
|
| 59 |
+
"pdfplumber>=0.11.0",
|
| 60 |
"runpod-flash>=1.16.0",
|
| 61 |
"psycopg[binary]>=3.3.4",
|
| 62 |
"pgvector>=0.4.2",
|
rag_search.py
CHANGED
|
@@ -23,6 +23,7 @@ import matplotlib.colors as mcolors
|
|
| 23 |
import textwrap
|
| 24 |
import datamapplot
|
| 25 |
import json
|
|
|
|
| 26 |
|
| 27 |
# st.set_option('deprecation.showPyplotGlobalUse', False)
|
| 28 |
|
|
@@ -385,7 +386,39 @@ def plot_date_distribution(df, date_column, figsize=(14, 8), color='orange', tit
|
|
| 385 |
return fig
|
| 386 |
|
| 387 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
"""
|
| 390 |
Adds a UI on top of a dataframe to let viewers filter columns
|
| 391 |
|
|
@@ -2423,6 +2456,35 @@ with tab_rag:
|
|
| 2423 |
except Exception as e:
|
| 2424 |
st.error(f"An error occurred during reranking: {e}")
|
| 2425 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2426 |
else:
|
| 2427 |
# ── Multi-DB Cross Similarity ─────────────────────────────────────────
|
| 2428 |
import plotly.graph_objects as go
|
|
|
|
| 23 |
import textwrap
|
| 24 |
import datamapplot
|
| 25 |
import json
|
| 26 |
+
from utils.data_processing import DataProcessor
|
| 27 |
|
| 28 |
# st.set_option('deprecation.showPyplotGlobalUse', False)
|
| 29 |
|
|
|
|
| 386 |
return fig
|
| 387 |
|
| 388 |
|
| 389 |
+
def gemini_query(question, selected_data, gemini_key):
|
| 390 |
+
"""Ask Gemini a question over a column's values (blank question → summarize).
|
| 391 |
+
|
| 392 |
+
Moved here from analyzing.py so all natural-language search/Q&A over a
|
| 393 |
+
dataset lives on the RAG search page.
|
| 394 |
+
"""
|
| 395 |
+
import google.generativeai as genai
|
| 396 |
+
|
| 397 |
+
if not question:
|
| 398 |
+
question = "Summarize the following data in relevant bullet points"
|
| 399 |
+
|
| 400 |
+
filtered = [str(x) for x in selected_data if str(x) != '' and x is not None]
|
| 401 |
+
context = '\n'.join(filtered)
|
| 402 |
+
|
| 403 |
+
genai.configure(api_key=gemini_key)
|
| 404 |
+
query_model = genai.GenerativeModel('models/gemini-3.1-pro-preview')
|
| 405 |
+
response = query_model.generate_content(
|
| 406 |
+
[f"{question}\n Answer based on this context: {context}\n\n"]
|
| 407 |
+
)
|
| 408 |
+
return response.text
|
| 409 |
+
|
| 410 |
+
|
| 411 |
def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
| 412 |
+
"""Shared filtering UI — delegates to the DataProcessor so the RAG search
|
| 413 |
+
page uses the same filter system as the parsing and analysis pages. Binary
|
| 414 |
+
0/1 columns get a value picker; other numerics get range/percentile/std-dev.
|
| 415 |
+
"""
|
| 416 |
+
return DataProcessor.filter_dataframe_enhanced(
|
| 417 |
+
df, enable_quick_filters=False, enable_advanced_filters=True
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def filter_dataframe_legacy(df: pd.DataFrame) -> pd.DataFrame:
|
| 422 |
"""
|
| 423 |
Adds a UI on top of a dataframe to let viewers filter columns
|
| 424 |
|
|
|
|
| 2456 |
except Exception as e:
|
| 2457 |
st.error(f"An error occurred during reranking: {e}")
|
| 2458 |
|
| 2459 |
+
# ── Gemini Q&A (moved from analyzing.py) ───────────────────────────────
|
| 2460 |
+
st.divider()
|
| 2461 |
+
st.markdown("#### 🔮 Gemini Q&A")
|
| 2462 |
+
st.caption(
|
| 2463 |
+
"Ask Gemini a question over one column's values, or leave the "
|
| 2464 |
+
"question empty to get a bullet-point summary."
|
| 2465 |
+
)
|
| 2466 |
+
gq1, gq2 = st.columns(2)
|
| 2467 |
+
with gq1:
|
| 2468 |
+
gemini_col = st.selectbox(
|
| 2469 |
+
"Which column do you want to query?",
|
| 2470 |
+
list(st.session_state['parsed_responses'].columns),
|
| 2471 |
+
key="gemini_qa_col",
|
| 2472 |
+
)
|
| 2473 |
+
with gq2:
|
| 2474 |
+
gemini_key_input = st.text_input(
|
| 2475 |
+
"Gemini API Key", value=GEMINI_KEY, type="password",
|
| 2476 |
+
help="Defaults to GEMINI_KEY in secrets.", key="gemini_qa_key",
|
| 2477 |
+
)
|
| 2478 |
+
if gemini_col and gemini_key_input:
|
| 2479 |
+
gemini_question = st.text_input(
|
| 2480 |
+
"Ask a question or leave empty for summarization",
|
| 2481 |
+
key="gemini_qa_question",
|
| 2482 |
+
)
|
| 2483 |
+
selected_column_data = st.session_state['parsed_responses'][gemini_col].tolist()
|
| 2484 |
+
if st.button("Generate Query", key="gemini_qa_run") and selected_column_data:
|
| 2485 |
+
with st.status("Generating answer with Gemini…", expanded=True):
|
| 2486 |
+
st.write(gemini_query(gemini_question, selected_column_data, gemini_key_input))
|
| 2487 |
+
|
| 2488 |
else:
|
| 2489 |
# ── Multi-DB Cross Similarity ─────────────────────────────────────────
|
| 2490 |
import plotly.graph_objects as go
|
requirements.txt
CHANGED
|
@@ -34,3 +34,27 @@ torch
|
|
| 34 |
umap_learn
|
| 35 |
xgboost
|
| 36 |
tf-keras
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
umap_learn
|
| 35 |
xgboost
|
| 36 |
tf-keras
|
| 37 |
+
|
| 38 |
+
# Imported by the app but previously missing from requirements (present in the
|
| 39 |
+
# uv env / uv.lock). stqdm, tqdm and psutil are hard top-level imports
|
| 40 |
+
# (uap_analyzer.py, the pages, and utils/memory_manager.py via utils/__init__),
|
| 41 |
+
# so the pages fail to import without them. psycopg (Neon semantic search) and
|
| 42 |
+
# transformers (pdf_ocr LightOnOCR) are lazy feature deps.
|
| 43 |
+
# Note: GPU acceleration (cupy/cuml/cuvs/rmm) and tiktoken are optional and
|
| 44 |
+
# guarded by try/except, so they are intentionally NOT pinned here.
|
| 45 |
+
stqdm
|
| 46 |
+
tqdm
|
| 47 |
+
psutil
|
| 48 |
+
transformers
|
| 49 |
+
psycopg[binary]
|
| 50 |
+
|
| 51 |
+
# Document Preprocessing pipeline (pipeline/) — vendored scripts' deps.
|
| 52 |
+
# (openai, Pillow, pymupdf, Requests are already listed above.)
|
| 53 |
+
# Note: pdf2image also needs the system `poppler` binary (poppler-utils).
|
| 54 |
+
pyyaml
|
| 55 |
+
google-genai
|
| 56 |
+
pypdf
|
| 57 |
+
mistralai
|
| 58 |
+
reportlab
|
| 59 |
+
pdf2image
|
| 60 |
+
pdfplumber
|
utils/data_processing.py
CHANGED
|
@@ -641,9 +641,34 @@ class DataProcessor:
|
|
| 641 |
col_info = next((col for col in profile['numeric_columns'] if col['name'] == column), None)
|
| 642 |
if not col_info:
|
| 643 |
return df
|
| 644 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
col1, col2 = st.columns([1, 2])
|
| 646 |
-
|
| 647 |
with col1:
|
| 648 |
filter_mode = st.radio(
|
| 649 |
f"Filter mode for {column}",
|
|
|
|
| 641 |
col_info = next((col for col in profile['numeric_columns'] if col['name'] == column), None)
|
| 642 |
if not col_info:
|
| 643 |
return df
|
| 644 |
+
|
| 645 |
+
# Binary boolean columns (values ⊆ {0, 1}) — Range/Percentile/StdDev are
|
| 646 |
+
# meaningless here, so offer a 0/1 value picker instead of a slider.
|
| 647 |
+
_nonnull = df[column].dropna()
|
| 648 |
+
_uniq = set(_nonnull.unique())
|
| 649 |
+
if _uniq and _uniq.issubset({0, 1}):
|
| 650 |
+
bcol1, bcol2 = st.columns([1, 2])
|
| 651 |
+
with bcol1:
|
| 652 |
+
_opts = sorted(_uniq)
|
| 653 |
+
picked = st.multiselect(
|
| 654 |
+
f"Values for {column}",
|
| 655 |
+
_opts,
|
| 656 |
+
default=_opts,
|
| 657 |
+
format_func=lambda v: f"{int(v)} — {'true' if int(v) == 1 else 'false'}",
|
| 658 |
+
key=f"binary_{column}",
|
| 659 |
+
)
|
| 660 |
+
df_filtered = df[df[column].isin(picked)]
|
| 661 |
+
with bcol2:
|
| 662 |
+
if len(df_filtered) > 0:
|
| 663 |
+
_vc = df_filtered[column].value_counts().sort_index()
|
| 664 |
+
_vc.index = _vc.index.map(
|
| 665 |
+
lambda v: f"{int(v)} ({'true' if int(v) == 1 else 'false'})"
|
| 666 |
+
)
|
| 667 |
+
st.bar_chart(_vc)
|
| 668 |
+
return df_filtered
|
| 669 |
+
|
| 670 |
col1, col2 = st.columns([1, 2])
|
| 671 |
+
|
| 672 |
with col1:
|
| 673 |
filter_mode = st.radio(
|
| 674 |
f"Filter mode for {column}",
|
uv.lock
CHANGED
|
@@ -2958,6 +2958,33 @@ wheels = [
|
|
| 2958 |
{ url = "https://files.pythonhosted.org/packages/62/33/61766ae033518957f877ab246f87ca30a85b778ebaad65b7f74fa7e52988/pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2", size = 11618, upload-time = "2024-01-07T20:32:59.957Z" },
|
| 2959 |
]
|
| 2960 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2961 |
[[package]]
|
| 2962 |
name = "pexpect"
|
| 2963 |
version = "4.9.0"
|
|
@@ -3527,6 +3554,35 @@ wheels = [
|
|
| 3527 |
{ url = "https://files.pythonhosted.org/packages/f4/fa/3597fb3fb28f40bf8291fdddbc4dcd51ce52fccaf1cbfca10ee9db09c69a/pypdf-6.12.0-py3-none-any.whl", hash = "sha256:a8e104ab950e655d0bcf5fa5e71317c06474bc707987335da44a210f73a8883b", size = 343457, upload-time = "2026-05-21T09:21:40.852Z" },
|
| 3528 |
]
|
| 3529 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3530 |
[[package]]
|
| 3531 |
name = "pyproj"
|
| 3532 |
version = "3.7.1"
|
|
@@ -5021,6 +5077,7 @@ dependencies = [
|
|
| 5021 |
{ name = "openpyxl" },
|
| 5022 |
{ name = "pandas" },
|
| 5023 |
{ name = "pdf2image" },
|
|
|
|
| 5024 |
{ name = "pgvector" },
|
| 5025 |
{ name = "pillow" },
|
| 5026 |
{ name = "plotly" },
|
|
@@ -5079,6 +5136,7 @@ requires-dist = [
|
|
| 5079 |
{ name = "openpyxl", specifier = ">=3.1.5" },
|
| 5080 |
{ name = "pandas", specifier = ">=2.3.1" },
|
| 5081 |
{ name = "pdf2image", specifier = ">=1.17.0" },
|
|
|
|
| 5082 |
{ name = "pgvector", specifier = ">=0.4.2" },
|
| 5083 |
{ name = "pillow", specifier = ">=11.3.0" },
|
| 5084 |
{ name = "plotly", specifier = ">=6.3.0" },
|
|
|
|
| 2958 |
{ url = "https://files.pythonhosted.org/packages/62/33/61766ae033518957f877ab246f87ca30a85b778ebaad65b7f74fa7e52988/pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2", size = 11618, upload-time = "2024-01-07T20:32:59.957Z" },
|
| 2959 |
]
|
| 2960 |
|
| 2961 |
+
[[package]]
|
| 2962 |
+
name = "pdfminer-six"
|
| 2963 |
+
version = "20251230"
|
| 2964 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2965 |
+
dependencies = [
|
| 2966 |
+
{ name = "charset-normalizer" },
|
| 2967 |
+
{ name = "cryptography" },
|
| 2968 |
+
]
|
| 2969 |
+
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
|
| 2970 |
+
wheels = [
|
| 2971 |
+
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
|
| 2972 |
+
]
|
| 2973 |
+
|
| 2974 |
+
[[package]]
|
| 2975 |
+
name = "pdfplumber"
|
| 2976 |
+
version = "0.11.9"
|
| 2977 |
+
source = { registry = "https://pypi.org/simple" }
|
| 2978 |
+
dependencies = [
|
| 2979 |
+
{ name = "pdfminer-six" },
|
| 2980 |
+
{ name = "pillow" },
|
| 2981 |
+
{ name = "pypdfium2" },
|
| 2982 |
+
]
|
| 2983 |
+
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
|
| 2984 |
+
wheels = [
|
| 2985 |
+
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
|
| 2986 |
+
]
|
| 2987 |
+
|
| 2988 |
[[package]]
|
| 2989 |
name = "pexpect"
|
| 2990 |
version = "4.9.0"
|
|
|
|
| 3554 |
{ url = "https://files.pythonhosted.org/packages/f4/fa/3597fb3fb28f40bf8291fdddbc4dcd51ce52fccaf1cbfca10ee9db09c69a/pypdf-6.12.0-py3-none-any.whl", hash = "sha256:a8e104ab950e655d0bcf5fa5e71317c06474bc707987335da44a210f73a8883b", size = 343457, upload-time = "2026-05-21T09:21:40.852Z" },
|
| 3555 |
]
|
| 3556 |
|
| 3557 |
+
[[package]]
|
| 3558 |
+
name = "pypdfium2"
|
| 3559 |
+
version = "5.9.0"
|
| 3560 |
+
source = { registry = "https://pypi.org/simple" }
|
| 3561 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b0/98/6b44bf82ddb3c7a3e0249203772aad8981b4491d6227f182685f310faeff/pypdfium2-5.9.0.tar.gz", hash = "sha256:db1274bd27844db6fda17ef1dbcd0026c47d357437058d838e98060c0da9e92e", size = 272455, upload-time = "2026-06-01T15:43:38.08Z" }
|
| 3562 |
+
wheels = [
|
| 3563 |
+
{ url = "https://files.pythonhosted.org/packages/8b/d9/59630cb40e5f37e7712e6ea65e9cac633f4195e8b737bb3a46054aa63340/pypdfium2-5.9.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:91914837c4a4285b3e0724a84eca8079363db7475acbcab405933d1807785664", size = 3407817, upload-time = "2026-06-01T15:42:58.426Z" },
|
| 3564 |
+
{ url = "https://files.pythonhosted.org/packages/0f/3d/e205708835a3730d5242652b6577ac06ad4721e6fcef77cc7c9d3541c686/pypdfium2-5.9.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:90610d352f050b065b703f3a46602a852fce7dd8787300c8c7a472485b644d8f", size = 2862706, upload-time = "2026-06-01T15:43:00.581Z" },
|
| 3565 |
+
{ url = "https://files.pythonhosted.org/packages/01/47/e843fb895a891438b3f8c6d834fdc9c19183cd60980fc9325429d5c01505/pypdfium2-5.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6c4fbe3a7190b329c526358fb2855d797f7b74b5ecfc61d19657ef20bcebc108", size = 3489945, upload-time = "2026-06-01T15:43:02.542Z" },
|
| 3566 |
+
{ url = "https://files.pythonhosted.org/packages/35/bd/f5e6afd556f97fcaa2bec4cb04669664c166028fc2a059bd65447c852b43/pypdfium2-5.9.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:e93f0cf440169a3e445e6fbd06c803877e7418f3e13254287875cb67f208bb5a", size = 3674186, upload-time = "2026-06-01T15:43:04.496Z" },
|
| 3567 |
+
{ url = "https://files.pythonhosted.org/packages/6d/4d/5286812216a292d51dfba8e7bff276da198f126508f8c2afa3630bf701dc/pypdfium2-5.9.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d902e03dff5efd51d93cd23d3e55bde53802fa6207bcd0e455239518859a069", size = 3669571, upload-time = "2026-06-01T15:43:06.571Z" },
|
| 3568 |
+
{ url = "https://files.pythonhosted.org/packages/ac/c8/822db2c89baa13e6cee321d587fcd42df463a1fc2f7520b3f6814768bc71/pypdfium2-5.9.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cf38d7ad3575947b82384869f2ab69ba345eb21d83118d25db3e83f967b0421", size = 3400412, upload-time = "2026-06-01T15:43:08.35Z" },
|
| 3569 |
+
{ url = "https://files.pythonhosted.org/packages/1a/dd/7d09d8cdc28383df13f739a97ac4f1215a704a97a29506dee2bf89d8a350/pypdfium2-5.9.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77f7479a28b43aa658735e3ce79cfd1fccd5d42db035c21bb4c26e8bd7e280e5", size = 3803326, upload-time = "2026-06-01T15:43:10.054Z" },
|
| 3570 |
+
{ url = "https://files.pythonhosted.org/packages/99/58/3f4e04ffe1ae62b437de07a96da672091cef62b619d0dc78207c1af442e6/pypdfium2-5.9.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07e6ba170d577eabf60dbba701d051c64318dd029d38ca5907d83ae1a66fe779", size = 4216890, upload-time = "2026-06-01T15:43:11.701Z" },
|
| 3571 |
+
{ url = "https://files.pythonhosted.org/packages/1d/f6/2dde4656750c4a6da99e1f070ca09d2b5a9d68186b42e711a1a3e5b1cb32/pypdfium2-5.9.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ce3a3dd23ec0adaa079d8be54565ba2aa2f6060e76a4989cd42dabc163d74ee", size = 3728830, upload-time = "2026-06-01T15:43:13.329Z" },
|
| 3572 |
+
{ url = "https://files.pythonhosted.org/packages/d0/ca/f2ff8b9200c7dfc5aee85126edc856eb93c7056085da2454a75ef1e4dbc4/pypdfium2-5.9.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae177938f5cf95a275db25a4f8553e2ebd954ecda2f9bc84848ba4b027ce438f", size = 4063322, upload-time = "2026-06-01T15:43:15.158Z" },
|
| 3573 |
+
{ url = "https://files.pythonhosted.org/packages/64/88/0b587de03c873c28adc59f6ac959de4032d3f3bc946094523b14a192d9c3/pypdfium2-5.9.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffe49edde2ac86f28ca7e58f565255a442f38a7508fff31b79a55f508f25a31e", size = 4039738, upload-time = "2026-06-01T15:43:16.975Z" },
|
| 3574 |
+
{ url = "https://files.pythonhosted.org/packages/83/4c/fa627f00a954e66465e929077cf43bd012595091fff82758d989486e7bdc/pypdfium2-5.9.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b7b760bc2957ecf73c274af6ed8b168a2dcb328ac0a0f7ed6123cd92f6e7c9c9", size = 4997259, upload-time = "2026-06-01T15:43:18.915Z" },
|
| 3575 |
+
{ url = "https://files.pythonhosted.org/packages/32/f0/1736d80c5d12d931f74ca6b4213b006ee016ec33c6325fad870234cc240c/pypdfium2-5.9.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7cdc8e5d2f8d82add1e4f70a4fbe5f3b33c17f301ebde38c669fd7f78a7d032c", size = 4537061, upload-time = "2026-06-01T15:43:20.879Z" },
|
| 3576 |
+
{ url = "https://files.pythonhosted.org/packages/01/00/aa8890dfd385b2e7365034231987029cff15cc7eb4f06e8380da5608738a/pypdfium2-5.9.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:38a058dbd4929acaf0ab9171179eb86c24d8c6655a6836006796105a9f200890", size = 5232786, upload-time = "2026-06-01T15:43:23.73Z" },
|
| 3577 |
+
{ url = "https://files.pythonhosted.org/packages/65/12/8f45ea698781a0bed96ac4fbde440060790863273943461f0f160a993d52/pypdfium2-5.9.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:1894511a0e862e7ec5679f3a6dc43ac72c4ef92c7ca438357203913e8634a643", size = 5170121, upload-time = "2026-06-01T15:43:25.858Z" },
|
| 3578 |
+
{ url = "https://files.pythonhosted.org/packages/25/bd/9bb6ba375796e1de1d6c1af8d8303dd1781190346871c81a94d4e09eddfd/pypdfium2-5.9.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:040f5513b808db705d4878f57e2bf0b9dc6e6a0ad8d765c36cf62febf3933b28", size = 4663540, upload-time = "2026-06-01T15:43:27.677Z" },
|
| 3579 |
+
{ url = "https://files.pythonhosted.org/packages/d2/4a/fd103bac197f22038bf70be1f7507ced7519f1214ea0dae137f37803ab8a/pypdfium2-5.9.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:f4991ae39bcea757552579bba4aebfaedb71c96dd35c2292f957b8ac9132f1ff", size = 5090619, upload-time = "2026-06-01T15:43:29.522Z" },
|
| 3580 |
+
{ url = "https://files.pythonhosted.org/packages/22/89/9531fa1e6e004fe522cdca0cd945cd6a9d7338e7125e6b0734d632d31fa6/pypdfium2-5.9.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:25ff1a5abd08ff9e87f62e5dac114ea95647c257fbbdbe029be8db71a6d7650b", size = 5050806, upload-time = "2026-06-01T15:43:31.322Z" },
|
| 3581 |
+
{ url = "https://files.pythonhosted.org/packages/fc/d0/e53c68555ff128b2470e4a468762b320d9c6ae2c914decea3487d923982f/pypdfium2-5.9.0-py3-none-win32.whl", hash = "sha256:b0057dc8c2033584dc3e61afb5f23a135dab52b081695b435e27f9b7b074c605", size = 3670966, upload-time = "2026-06-01T15:43:32.991Z" },
|
| 3582 |
+
{ url = "https://files.pythonhosted.org/packages/da/0c/22e5fc035ad1594b44f265bc0a59ae34d377bc2ea74a92793e7a674bf96d/pypdfium2-5.9.0-py3-none-win_amd64.whl", hash = "sha256:06508c33b9772cf3878e48364c6e14c70cefc18a3abd6983ac9f338da9305275", size = 3800959, upload-time = "2026-06-01T15:43:34.536Z" },
|
| 3583 |
+
{ url = "https://files.pythonhosted.org/packages/11/e3/cf1711add7add22a17f7c7633cd795edc92f17ab7bdf1930493ae0f56680/pypdfium2-5.9.0-py3-none-win_arm64.whl", hash = "sha256:565ddfc98795fd2f6054b544ee9791d7b9032f9cf77a57891b6e501fafd0ef3f", size = 3585718, upload-time = "2026-06-01T15:43:36.521Z" },
|
| 3584 |
+
]
|
| 3585 |
+
|
| 3586 |
[[package]]
|
| 3587 |
name = "pyproj"
|
| 3588 |
version = "3.7.1"
|
|
|
|
| 5077 |
{ name = "openpyxl" },
|
| 5078 |
{ name = "pandas" },
|
| 5079 |
{ name = "pdf2image" },
|
| 5080 |
+
{ name = "pdfplumber" },
|
| 5081 |
{ name = "pgvector" },
|
| 5082 |
{ name = "pillow" },
|
| 5083 |
{ name = "plotly" },
|
|
|
|
| 5136 |
{ name = "openpyxl", specifier = ">=3.1.5" },
|
| 5137 |
{ name = "pandas", specifier = ">=2.3.1" },
|
| 5138 |
{ name = "pdf2image", specifier = ">=1.17.0" },
|
| 5139 |
+
{ name = "pdfplumber", specifier = ">=0.11.0" },
|
| 5140 |
{ name = "pgvector", specifier = ">=0.4.2" },
|
| 5141 |
{ name = "pillow", specifier = ">=11.3.0" },
|
| 5142 |
{ name = "plotly", specifier = ">=6.3.0" },
|