Ashoka74 Claude Opus 4.8 (1M context) commited on
Commit
0e62cc9
·
1 Parent(s): fe14cc9

Deploy: SCU_v1 default, Sankey, filter reconciliation, nested-expander fixes, deps

Browse files

Slim 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 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
- # Scratch notebooks
182
- Untitled.ipynb
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
- def gemini_query(question, selected_data, gemini_key):
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
- _pair_drilldown(df, cv1, pairs1, "cv_dd1", clicked1, drop_missing)
992
- if n_excl1:
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
- else:
1003
- st.info("No non-trivial associations found in pass 1.")
 
 
 
 
 
 
 
 
 
 
 
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
- _pair_drilldown(df, cv2, pairs2, "cv_dd2", clicked2, drop_missing)
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
- else:
1062
- st.info("No non-trivial associations in the refined set.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- col1, col2 = st.columns(2)
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
- # this will check if the dataframe is not empty
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
- {activeTab === 'association' && <CramersVExplorer source="dataset" />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 type { CramersVResponse, ContingencyResponse, ColumnGroup } from '../../types';
 
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
- <button
126
- onClick={run}
127
- disabled={loading}
128
- 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"
129
- >
130
- <Play className="h-3.5 w-3.5" />
131
- {loading ? 'Computing…' : 'Compute'}
132
- </button>
 
 
 
 
 
 
 
 
 
 
 
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 whenever the selection changes
 
 
 
75
  useEffect(() => {
76
  if (selectedSchemas.length === 0) {
77
  setMergedFormat('');
78
  setFieldCount(0);
 
79
  return;
80
  }
81
- api
82
- .mergeSchema(selectedSchemas)
83
- .then((m) => {
84
- setMergedFormat(m.schema_json);
85
- setFieldCount(m.fields.length);
86
- })
87
- .catch((e) => setError(e instanceof Error ? e.message : 'Schema merge failed'));
88
- }, [selectedSchemas]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 || !mergedFormat) return;
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 || !mergedFormat) {
134
- setError('Select at least one text column and one schema.');
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 || !mergedFormat}
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 || !mergedFormat || !apiKey}
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
- <p className="mt-2 text-[11px] text-text-muted">
374
- {textColumns.length} text column(s), {selectedSchemas.length} schema(s) selected.
375
- </p>
 
 
 
 
 
 
 
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
- # this is a test comment
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
- _min = float(df_[column].min())
828
- _max = float(df_[column].max())
829
- step = (_max - _min) / 100
830
- user_num_input = right.slider(
831
- f"Values for {column}",
832
- min_value=_min,
833
- max_value=_max,
834
- value=(_min, _max),
835
- step=step,
836
- )
837
- df_ = df_[df_[column].between(*user_num_input)]
838
- filtered_columns.append(column)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
839
 
840
- # Chart_GPT = ChartGPT(df_, title_font, body_font, title_size,
841
- # colors, interpretation, extract_docx, img_path)
842
 
843
- with st.status(f"Numerical Distribution: {column}", expanded=False) as stat_:
844
- st.pyplot(plot_hist(df_, column, bins=int(round(len(df_[column].unique())-1)/2)))
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 = st.session_state.get('parsed_responses')
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 st.session_state.get('parsed_responses'):
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 st.session_state.get('parsed_responses') and md_files:
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 st.session_state['parsed_responses'] is not None:
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 st.session_state['parsed_responses'] is None:
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 st.session_state['parsed_responses'] is not None or '_scu_csv_df' in st.session_state:
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 st.session_state.get('parsed_responses'):
2443
- with st.expander("🧹 SCU Normalization (SCU v2)", expanded=True):
2444
- st.markdown(
2445
- "Canonicalises the parsed data — country ISO codes, US-state "
2446
- "codes, witness roles, craft shape/size bands — and derives the "
2447
- "**SCU five-criterion eligibility gate** (`scu_eligible` plus the "
2448
- "per-criterion columns). Turn this off in the app sidebar to hide."
2449
- )
2450
- render_scu_normalization(st.session_state['parsed_responses'])
 
 
 
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
- Orchestrates the standalone preprocessing scripts (scrape → layout → OCR →
5
- concat → report extraction → table creation) that live in a separate pipeline
6
- directory (e.g. D:/divided). Each step is run as a subprocess with its output
7
- streamed live; the scripts themselves are not modified.
 
 
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
- pipeline_dir = st.text_input(
54
- "Pipeline directory (where the scripts and data live)",
55
- value=st.session_state.get("prep_dir", "/mnt/d/divided"),
56
- key="prep_dir",
57
- help="On WSL the Windows D: drive is /mnt/d/divided; on native Windows "
58
- "use D:/divided. Every command runs with this as the working dir.",
 
59
  )
60
  py_exe = st.text_input(
61
- "Python executable", value=st.session_state.get("prep_py", "python"),
62
  key="prep_py",
63
- help="Must be a Python that has the pipeline dependencies installed "
64
- "(see the dependencies note below).",
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
- with st.expander("Pipeline dependencies", expanded=False):
 
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
- _pdir = Path(pipeline_dir) if pipeline_dir else None
88
- if _pdir is None or not _pdir.is_dir():
89
- st.error(f"Pipeline directory not found: `{pipeline_dir}`")
90
- st.stop()
91
-
92
- _present = [s for s in PIPELINE_SCRIPTS if (_pdir / s).is_file()]
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(_pdir), env=env,
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
- with st.expander("Last run output", expanded=False):
 
 
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 shells out to the script in the pipeline directory the scripts "
327
- "themselves are unchanged. Long steps (OCR, extraction) keep this tab busy "
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" },