eurekiusz 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: eurekiusz
3
+ Version: 0.1.0
4
+ Summary: Lokalny asystent ustaw podatkowych: ustawa → nowela → KIS. Bez zgadywania kwot.
5
+ Author-email: QA10 <hello@qa10.io>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://qa10.io
8
+ Keywords: vat,podatki,eli,kis,eureka
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Environment :: Console
11
+ Classifier: Environment :: Win32 (MS Windows)
12
+ Classifier: Intended Audience :: Legal Industry
13
+ Classifier: Topic :: Office/Business :: Financial
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Eurekiusz
18
+
19
+ Lokalny asystent ustaw podatkowych. Trzy warstwy: **ustawa → nowela → KIS**.
20
+ Model nie zgaduje kwot. Pusty kontekst = `Nie znalazlem w bazie.`
21
+
22
+ ## Instalacja
23
+
24
+ ```
25
+ pip install eurekiusz
26
+ ```
27
+
28
+ Windows (po `winget install Python.Python.3.12` i **nowym** PowerShellu):
29
+
30
+ ```
31
+ python -m pip install eurekiusz
32
+ ```
33
+
34
+ ## Użycie
35
+
36
+ ```
37
+ eurekiusz ask "jaki jest limit zwolnienia VAT 2026"
38
+ eurekiusz lookup DU/2025/775 113
39
+ eurekiusz health
40
+ eurekiusz gui
41
+ ```
42
+
43
+ Albo skrót okna: `eurekiusz-gui`.
44
+
45
+ Bramka: art. 113 TJ `DU/2025/775` = **200 000 zł**. Limit 240 000 zł to nowela `DU/2025/896` od 1.01.2026. Sama kwota 240k przy cytacie 775 = błąd.
46
+
47
+ ## Baza
48
+
49
+ Wheel zawiera `chunks.db` (~27 MB). Nadpisanie:
50
+
51
+ ```
52
+ set EUREKIUSZ_DB=C:\path\chunks.db
53
+ ```
54
+
55
+ albo plik `~/.eurekiusz/chunks.db`.
56
+
57
+ ## Czego tu nie ma
58
+
59
+ - modelu LLM / GGUF (osobny download na desktop Wails)
60
+ - Qdrant, 8 botów, chmury
61
+ - treści z bazy Optima `prawo_rachunkowe`
@@ -0,0 +1,45 @@
1
+ # Eurekiusz
2
+
3
+ Lokalny asystent ustaw podatkowych. Trzy warstwy: **ustawa → nowela → KIS**.
4
+ Model nie zgaduje kwot. Pusty kontekst = `Nie znalazlem w bazie.`
5
+
6
+ ## Instalacja
7
+
8
+ ```
9
+ pip install eurekiusz
10
+ ```
11
+
12
+ Windows (po `winget install Python.Python.3.12` i **nowym** PowerShellu):
13
+
14
+ ```
15
+ python -m pip install eurekiusz
16
+ ```
17
+
18
+ ## Użycie
19
+
20
+ ```
21
+ eurekiusz ask "jaki jest limit zwolnienia VAT 2026"
22
+ eurekiusz lookup DU/2025/775 113
23
+ eurekiusz health
24
+ eurekiusz gui
25
+ ```
26
+
27
+ Albo skrót okna: `eurekiusz-gui`.
28
+
29
+ Bramka: art. 113 TJ `DU/2025/775` = **200 000 zł**. Limit 240 000 zł to nowela `DU/2025/896` od 1.01.2026. Sama kwota 240k przy cytacie 775 = błąd.
30
+
31
+ ## Baza
32
+
33
+ Wheel zawiera `chunks.db` (~27 MB). Nadpisanie:
34
+
35
+ ```
36
+ set EUREKIUSZ_DB=C:\path\chunks.db
37
+ ```
38
+
39
+ albo plik `~/.eurekiusz/chunks.db`.
40
+
41
+ ## Czego tu nie ma
42
+
43
+ - modelu LLM / GGUF (osobny download na desktop Wails)
44
+ - Qdrant, 8 botów, chmury
45
+ - treści z bazy Optima `prawo_rachunkowe`
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "eurekiusz"
7
+ version = "0.1.0"
8
+ description = "Lokalny asystent ustaw podatkowych: ustawa → nowela → KIS. Bez zgadywania kwot."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "QA10", email = "hello@qa10.io" }]
12
+ license = { text = "Proprietary" }
13
+ keywords = ["vat", "podatki", "eli", "kis", "eureka"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Environment :: Console",
17
+ "Environment :: Win32 (MS Windows)",
18
+ "Intended Audience :: Legal Industry",
19
+ "Topic :: Office/Business :: Financial",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://qa10.io"
25
+
26
+ [project.scripts]
27
+ eurekiusz = "eurekiusz.cli:main"
28
+
29
+ [project.gui-scripts]
30
+ eurekiusz-gui = "eurekiusz.gui:main"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ eurekiusz = ["data/chunks.db"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Eurekiusz — lokalny asystent ustaw podatkowych (ustawa → nowela → KIS)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,120 @@
1
+ """CLI: eurekiusz ask|lookup|search|health|gui"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+
8
+ from eurekiusz import __version__
9
+ from eurekiusz.law import ask, health, lookup, search
10
+
11
+
12
+ def _clip(text: str, n: int = 900) -> str:
13
+ text = (text or "").strip()
14
+ if len(text) > n:
15
+ return text[:n] + "…"
16
+ return text
17
+
18
+
19
+ def _hit_lines(hit: dict) -> list[str]:
20
+ if not hit:
21
+ return []
22
+ inner = hit.get("article") or hit
23
+ eli = inner.get("eli") or hit.get("eli") or ""
24
+ art = inner.get("art") or hit.get("art") or ""
25
+ text = inner.get("text") or hit.get("text") or hit.get("head") or ""
26
+ lines = [f"{eli} art. {art}".strip()]
27
+ if text:
28
+ lines.append(_clip(text))
29
+ return lines
30
+
31
+
32
+ def format_ask(out: dict) -> str:
33
+ if not out.get("ok"):
34
+ return (out.get("reason") or "Nie znalazlem w bazie.") + "\n"
35
+ lines: list[str] = []
36
+ mode = out.get("mode")
37
+ if mode == "dual_cite":
38
+ if out.get("ruling"):
39
+ lines.append(out["ruling"])
40
+ lines.append("")
41
+ lines.extend(_hit_lines(out.get("tj") or {}))
42
+ lines.append("")
43
+ nowela = out.get("nowela") or {}
44
+ if nowela.get("ok"):
45
+ lines.append("Nowela DU/2025/896 (wejście 1.01.2026):")
46
+ lines.extend(_hit_lines(nowela))
47
+ lines.append("")
48
+ elif mode == "lookup":
49
+ lines.extend(_hit_lines(out.get("hit") or {}))
50
+ lines.append("")
51
+ elif mode == "disambiguate":
52
+ lines.append(out.get("note") or "Kilka ustaw. Sprecyzuj.")
53
+ for c in out.get("candidates") or []:
54
+ lines.append(f"- {c.get('eli')} art. {c.get('art')} ({c.get('domain')})")
55
+ lines.append("")
56
+ else:
57
+ hits = out.get("hits") or []
58
+ if not hits:
59
+ return "Nie znalazlem w bazie.\n"
60
+ for h in hits[:3]:
61
+ lines.extend(_hit_lines(h))
62
+ lines.append("")
63
+ kis = out.get("kis")
64
+ if kis:
65
+ lines.append(f"KIS {kis.get('sygnatura') or kis.get('id')}: {kis.get('teza') or ''}")
66
+ if kis.get("url"):
67
+ lines.append(kis["url"])
68
+ if kis.get("uwaga"):
69
+ lines.append(kis["uwaga"])
70
+ elif mode != "disambiguate":
71
+ lines.append("KIS: nie znalazłem w bazie interpretacji dla tego artykułu.")
72
+ return "\n".join(lines).strip() + "\n"
73
+
74
+
75
+ def main() -> int:
76
+ p = argparse.ArgumentParser(prog="eurekiusz")
77
+ p.add_argument("--version", action="version", version=f"eurekiusz {__version__}")
78
+ sub = p.add_subparsers(dest="cmd", required=True)
79
+
80
+ pa = sub.add_parser("ask", help="pytanie → ustawa + nowela + KIS")
81
+ pa.add_argument("question")
82
+ pa.add_argument("--json", action="store_true")
83
+
84
+ pl = sub.add_parser("lookup")
85
+ pl.add_argument("eli")
86
+ pl.add_argument("art")
87
+
88
+ ps = sub.add_parser("search")
89
+ ps.add_argument("query")
90
+ ps.add_argument("--domain")
91
+ ps.add_argument("--eli")
92
+ ps.add_argument("-k", type=int, default=5)
93
+
94
+ sub.add_parser("health")
95
+ sub.add_parser("gui", help="okno desktopowe")
96
+
97
+ args = p.parse_args()
98
+ if args.cmd == "gui":
99
+ from eurekiusz.gui import run_gui
100
+
101
+ run_gui()
102
+ return 0
103
+ if args.cmd == "lookup":
104
+ out = lookup(args.eli, args.art)
105
+ elif args.cmd == "search":
106
+ out = search(args.query, domain=args.domain, eli=args.eli, k=args.k)
107
+ elif args.cmd == "health":
108
+ out = health()
109
+ else:
110
+ out = ask(args.question)
111
+ if not getattr(args, "json", False) and args.cmd == "ask":
112
+ sys.stdout.write(format_ask(out))
113
+ return 0 if out.get("ok") else 1
114
+ json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
115
+ sys.stdout.write("\n")
116
+ return 0 if out.get("ok") else 1
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """EUREKA (KIS) layer 3. Never overrides statute. Empty is success."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import urllib.error
8
+ import urllib.request
9
+
10
+ EUREKA_SEARCH = (
11
+ "https://eureka.mf.gov.pl/api/public/v1/wyszukiwarka/informacje/"
12
+ "?size=12&page=0&sort=parametryPozycjonowania%2Casc"
13
+ )
14
+ UA = "Mozilla/5.0 (compatible; Eurekiusz/1.0; +https://qa10.io)"
15
+
16
+
17
+ def _przepisy_blob(pr) -> str:
18
+ if isinstance(pr, list):
19
+ return " ".join(str(x) for x in pr)
20
+ return str(pr or "")
21
+
22
+
23
+ def _status_ok(st) -> bool:
24
+ s = st[0] if isinstance(st, list) else st
25
+ return bool(s) and "aktualna" in str(s).lower()
26
+
27
+
28
+ def art_in_przepisy(pr, art: str) -> bool:
29
+ """EUREKA encodes 'art. 113-ust. 1'. Substring '113' is a false friend."""
30
+ blob = _przepisy_blob(pr)
31
+ art = art.strip()
32
+ return bool(re.search(rf"art\.\s*{re.escape(art)}-", blob, re.I))
33
+
34
+
35
+ def search_kis(nouns: str, art: str | None = None, timeout: float = 8.0) -> dict | None:
36
+ """Return at most one aktualna interpretacja, or None.
37
+
38
+ Requires art in PRZEPISY when art is given. Requires at least one
39
+ query token (len>=4) in TEZA. Otherwise skip — ranking is garbage.
40
+ """
41
+ q = " ".join((nouns or "").split()[:8]).strip()
42
+ if len(q) < 4:
43
+ return None
44
+ body = json.dumps(
45
+ {
46
+ "searchQuery": q,
47
+ "searchInContent": True,
48
+ "searchInSynonyms": True,
49
+ "searchInFullPhrase": False,
50
+ "filter": {},
51
+ "columns": [
52
+ "ID_INFORMACJI",
53
+ "SYG",
54
+ "DT_WYD",
55
+ "TEZA",
56
+ "STATUS_INFORMACJI",
57
+ "PRZEPISY",
58
+ "KATEGORIA_INFORMACJI",
59
+ ],
60
+ "warunkiDodatkowe": [],
61
+ }
62
+ ).encode()
63
+ req = urllib.request.Request(
64
+ EUREKA_SEARCH,
65
+ data=body,
66
+ headers={
67
+ "Content-Type": "application/json",
68
+ "Accept": "application/json",
69
+ "User-Agent": UA,
70
+ },
71
+ method="POST",
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=timeout) as r:
75
+ data = json.loads(r.read().decode())
76
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
77
+ return None
78
+ tokens = [t.lower() for t in re.findall(r"[a-ząćęłńóśźż]{4,}", q, re.I)]
79
+ for item in data.get("results") or []:
80
+ if not _status_ok(item.get("STATUS_INFORMACJI")):
81
+ continue
82
+ if art and not art_in_przepisy(item.get("PRZEPISY"), art):
83
+ continue
84
+ teza = (item.get("TEZA") or "").lower()
85
+ if tokens and not any(t in teza for t in tokens):
86
+ continue
87
+ iid = str(item.get("ID_INFORMACJI") or "")
88
+ return {
89
+ "id": iid,
90
+ "sygnatura": item.get("SYG"),
91
+ "data": item.get("DT_WYD"),
92
+ "teza": (item.get("TEZA") or "")[:400],
93
+ "przepisy": item.get("PRZEPISY") or [],
94
+ "url": f"https://eureka.mf.gov.pl/informacje/podglad/{iid}" if iid else None,
95
+ "uwaga": "Interpretacja KIS nie zmienia ustawy. To wykładnia organu.",
96
+ }
97
+ return None
@@ -0,0 +1,120 @@
1
+ """Desktop window. stdlib tkinter. No LLM. Empty RAG = nie znalazłem."""
2
+ from __future__ import annotations
3
+
4
+ import tkinter as tk
5
+ from eurekiusz.cli import format_ask
6
+ from eurekiusz.law import ask, health
7
+
8
+
9
+ def run_gui() -> None:
10
+ root = tk.Tk()
11
+ root.title("Eurekiusz")
12
+ root.minsize(720, 480)
13
+ root.configure(bg="#1a1412")
14
+
15
+ try:
16
+ h = health()
17
+ status = (
18
+ f"akty {h.get('acts')} · artykuły {h.get('articles')} · "
19
+ f"113={'200k' if h.get('vat_113_200k') else 'BŁĄD'}"
20
+ )
21
+ except SystemExit as e:
22
+ status = str(e)
23
+
24
+ title = tk.Label(
25
+ root,
26
+ text="Eurekiusz",
27
+ fg="#E8DDD4",
28
+ bg="#1a1412",
29
+ font=("Segoe UI", 18, "bold"),
30
+ )
31
+ title.pack(anchor="w", padx=16, pady=8)
32
+
33
+ hint = tk.Label(
34
+ root,
35
+ text="Pytanie do ustawy. Model nie zgaduje kwot. Pusty kontekst = nie znalazłem.",
36
+ fg="#93A292",
37
+ bg="#1a1412",
38
+ font=("Segoe UI", 10),
39
+ )
40
+ hint.pack(anchor="w", padx=16)
41
+
42
+ q = tk.StringVar()
43
+ row = tk.Frame(root, bg="#1a1412")
44
+ row.pack(fill="x", padx=16, pady=12)
45
+ entry = tk.Entry(
46
+ row,
47
+ textvariable=q,
48
+ font=("Segoe UI", 12),
49
+ bg="#2a201c",
50
+ fg="#E8DDD4",
51
+ insertbackground="#E8DDD4",
52
+ relief="flat",
53
+ )
54
+ entry.pack(side="left", fill="x", expand=True, ipady=8)
55
+
56
+ body = tk.Text(
57
+ root,
58
+ wrap="word",
59
+ bg="#120e0c",
60
+ fg="#E8DDD4",
61
+ insertbackground="#E8DDD4",
62
+ relief="flat",
63
+ padx=12,
64
+ pady=12,
65
+ font=("Consolas", 11),
66
+ )
67
+ body.pack(fill="both", expand=True, padx=16, pady=(0, 8))
68
+ body.insert("1.0", status + "\n")
69
+ body.configure(state="disabled")
70
+
71
+ foot = tk.Label(
72
+ root,
73
+ text=status,
74
+ fg="#93A292",
75
+ bg="#1a1412",
76
+ font=("Segoe UI", 9),
77
+ )
78
+ foot.pack(anchor="w", padx=16, pady=(0, 12))
79
+
80
+ def run_ask(_event=None) -> None:
81
+ question = q.get().strip()
82
+ if not question:
83
+ return
84
+ body.configure(state="normal")
85
+ body.delete("1.0", "end")
86
+ body.insert("1.0", "Szukam…\n")
87
+ body.update_idletasks()
88
+ try:
89
+ out = ask(question)
90
+ text = format_ask(out)
91
+ except Exception as exc:
92
+ text = f"Błąd: {exc}\n"
93
+ body.delete("1.0", "end")
94
+ body.insert("1.0", text)
95
+ body.configure(state="disabled")
96
+
97
+ btn = tk.Button(
98
+ row,
99
+ text="Pytaj",
100
+ command=run_ask,
101
+ bg="#39703D",
102
+ fg="#E8DDD4",
103
+ activebackground="#75B974",
104
+ relief="flat",
105
+ font=("Segoe UI", 11, "bold"),
106
+ padx=16,
107
+ pady=6,
108
+ )
109
+ btn.pack(side="left", padx=(8, 0))
110
+ entry.bind("<Return>", run_ask)
111
+ entry.focus_set()
112
+ root.mainloop()
113
+
114
+
115
+ def main() -> None:
116
+ run_gui()
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env python3
2
+ """Swiss-watch query layer over rag/chunks.db. JSON to stdout.
3
+
4
+ python3 indexer/law.py lookup DU/2025/775 113
5
+ python3 indexer/law.py search "zwolnienie podmiotowe" --domain vat
6
+ python3 indexer/law.py ask "jaki jest limit zwolnienia VAT 2026"
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import re
13
+ import sqlite3
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from eurekiusz.paths import find_db
18
+
19
+ DB = find_db()
20
+
21
+ STOP = {
22
+ "jaki", "jaka", "jakie", "jest", "czy", "dla", "oraz", "lub", "oraz",
23
+ "the", "and", "art", "ust", "pkt", "tej", "tym", "nie", "tak",
24
+ "przez", "oraz", "się", "od", "do", "na", "w", "z", "o", "u", "i", "a",
25
+ }
26
+
27
+ CORE_TJ = {
28
+ "vat": "DU/2025/775",
29
+ "pit": "DU/2026/592",
30
+ "cit": "DU/2026/554",
31
+ "ordynacja": "DU/2026/622",
32
+ "ryczalt": "DU/2025/843",
33
+ "zus": "DU/2026/199",
34
+ "nip": "DU/2026/151",
35
+ "akcyza": "DU/2026/412",
36
+ "rolny": "DU/2025/1344",
37
+ "pcc": "DU/2026/191",
38
+ "kks": "DU/2025/633",
39
+ "spadki": "DU/2026/478",
40
+ "lokalne": "DU/2025/707",
41
+ "zdrowotna": "DU/2025/1461",
42
+ }
43
+
44
+ DOMAIN_RX = [
45
+ ("vat", re.compile(r"\bvat\b|towarów i usług|ksef|faktury ustrukturyzowan|zwolnieni\w+ podmiotow", re.I)),
46
+ ("pit", re.compile(r"\bpit\b|osób fizycznych|skala podatk", re.I)),
47
+ ("cit", re.compile(r"\bcit\b|osób prawnych", re.I)),
48
+ ("ryczalt", re.compile(r"ryczałt|zryczałtowan", re.I)),
49
+ ("ordynacja", re.compile(r"ordynacj|przedawnien|zobowiązan\w+ podatkow", re.I)),
50
+ ("zus", re.compile(r"\bzus\b|składk|ubezpieczeń społeczn", re.I)),
51
+ ("akcyza", re.compile(r"akcyz", re.I)),
52
+ ("nip", re.compile(r"\bnip\b|ewidencj\w+ podatnik", re.I)),
53
+ ("pcc", re.compile(r"\bpcc\b|czynności cywilnoprawn", re.I)),
54
+ ("kks", re.compile(r"\bkks\b|karny skarbowy", re.I)),
55
+ ("spadki", re.compile(r"spadk|darowizn", re.I)),
56
+ ("lokalne", re.compile(r"nieruchomości|opłatach lokalnych", re.I)),
57
+ ("zdrowotna", re.compile(r"składk\w+ zdrowotn|opieki zdrowotnej", re.I)),
58
+ ]
59
+
60
+
61
+ def conn() -> sqlite3.Connection:
62
+ if not DB.exists():
63
+ raise SystemExit(f"missing DB {DB}")
64
+ c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
65
+ c.row_factory = sqlite3.Row
66
+ return c
67
+
68
+
69
+ def amendments_for(c: sqlite3.Connection, eli: str, art: str | None = None) -> list[dict]:
70
+ q = "SELECT * FROM amendments WHERE target_eli=?"
71
+ args: list = [eli]
72
+ if art:
73
+ q += " AND (target_art=? OR target_art IS NULL OR target_art='')"
74
+ args.append(art)
75
+ rows = c.execute(q, args).fetchall()
76
+ return [dict(r) for r in rows]
77
+
78
+
79
+ def lookup(eli: str, art: str) -> dict:
80
+ c = conn()
81
+ art = art.lstrip("0") if art else art
82
+ row = c.execute(
83
+ "SELECT a.eli, a.art, a.text, a.char_len, t.title, t.year, t.pos, t.domain, t.source, t.is_tj, t.notes "
84
+ "FROM articles a JOIN acts t ON t.eli=a.eli WHERE a.eli=? AND a.art=?",
85
+ (eli, art),
86
+ ).fetchone()
87
+ if not row:
88
+ return {"ok": False, "eli": eli, "art": art, "error": "not found"}
89
+ d = dict(row)
90
+ d["ok"] = True
91
+ d["amendments"] = amendments_for(c, eli, art)
92
+ return d
93
+
94
+
95
+ def fts_query(question: str) -> str:
96
+ words = re.findall(r"[0-9A-Za-zÀ-ÿąćęłńóśźżĄĆĘŁŃÓŚŹŻ]{3,}", question, re.I)
97
+ terms = []
98
+ for w in words:
99
+ lw = w.lower()
100
+ if lw in STOP:
101
+ continue
102
+ if re.fullmatch(r"\d+[a-z]*", lw):
103
+ continue
104
+ terms.append(lw)
105
+ # unique preserve order
106
+ seen = set()
107
+ out = []
108
+ for t in terms:
109
+ if t not in seen:
110
+ seen.add(t)
111
+ out.append(t)
112
+ if not out:
113
+ return ""
114
+ return " AND ".join(out[:8])
115
+
116
+
117
+ def route_domain(question: str) -> str | None:
118
+ for name, rx in DOMAIN_RX:
119
+ if rx.search(question):
120
+ return name
121
+ return None
122
+
123
+
124
+ def search(question: str, domain: str | None = None, eli: str | None = None, k: int = 5) -> dict:
125
+ c = conn()
126
+ if not eli and domain and domain in CORE_TJ:
127
+ eli = CORE_TJ[domain]
128
+ match = fts_query(question)
129
+ if not match:
130
+ return {"ok": False, "error": "empty query", "question": question}
131
+ sql = (
132
+ "SELECT a.eli, a.art, a.char_len, t.title, t.domain, t.is_tj, "
133
+ "snippet(articles_fts, 2, '[', ']', '…', 24) AS snip, "
134
+ "bm25(articles_fts) AS rank "
135
+ "FROM articles_fts "
136
+ "JOIN articles a ON a.id = articles_fts.rowid "
137
+ "JOIN acts t ON t.eli = a.eli "
138
+ "WHERE articles_fts MATCH ? AND t.searchable=1 AND a.char_len>=80"
139
+ )
140
+ args: list = [match]
141
+ if eli:
142
+ sql += " AND a.eli=?"
143
+ args.append(eli)
144
+ elif domain:
145
+ sql += " AND t.domain=?"
146
+ args.append(domain)
147
+ sql += " ORDER BY t.is_tj DESC, rank LIMIT ?"
148
+ args.append(k)
149
+ rows = [dict(r) for r in c.execute(sql, args).fetchall()]
150
+ used = match
151
+ if not rows:
152
+ parts = match.split(" AND ")
153
+ while len(parts) > 1 and not rows:
154
+ parts = parts[:-1]
155
+ used = " AND ".join(parts)
156
+ args2 = [used] + args[1:]
157
+ rows = [dict(r) for r in c.execute(sql, args2).fetchall()]
158
+ return {
159
+ "ok": True,
160
+ "match": used,
161
+ "eli": eli,
162
+ "domain": domain,
163
+ "hits": rows,
164
+ }
165
+
166
+
167
+ def ask(question: str) -> dict:
168
+ domain = route_domain(question)
169
+ # Hard gate: VAT limit 2026
170
+ if re.search(r"limit|zwolnieni", question, re.I) and (domain == "vat" or re.search(r"vat|113", question, re.I)):
171
+ tj = lookup("DU/2025/775", "113")
172
+ nowela = lookup("DU/2025/896", "1")
173
+ return attach_kis(
174
+ {
175
+ "ok": True,
176
+ "mode": "dual_cite",
177
+ "question": question,
178
+ "domain": "vat",
179
+ "ruling": (
180
+ "W TJ DU/2025/775 art. 113 ust. 1 jest 200 000 zł. "
181
+ "Od 1.01.2026 nowela DU/2025/896 podnosi limit do 240 000 zł. "
182
+ "Nie wolno cytować 240 000 przy samej sygnaturze 775."
183
+ ),
184
+ "tj": tj,
185
+ "nowela": nowela,
186
+ },
187
+ question,
188
+ "113",
189
+ )
190
+ # explicit art. N
191
+ m = re.search(r"art\.?\s*(\d+[a-z]*)", question, re.I)
192
+ if m and domain and domain in CORE_TJ:
193
+ hit = lookup(CORE_TJ[domain], m.group(1))
194
+ if hit.get("ok"):
195
+ return attach_kis(
196
+ {"ok": True, "mode": "lookup", "question": question, "domain": domain, "hit": hit},
197
+ question,
198
+ m.group(1),
199
+ )
200
+ if m and not domain:
201
+ c = conn()
202
+ art = m.group(1)
203
+ rows = c.execute(
204
+ "SELECT a.eli, a.art, t.domain, t.is_tj, substr(a.text,1,240) AS head "
205
+ "FROM articles a JOIN acts t ON t.eli=a.eli "
206
+ "WHERE a.art=? AND t.searchable=1 AND t.is_tj=1 AND a.char_len>=80 "
207
+ "ORDER BY t.domain",
208
+ (art,),
209
+ ).fetchall()
210
+ hits = [dict(r) for r in rows]
211
+ if len(hits) == 1:
212
+ full = lookup(hits[0]["eli"], art)
213
+ return attach_kis(
214
+ {"ok": True, "mode": "lookup", "question": question, "domain": hits[0]["domain"], "hit": full},
215
+ question,
216
+ art,
217
+ )
218
+ if len(hits) > 1:
219
+ return {
220
+ "ok": True,
221
+ "mode": "disambiguate",
222
+ "question": question,
223
+ "art": art,
224
+ "note": "Ten numer artykułu jest w kilku ustawach. Podaj VAT/PIT/CIT/Ordynacja.",
225
+ "candidates": hits,
226
+ }
227
+ s = search(question, domain=domain, k=5)
228
+ s["question"] = question
229
+ s["mode"] = "search"
230
+ art = None
231
+ if s.get("hits"):
232
+ art = s["hits"][0].get("art")
233
+ return attach_kis(s, question, art)
234
+
235
+
236
+ def attach_kis(out: dict, question: str, art: str | None) -> dict:
237
+ try:
238
+ from eurekiusz.eureka import search_kis
239
+ except ImportError:
240
+ from indexer.eureka import search_kis # type: ignore
241
+ nouns = " ".join(re.findall(r"[A-Za-ząćęłńóśźżĄĆĘŁŃÓŚŹŻ]{4,}", question)[:6])
242
+ out["kis"] = search_kis(nouns, art=art)
243
+ return out
244
+
245
+
246
+ def health() -> dict:
247
+ c = conn()
248
+ empty = [r[0] for r in c.execute(
249
+ "SELECT a.eli FROM acts a LEFT JOIN articles j ON j.eli=a.eli GROUP BY a.eli HAVING count(j.id)=0"
250
+ )]
251
+ gold = ["13", "15", "86", "86a", "106ga", "108a", "113"]
252
+ have = {r[0] for r in c.execute("SELECT art FROM articles WHERE eli='DU/2025/775'")}
253
+ row113 = c.execute(
254
+ "SELECT instr(text,'200 000'), instr(text,'240 000'), char_len FROM articles WHERE eli='DU/2025/775' AND art='113'"
255
+ ).fetchone()
256
+ amd = c.execute("SELECT replaces_to, entry_into_force FROM amendments WHERE eli='DU/2025/896'").fetchone()
257
+ cores = {}
258
+ for name, eli in CORE_TJ.items():
259
+ n = c.execute("SELECT count(*) FROM articles WHERE eli=?", (eli,)).fetchone()[0]
260
+ cores[name] = {"eli": eli, "articles": n, "ok": n > 0}
261
+ return {
262
+ "ok": True,
263
+ "acts": c.execute("SELECT count(*) FROM acts").fetchone()[0],
264
+ "articles": c.execute("SELECT count(*) FROM articles").fetchone()[0],
265
+ "amendments": c.execute("SELECT count(*) FROM amendments").fetchone()[0],
266
+ "empty_acts": empty,
267
+ "gold_775_missing": [a for a in gold if a not in have],
268
+ "vat_113_200k": bool(row113 and row113[0] > 0),
269
+ "vat_113_no_240k": bool(row113 and row113[1] == 0),
270
+ "vat_113_len": row113[2] if row113 else None,
271
+ "amd_896": dict(zip(("to", "from_date"), amd)) if amd else None,
272
+ "searchable": c.execute("SELECT count(*) FROM acts WHERE searchable=1").fetchone()[0],
273
+ "cores": cores,
274
+ }
275
+
276
+
277
+ def main() -> int:
278
+ p = argparse.ArgumentParser()
279
+ sub = p.add_subparsers(dest="cmd", required=True)
280
+ pl = sub.add_parser("lookup")
281
+ pl.add_argument("eli")
282
+ pl.add_argument("art")
283
+ ps = sub.add_parser("search")
284
+ ps.add_argument("query")
285
+ ps.add_argument("--domain")
286
+ ps.add_argument("--eli")
287
+ ps.add_argument("-k", type=int, default=5)
288
+ pa = sub.add_parser("ask")
289
+ pa.add_argument("question")
290
+ sub.add_parser("health")
291
+ args = p.parse_args()
292
+ if args.cmd == "lookup":
293
+ out = lookup(args.eli, args.art)
294
+ elif args.cmd == "search":
295
+ out = search(args.query, domain=args.domain, eli=args.eli, k=args.k)
296
+ elif args.cmd == "health":
297
+ out = health()
298
+ else:
299
+ out = ask(args.question)
300
+ json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
301
+ sys.stdout.write("\n")
302
+ return 0 if out.get("ok") else 1
303
+
304
+
305
+ if __name__ == "__main__":
306
+ raise SystemExit(main())
@@ -0,0 +1,39 @@
1
+ """DB discovery for pip install (CWD is not the repo)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+
8
+ def find_db() -> Path:
9
+ env = os.environ.get("EUREKIUSZ_DB")
10
+ if env:
11
+ p = Path(env).expanduser()
12
+ if p.is_file():
13
+ return p
14
+ raise SystemExit(f"EUREKIUSZ_DB nie wskazuje na plik: {p}")
15
+
16
+ home = Path.home() / ".eurekiusz" / "chunks.db"
17
+ if home.is_file():
18
+ return home
19
+
20
+ try:
21
+ from importlib.resources import files
22
+
23
+ packaged = files("eurekiusz").joinpath("data/chunks.db")
24
+ if packaged.is_file():
25
+ return Path(str(packaged))
26
+ except (ModuleNotFoundError, TypeError, OSError):
27
+ pass
28
+
29
+ here = Path(__file__).resolve()
30
+ for cand in (
31
+ here.parents[2] / "rag" / "chunks.db",
32
+ Path("/root/eurekiusz-go/rag/chunks.db"),
33
+ ):
34
+ if cand.is_file():
35
+ return cand
36
+
37
+ raise SystemExit(
38
+ "Brak chunks.db. Ustaw EUREKIUSZ_DB albo skopiuj bazę do ~/.eurekiusz/chunks.db"
39
+ )
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: eurekiusz
3
+ Version: 0.1.0
4
+ Summary: Lokalny asystent ustaw podatkowych: ustawa → nowela → KIS. Bez zgadywania kwot.
5
+ Author-email: QA10 <hello@qa10.io>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://qa10.io
8
+ Keywords: vat,podatki,eli,kis,eureka
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Environment :: Console
11
+ Classifier: Environment :: Win32 (MS Windows)
12
+ Classifier: Intended Audience :: Legal Industry
13
+ Classifier: Topic :: Office/Business :: Financial
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Eurekiusz
18
+
19
+ Lokalny asystent ustaw podatkowych. Trzy warstwy: **ustawa → nowela → KIS**.
20
+ Model nie zgaduje kwot. Pusty kontekst = `Nie znalazlem w bazie.`
21
+
22
+ ## Instalacja
23
+
24
+ ```
25
+ pip install eurekiusz
26
+ ```
27
+
28
+ Windows (po `winget install Python.Python.3.12` i **nowym** PowerShellu):
29
+
30
+ ```
31
+ python -m pip install eurekiusz
32
+ ```
33
+
34
+ ## Użycie
35
+
36
+ ```
37
+ eurekiusz ask "jaki jest limit zwolnienia VAT 2026"
38
+ eurekiusz lookup DU/2025/775 113
39
+ eurekiusz health
40
+ eurekiusz gui
41
+ ```
42
+
43
+ Albo skrót okna: `eurekiusz-gui`.
44
+
45
+ Bramka: art. 113 TJ `DU/2025/775` = **200 000 zł**. Limit 240 000 zł to nowela `DU/2025/896` od 1.01.2026. Sama kwota 240k przy cytacie 775 = błąd.
46
+
47
+ ## Baza
48
+
49
+ Wheel zawiera `chunks.db` (~27 MB). Nadpisanie:
50
+
51
+ ```
52
+ set EUREKIUSZ_DB=C:\path\chunks.db
53
+ ```
54
+
55
+ albo plik `~/.eurekiusz/chunks.db`.
56
+
57
+ ## Czego tu nie ma
58
+
59
+ - modelu LLM / GGUF (osobny download na desktop Wails)
60
+ - Qdrant, 8 botów, chmury
61
+ - treści z bazy Optima `prawo_rachunkowe`
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/eurekiusz/__init__.py
4
+ src/eurekiusz/cli.py
5
+ src/eurekiusz/eureka.py
6
+ src/eurekiusz/gui.py
7
+ src/eurekiusz/law.py
8
+ src/eurekiusz/paths.py
9
+ src/eurekiusz.egg-info/PKG-INFO
10
+ src/eurekiusz.egg-info/SOURCES.txt
11
+ src/eurekiusz.egg-info/dependency_links.txt
12
+ src/eurekiusz.egg-info/entry_points.txt
13
+ src/eurekiusz.egg-info/top_level.txt
14
+ src/eurekiusz/data/chunks.db
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ eurekiusz = eurekiusz.cli:main
3
+
4
+ [gui_scripts]
5
+ eurekiusz-gui = eurekiusz.gui:main
@@ -0,0 +1 @@
1
+ eurekiusz