File size: 5,503 Bytes
0cac9cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
"""Excel file discovery and context summarization utilities for prompt building."""

from __future__ import annotations

import hashlib
import json
import re
from pathlib import Path

import pandas as pd

from ..config import EXCEL_DIR

SUPPORTED_SUFFIXES = {".xlsx", ".xls"}


def _table_name_from_file(file_name: str) -> str:
	"""Normalize a workbook or sheet label into a SQL-safe identifier."""
	stem = file_name.rsplit(".", 1)[0]
	normalized = re.sub(r"[^0-9A-Za-z]+", "_", stem).strip("_")
	return normalized or "excel_data"


def _table_name_from_sheet(file_name: str, sheet_name: str) -> str:
	"""Return the SQL table name used by the executor for one workbook sheet."""
	workbook_part = _table_name_from_file(file_name)
	sheet_part = _table_name_from_file(sheet_name)
	return f"{workbook_part}__{sheet_part}"


def normalize_excel_dir(excel_dir: str | Path | None = None) -> Path:
	"""Normalize Excel directory input into an absolute resolved path."""
	path = Path(excel_dir) if excel_dir is not None else EXCEL_DIR
	return path.expanduser().resolve()


def list_excel_files(excel_dir: str | Path | None = None) -> list[Path]:
	"""Return supported Excel workbooks in a deterministic sorted order."""
	directory = normalize_excel_dir(excel_dir)
	if not directory.exists() or not directory.is_dir():
		return []
	return sorted(
		[item for item in directory.iterdir() if item.is_file() and item.suffix.lower() in SUPPORTED_SUFFIXES],
		key=lambda item: item.name.lower(),
	)


def read_excel_file(file_path: Path) -> pd.ExcelFile:
	"""Open an Excel workbook and raise if the expected file is missing."""
	if not file_path.exists():
		raise FileNotFoundError(f"File {file_path.name} not found in {file_path.parent}")
	return pd.ExcelFile(file_path)


def get_col_info(df: pd.DataFrame) -> str:
	"""Build compact schema metadata for a DataFrame."""
	col_types = df.dtypes
	rows = len(df)
	cols = len(df.columns)
	info = "\n".join([f"{col}: {dtype}" for col, dtype in col_types.items()])
	if not info:
		info = "No columns found"
	info += f"\nColumns: {cols}"
	info += f"\nRows: {rows}"
	return info


def get_sample_rows(df: pd.DataFrame, sample_size: int = 1) -> str:
	"""Return a small human-readable table preview used in LLM context text."""
	if df.empty:
		return "Sample rows: No rows found"

	sample_df = df.head(sample_size)
	headers = [str(column).strip() for column in sample_df.columns]
	lines = [" | ".join(headers)]

	for row in sample_df.itertuples(index=False, name=None):
		values = [str(value).strip() for value in row]
		lines.append(" | ".join(values))

	return "Sample rows:\n" + "\n".join(lines)


def _snapshot_file(file_path: Path) -> dict[str, str | int]:
	"""Return file metadata used to detect workbook set/content changes."""
	stat = file_path.stat()
	return {
		"name": file_path.name,
		"size": stat.st_size,
		"mtime_ns": stat.st_mtime_ns,
	}


def get_excel_snapshot(excel_dir: str | Path | None = None) -> tuple[str, list[dict[str, str | int]]]:
	"""Return snapshot digest and entries for all tracked Excel files."""
	files = list_excel_files(excel_dir)
	snapshot = [_snapshot_file(file_path) for file_path in files]
	digest = hashlib.sha256(json.dumps(snapshot, sort_keys=True).encode("utf-8")).hexdigest()
	return digest, snapshot


def describe_excel_snapshot_changes(
	previous_snapshot: list[dict[str, str | int]] | None,
	current_snapshot: list[dict[str, str | int]] | None,
) -> str:
	"""Describe added/removed workbook names between two snapshots."""
	previous_names = {
		str(item.get("name"))
		for item in (previous_snapshot or [])
		if isinstance(item, dict) and isinstance(item.get("name"), str)
	}
	current_names = {
		str(item.get("name"))
		for item in (current_snapshot or [])
		if isinstance(item, dict) and isinstance(item.get("name"), str)
	}

	added = sorted(current_names - previous_names)
	removed = sorted(previous_names - current_names)
	if not added and not removed:
		return ""

	sections: list[str] = ["### EXCEL FILE CHANGES"]
	if added:
		sections.append(f"Added files: {', '.join(added)}")
	if removed:
		sections.append(f"Removed files: {', '.join(removed)}")
	return "\n".join(sections)


def build_excel_files_info(excel_dir: str | Path | None = None) -> tuple[str, str, list[dict[str, str | int]]]:
	"""Build text context, snapshot digest, and snapshot entries for prompts."""
	directory = normalize_excel_dir(excel_dir)
	files = list_excel_files(directory)
	digest, snapshot = get_excel_snapshot(directory)

	if not files:
		message = f"Excel directory: {directory}\nNo Excel files found."
		return message, digest, snapshot

	sections: list[str] = [f"Excel directory: {directory}", f"Excel files found: {len(files)}"]
	for file_path in files:
		xls = read_excel_file(file_path)
		sheet_names = [str(sheet_name) for sheet_name in xls.sheet_names]
		sheet_sections: list[str] = [f"File: {file_path.name}", f"Sheets: {', '.join(sheet_names)}"]
		for sheet_name in xls.sheet_names:
			df = xls.parse(sheet_name)
			sheet_sections.append(f"Sheet: {sheet_name}")
			sheet_sections.append(f"SQL table: {_table_name_from_sheet(file_path.name, str(sheet_name))}")
			sheet_sections.append(get_col_info(df))
			sheet_sections.append(get_sample_rows(df))
		sections.append("\n".join(sheet_sections))

	return "\n\n".join(sections), digest, snapshot


def format_excel_context(info: str | None) -> str:
	"""Wrap plain Excel metadata text in a structured prompt section."""
	if not info:
		return ""
	return f"### EXCEL FILE CONTEXT\n{info}"