linexcel 0.1.0__py3-none-any.whl

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.
linexcel/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Analyse de data lineage pour classeurs Excel.
2
+
3
+ Pipeline : extraction des formules (formualizer, moteur Rust) →
4
+ regroupement des formules étirées (canonicalisation R1C1) →
5
+ graphe de dépendances (cellules, plages, noms définis, VBA) →
6
+ décomposition des fonctions composées avec évaluation pas-à-pas →
7
+ documentation automatique par IA (google-genai) fondée sur le
8
+ lignage déterministe.
9
+
10
+ Utilisable comme bibliothèque autonome (marimo, Jupyter, script), sans
11
+ serveur FastAPI ni clé IA :
12
+
13
+ from linexcel import analyze
14
+ result = analyze("classeur.xlsx") # -> LineageResult
15
+ result # graphe interactif dans marimo
16
+ result.save_html("lineage.html") # visualiseur autonome
17
+ result.stats, result.warnings # métadonnées
18
+ result.document(api_key="…") # documentation IA (optionnelle)
19
+ """
20
+
21
+ from linexcel.analyzer import analyze_workbook
22
+ from linexcel.result import LineageResult, analyze
23
+
24
+ __all__ = ["analyze", "LineageResult", "analyze_workbook"]
linexcel/aidoc.py ADDED
@@ -0,0 +1,158 @@
1
+ """Documentation automatique des calculs par IA (google-genai / Gemini).
2
+
3
+ Le modèle ne « devine » rien : chaque nœud lui est présenté avec son dossier
4
+ déterministe issu du graphe (formule exacte, décomposition évaluée étape par
5
+ étape, précédents et leurs valeurs, dépendants, étendue du groupe étiré,
6
+ liens VBA). La consigne impose de ne citer que ces faits, ce qui rend la
7
+ documentation « prouvable » : chaque affirmation renvoie à la formule ou à
8
+ une valeur du classeur.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from typing import Any
16
+
17
+ DEFAULT_MODEL = "gemini-2.5-flash"
18
+ MAX_DOSSIER_CHARS = 6_000
19
+
20
+ _SYSTEM = """Tu documentes des calculs Excel pour un lecteur métier francophone.
21
+ Pour le nœud fourni, rédige une fiche courte en Markdown :
22
+ 1. **Rôle** — une phrase sur ce que calcule la formule ;
23
+ 2. **Comment** — la logique, étape par étape, en t'appuyant STRICTEMENT sur la
24
+ décomposition fournie (cite les sous-expressions et leurs valeurs évaluées) ;
25
+ 3. **Sources** — d'où viennent les données (précédents, plages, noms, VBA) ;
26
+ 4. **Preuve** — la formule exacte et, si disponible, la valeur calculée.
27
+ Règles absolues : n'invente aucune donnée ; n'affirme rien qui ne soit pas dans
28
+ le dossier ; si une information manque, écris « non déterminé par le lignage ».
29
+ Réponds UNIQUEMENT avec la fiche Markdown, aucun JSON, aucun délimiteur."""
30
+
31
+
32
+ class AiDocError(RuntimeError):
33
+ pass
34
+
35
+
36
+ def _client(api_key: str | None = None):
37
+ try:
38
+ from google import genai
39
+ except ImportError as exc: # pragma: no cover
40
+ raise AiDocError(
41
+ "Le paquet google-genai n'est pas installé "
42
+ "(pip install 'backend[ai]' ou pip install google-genai)"
43
+ ) from exc
44
+ api_key = api_key or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
45
+ if not api_key:
46
+ raise AiDocError(
47
+ "Aucune clé Gemini fournie : passer api_key=... ou définir "
48
+ "GOOGLE_API_KEY dans l'environnement"
49
+ )
50
+ return genai.Client(api_key=api_key)
51
+
52
+
53
+ def build_dossier(graph: dict[str, Any], node_id: str) -> dict[str, Any] | None:
54
+ """Dossier déterministe d'un nœud : tout ce que l'IA a le droit d'utiliser."""
55
+ nodes = {n["id"]: n for n in graph["nodes"]}
56
+ node = nodes.get(node_id)
57
+ if node is None:
58
+ return None
59
+ precedents, dependents = [], []
60
+ for e in graph["edges"]:
61
+ if e["target"] == node_id:
62
+ src = nodes.get(e["source"], {})
63
+ precedents.append(_neighbor(src, e))
64
+ elif e["source"] == node_id:
65
+ dst = nodes.get(e["target"], {})
66
+ dependents.append(_neighbor(dst, e))
67
+ dossier = {
68
+ "node_id": node_id,
69
+ "kind": node.get("kind"),
70
+ "sheet": node.get("sheet"),
71
+ "adresse": node.get("addr"),
72
+ "formule": node.get("formula"),
73
+ "forme_r1c1": node.get("r1c1"),
74
+ "cellules_du_groupe": node.get("count"),
75
+ "etendue": node.get("bbox"),
76
+ "valeur_calculee": node.get("value"),
77
+ "exemples_valeurs": node.get("samples"),
78
+ "decomposition": _compact_steps(node.get("steps")),
79
+ "precedents": precedents[:30],
80
+ "dependants": dependents[:30],
81
+ }
82
+ if node.get("kind") == "vba":
83
+ dossier["vba"] = {
84
+ "module": node.get("module"),
85
+ "procedure": node.get("proc"),
86
+ "type": node.get("procKind"),
87
+ "code": (node.get("code") or "")[:2500],
88
+ }
89
+ return dossier
90
+
91
+
92
+ def _neighbor(other: dict, edge: dict) -> dict:
93
+ return {
94
+ "id": other.get("id"),
95
+ "kind": other.get("kind"),
96
+ "label": other.get("label"),
97
+ "lien": edge.get("kind"),
98
+ "valeur": other.get("value"),
99
+ "formule": other.get("formula"),
100
+ }
101
+
102
+
103
+ def _compact_steps(step: dict | None) -> dict | None:
104
+ if step is None:
105
+ return None
106
+ out = {
107
+ "expression": step.get("expr"),
108
+ "operation": step.get("label"),
109
+ "valeur": step.get("value") if step.get("evaluated") else "non évaluée",
110
+ }
111
+ if step.get("inputs"):
112
+ out["entrees"] = step["inputs"]
113
+ children = [_compact_steps(c) for c in step.get("children", [])]
114
+ if children:
115
+ out["sous_etapes"] = children
116
+ return out
117
+
118
+
119
+ def document_nodes(
120
+ graph: dict[str, Any],
121
+ node_ids: list[str],
122
+ model: str | None = None,
123
+ api_key: str | None = None,
124
+ ) -> dict[str, str]:
125
+ """Documente les nœuds demandés, par lots, et retourne {node_id: markdown}."""
126
+ client = _client(api_key)
127
+ model = model or os.getenv("GEMINI_MODEL", DEFAULT_MODEL)
128
+ docs: dict[str, str] = {}
129
+ dossiers = []
130
+ for nid in node_ids:
131
+ d = build_dossier(graph, nid)
132
+ if d is not None:
133
+ blob = json.dumps(d, ensure_ascii=False, default=str)
134
+ if len(blob) > MAX_DOSSIER_CHARS:
135
+ d["decomposition"] = "tronquée (formule très longue)"
136
+ blob = json.dumps(d, ensure_ascii=False, default=str)
137
+ dossiers.append((nid, blob))
138
+
139
+ for nid, blob in dossiers:
140
+ prompt = (
141
+ _SYSTEM
142
+ + "\n\nDossier de lignage (déterministe, extrait du classeur) :\n"
143
+ + blob
144
+ )
145
+ try:
146
+ response = client.models.generate_content(
147
+ model=model,
148
+ contents=prompt,
149
+ config={"temperature": 0.2},
150
+ )
151
+ text = (response.text or "").strip()
152
+ except AiDocError:
153
+ raise
154
+ except Exception as exc:
155
+ raise AiDocError(f"Appel Gemini en échec : {exc}") from exc
156
+ if text:
157
+ docs[nid] = text
158
+ return docs