File size: 15,203 Bytes
a72d1cf | 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 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 | """Çözümlenmiş tabloları tip çıkarımı + adlandırma ile SQLite'a aktarır.
Her veritabanı iki meta tablo içerir:
_tablolar : tablo adı, belge başlığı, kaynak, sayfa, birim, çıkarıcı, ...
_sutunlar : sütun adı, orijinal başlık, tip, birim, çarpan, örnek değerler, ...
Bu meta bilgiler Text-to-SQL modülüne şema bağlamı olarak verilir.
"""
from __future__ import annotations
import json
import re
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from .extract import ResolvedTable
from .trnum import (detect_unit, fold, parse_date, parse_number, slugify, strip_units,
tr_lower, _RE_DATE_RANGE, _RE_YEAR)
STOP = {"ve", "ile", "gore", "ait", "icin", "olarak", "ornek", "yili", "tablo", "olan"}
TOTAL_RE = re.compile(r"^(genel\s+)?toplam\b|^turkiye( toplami)?$|^toplam\s*\(|^[a-z]+(\s+[a-z]+)?\s+toplami$")
def table_name_from_caption(caption: str, fallback: str = "tablo") -> str:
cap = re.sub(r"^\s*(tablo|çizelge|cizelge|table)\s*[\d.]*\s*[:.\-–]?\s*", "", caption or "", flags=re.I)
cap = strip_units(cap)
toks = [t for t in slugify(cap, 200).split("_") if t and t not in STOP and not t.isdigit()] if cap.strip() else []
name = "_".join(toks[:4])
if not name:
name = slugify(fallback)
if name[0].isdigit():
name = "t_" + name
return name
def column_name(parts: list[str], idx: int) -> str:
text = " ".join(parts)
years = []
m = _RE_DATE_RANGE.search(text)
if m:
years.append(m.group(4))
text = text[:m.start()] + " " + text[m.end():]
for y in re.findall(r"\(\s*((?:19|20)\d{2})\s*\)", text): # 'Nüfus (2025)': parantezli yıl birim değildir
if y not in years:
years.append(y)
text = strip_units(text)
for y in _RE_YEAR.findall(text):
if y not in years:
years.append(y)
text = _RE_YEAR.sub(" ", text)
base = slugify(text, 40) if re.search(r"[A-Za-zÇĞİÖŞÜçğıöşü]", text) else ""
if base == "sutun":
base = ""
if not base and years:
base = "yil"
if not base:
base = f"sutun_{idx + 1}"
name = "_".join([base] + years)
return ("c_" + name) if name[0].isdigit() else name
@dataclass
class ColumnInfo:
name: str
original: str
sqltype: str # INTEGER | REAL | TEXT
semantic: str # sayi | yuzde | tarih | metin | yil | bayrak
unit: str | None = None
multiplier: float = 1.0
unparsed: int = 0 # sayısal sütunda ayrıştırılamayan hücre sayısı
samples: list = field(default_factory=list)
description: str = "" # isteğe bağlı açıklama (ör. AI önerisi)
@dataclass
class BuiltTable:
name: str
caption: str
columns: list[ColumnInfo]
rows: list[list]
source: str
page: int | None
extractor: str
unit: str | None
n_header_rows: int
merged_cells: int
mode: str
summary: str = "" # isteğe bağlı tablo özeti (ör. AI önerisi)
context_years: str = "" # tablo kendisi yıl içermiyorsa, hemen üstündeki belge metninde geçen yıllar
def _infer_column(values: list[str], mode: str):
nonnull = [v for v in values if fold(v) not in ("", "-", "—", "...")]
if not nonnull or mode == "text":
return "TEXT", "metin", list(values), 0
# tarih?
dates = [parse_date(v) for v in nonnull]
nums = [parse_number(v, mode) for v in nonnull]
n_date = sum(d is not None for d in dates)
n_num = sum(p.ok and p.value is not None for p in nums)
if n_date / len(nonnull) >= 0.8 and n_num / len(nonnull) < 0.8:
return "TEXT", "tarih", [parse_date(v) for v in values], 0
if n_num / len(nonnull) >= 0.8:
parsed = [parse_number(v, mode) for v in values]
out = [p.value if p.ok else None for p in parsed]
unparsed = sum(1 for v, p in zip(values, parsed) if not p.ok and fold(v) not in ("", "-"))
is_pct = any(p.is_percent for p in parsed)
all_int = all(x is None or float(x).is_integer() for x in out)
if mode == "tr": # '258.107.640,00' gibi ondalık virgüllü yazımlar REAL kalmalı
is_int = all_int and not any("," in v for v in nonnull)
else:
is_int = all_int
if is_int:
out = [int(x) if x is not None else None for x in out]
sem = "yuzde" if is_pct else "sayi"
if is_int and all(x is None or 1900 <= x <= 2100 for x in out):
sem = "yil"
return ("INTEGER" if is_int else "REAL"), sem, out, unparsed
return "TEXT", "metin", list(values), 0
def build_table(rt: ResolvedTable, mode: str = "tr", name: str | None = None) -> BuiltTable:
ncol = len(rt.header_parts)
cap_unit, cap_mult = detect_unit(rt.caption)
cols, data_cols = [], []
used = set()
for j in range(ncol):
parts = rt.header_parts[j]
original = " ".join(parts)
vals = [r[j] if j < len(r) else "" for r in rt.body]
sqltype, sem, conv, unparsed = _infer_column(vals, mode)
nm = column_name(parts, j) if parts else ("kalem" if sqltype == "TEXT" else f"sutun_{j + 1}")
base, k = nm, 2
while nm in used:
nm = f"{base}_{k}"
k += 1
used.add(nm)
if sem == "sayi" and re.search(r"\b(dipnot\w*|referanslar\w*|kod|sira no)\b", fold(original)):
sem = "kod" # dipnot numarası / ekonomik kod: ölçü değildir, birimi yoktur
unit, mult = detect_unit(original)
if sem == "kod":
unit, mult = None, 1.0
if sem == "yuzde" or (unit is None and "%" in original):
unit, mult = "%", 1.0
if sem == "sayi":
sem = "yuzde"
if unit is None and sem == "sayi" and cap_unit and nm != "yil" and sem != "kod":
unit, mult = cap_unit, cap_mult
if sem == "yil":
unit, mult = None, 1.0
samples = [v for v in conv if v not in (None, "")][:3]
cols.append(ColumnInfo(nm, original, sqltype, sem, unit, mult, unparsed, samples))
data_cols.append(conv)
rows = [list(r) for r in zip(*data_cols)] if data_cols else []
# toplam satırı işareti
text_idx = [i for i, c in enumerate(cols) if c.sqltype == "TEXT" and c.semantic == "metin"][:2]
flags = []
for r, raw in zip(rows, rt.body):
is_tot = any(TOTAL_RE.search(fold(raw[i])) for i in text_idx if i < len(raw))
if not text_idx and raw:
is_tot = bool(TOTAL_RE.search(fold(raw[0])))
flags.append(1 if is_tot else 0)
if any(flags):
cols.append(ColumnInfo("toplam_satiri", "(otomatik) Toplam satırı mı?", "INTEGER", "bayrak",
samples=[0, 1]))
rows = [r + [f] for r, f in zip(rows, flags)]
fallback = (Path(rt.source).stem or "tablo") + (f"_s{rt.page}" if rt.page else "")
tname = name or table_name_from_caption(rt.caption, fallback)
own_years = re.findall(r"(?:19|20)\d{2}", (rt.caption or "") + " " + " ".join(c.name for c in cols))
if not own_years and not any(c.semantic in ("yil", "tarih") for c in cols):
ctx = sorted(set(re.findall(r"\b((?:19|20)\d{2})\b", rt.context or "")))
else:
ctx = []
return BuiltTable(tname, rt.caption, cols, rows, rt.source, rt.page, rt.extractor, cap_unit,
rt.n_header_rows, rt.merged_cells, mode, context_years=",".join(ctx))
# ======================================================================= SQLite
def register_functions(con: sqlite3.Connection):
"""Türkçe duyarlı yardımcı fonksiyonlar: TR_LOWER, TR_NORM."""
con.create_function("TR_LOWER", 1, lambda s: tr_lower(s) if s is not None else None,
deterministic=True)
con.create_function("TR_NORM", 1, lambda s: fold(s) if s is not None else None,
deterministic=True)
def connect(db_path: str | Path, readonly: bool = False) -> sqlite3.Connection:
if readonly:
con = sqlite3.connect(f"file:{Path(db_path).as_posix()}?mode=ro", uri=True,
check_same_thread=False)
else:
con = sqlite3.connect(str(db_path), check_same_thread=False)
register_functions(con)
return con
META_DDL = """
CREATE TABLE IF NOT EXISTS _tablolar(
tablo TEXT PRIMARY KEY, baslik TEXT, kaynak TEXT, sayfa INTEGER, cikarici TEXT,
birim TEXT, satir_sayisi INTEGER, sutun_sayisi INTEGER, baslik_satir_sayisi INTEGER,
birlesik_hucre INTEGER, sayi_kipi TEXT, ozet TEXT);
CREATE TABLE IF NOT EXISTS _sutunlar(
tablo TEXT, sutun TEXT, sira INTEGER, orijinal_baslik TEXT, sql_tipi TEXT, anlam TEXT,
birim TEXT, carpan REAL, ayristirilamayan INTEGER, ornek_degerler TEXT, aciklama TEXT,
PRIMARY KEY(tablo, sutun));
"""
def ensure_meta(con: sqlite3.Connection):
"""Meta tabloları oluşturur; eski sürüm veritabanlarına yeni sütunları ekler."""
con.executescript(META_DDL)
for tbl, col in (("_tablolar", "ozet"), ("_sutunlar", "aciklama"), ("_tablolar", "baglam_yillari")):
have = {r[1] for r in con.execute(f"PRAGMA table_info({tbl})")}
if col not in have:
con.execute(f"ALTER TABLE {tbl} ADD COLUMN {col} TEXT")
con.commit()
def drop_same_source(con: sqlite3.Connection, bt: "BuiltTable") -> list[str]:
"""Aynı belge, sayfa ve başlıktan daha önce farklı adla aktarılmış tabloları siler (yeniden aktarımda kopya oluşmasın)."""
ensure_meta(con)
old = [r[0] for r in con.execute(
"SELECT tablo FROM _tablolar WHERE kaynak=? AND baslik=? AND COALESCE(sayfa,-1)=COALESCE(?,-1) AND tablo<>?",
(bt.source, bt.caption, bt.page, bt.name))]
for t in old:
con.execute(f'DROP TABLE IF EXISTS "{t}"')
con.execute("DELETE FROM _tablolar WHERE tablo=?", (t,))
con.execute("DELETE FROM _sutunlar WHERE tablo=?", (t,))
con.commit()
return old
def set_table_summary(con: sqlite3.Connection, table: str, summary: str):
ensure_meta(con)
con.execute("UPDATE _tablolar SET ozet=? WHERE tablo=?", (summary, table))
con.commit()
def write_table(con: sqlite3.Connection, bt: BuiltTable, replace: bool = True) -> str:
ensure_meta(con)
name = bt.name
existing = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
# aynı belge, sayfa ve başlıktan gelen tablo varsa (yeniden aktarım) onun yerine yaz
same = con.execute("SELECT tablo FROM _tablolar WHERE kaynak IS ? AND sayfa IS ? AND baslik IS ? "
"AND (tablo=? OR tablo LIKE ? ESCAPE '\\')", (bt.source, bt.page, bt.caption, name, name + "\\_%")
).fetchone() if replace else None
if same:
name = same[0]
other_source = not same and name in existing
if not replace or other_source:
# başka bir belgenin/sayfanın tablosunun üzerine yazma: benzersiz ad seç
base, k = name, 2
while name in existing:
name = f"{base}_{k}"
k += 1
con.execute(f'DROP TABLE IF EXISTS "{name}"')
con.execute("DELETE FROM _tablolar WHERE tablo=?", (name,))
con.execute("DELETE FROM _sutunlar WHERE tablo=?", (name,))
coldefs = ", ".join(f'"{c.name}" {c.sqltype}' for c in bt.columns)
con.execute(f'CREATE TABLE "{name}" ({coldefs})')
ph = ", ".join("?" for _ in bt.columns)
con.executemany(f'INSERT INTO "{name}" VALUES ({ph})', bt.rows)
con.execute("INSERT INTO _tablolar (tablo, baslik, kaynak, sayfa, cikarici, birim, satir_sayisi, sutun_sayisi, "
"baslik_satir_sayisi, birlesik_hucre, sayi_kipi, ozet, baglam_yillari) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(name, bt.caption, bt.source, bt.page, bt.extractor, bt.unit, len(bt.rows),
len(bt.columns), bt.n_header_rows, bt.merged_cells, bt.mode, bt.summary or None,
bt.context_years or None))
for i, c in enumerate(bt.columns):
con.execute("INSERT INTO _sutunlar (tablo, sutun, sira, orijinal_baslik, sql_tipi, anlam, birim, carpan, "
"ayristirilamayan, ornek_degerler, aciklama) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(name, c.name, i, c.original, c.sqltype, c.semantic, c.unit, c.multiplier,
c.unparsed, json.dumps(c.samples, ensure_ascii=False, default=str), c.description or None))
con.commit()
return name
def list_tables(con) -> list[str]:
try:
return [r[0] for r in con.execute("SELECT tablo FROM _tablolar ORDER BY rowid")]
except sqlite3.OperationalError:
return [r[0] for r in con.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_%' ESCAPE '\\'")]
def describe_schema(con, tables: list[str] | None = None, n_samples: int = 3,
distinct_text: int = 25) -> str:
"""LLM'e verilecek şema açıklaması (CREATE TABLE + Türkçe yorumlar + örnek değerler)."""
out = []
tables = tables or list_tables(con)
for t in tables:
tcols = {r[1] for r in con.execute("PRAGMA table_info(_tablolar)")}
ccols = {r[1] for r in con.execute("PRAGMA table_info(_sutunlar)")}
ozet_sql = "ozet" if "ozet" in tcols else "NULL"
acik_sql = "aciklama" if "aciklama" in ccols else "NULL"
meta = con.execute(f"SELECT baslik, kaynak, sayfa, birim, {ozet_sql} FROM _tablolar WHERE tablo=?", (t,)).fetchone()
cols = con.execute(f"SELECT sutun, sql_tipi, anlam, birim, orijinal_baslik, ornek_degerler, {acik_sql} "
"FROM _sutunlar WHERE tablo=? ORDER BY sira", (t,)).fetchall()
if meta:
out.append(f"-- Tablo: {t} | Belgedeki başlık: \"{meta[0]}\" | Kaynak: {meta[1]}"
+ (f" s.{meta[2]}" if meta[2] else "") + (f" | Birim: {meta[3]}" if meta[3] else ""))
if meta[4]:
out.append(f"-- Özet: {meta[4]}")
lines = []
for sutun, tip, anlam, birim, orij, ornek, aciklama in cols:
note = f"orijinal: \"{orij}\"; anlam: {anlam}" + (f"; birim: {birim}" if birim else "")
if aciklama:
note += f"; açıklama: {aciklama}"
if tip == "TEXT" and anlam in ("metin",):
vals = [r[0] for r in con.execute(
f'SELECT DISTINCT "{sutun}" FROM "{t}" WHERE "{sutun}" IS NOT NULL LIMIT {distinct_text + 1}')]
if len(vals) <= distinct_text:
note += "; değerler: " + ", ".join(repr(v) for v in vals)
else:
note += "; örnek: " + ", ".join(repr(v) for v in vals[:n_samples])
elif anlam == "tarih":
note += "; ISO biçim, örnek: " + ", ".join(json.loads(ornek or "[]")[:2])
else:
note += "; örnek: " + ", ".join(str(x) for x in json.loads(ornek or "[]")[:n_samples])
lines.append(f' "{sutun}" {tip}, -- {note}')
if lines:
lines[-1] = lines[-1].replace(", --", " --", 1)
out.append(f'CREATE TABLE "{t}" (\n' + "\n".join(lines) + "\n);")
return "\n".join(out)
|