chaskiwasi 0.2.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.
Files changed (41) hide show
  1. chaskiwasi-0.2.0/.github/workflows/ci.yml +31 -0
  2. chaskiwasi-0.2.0/.github/workflows/publish.yml +35 -0
  3. chaskiwasi-0.2.0/.gitignore +142 -0
  4. chaskiwasi-0.2.0/PKG-INFO +31 -0
  5. chaskiwasi-0.2.0/README.md +1 -0
  6. chaskiwasi-0.2.0/pyproject.toml +47 -0
  7. chaskiwasi-0.2.0/src/__init__.py +0 -0
  8. chaskiwasi-0.2.0/src/chaskiwasi/__init__.py +0 -0
  9. chaskiwasi-0.2.0/src/chaskiwasi/chunking/__init__.py +0 -0
  10. chaskiwasi-0.2.0/src/chaskiwasi/chunking/chunk_tokenizer.py +43 -0
  11. chaskiwasi-0.2.0/src/chaskiwasi/classification/__init__.py +0 -0
  12. chaskiwasi-0.2.0/src/chaskiwasi/classification/cascade_factory.py +68 -0
  13. chaskiwasi-0.2.0/src/chaskiwasi/classification/strategies/__init__.py +0 -0
  14. chaskiwasi-0.2.0/src/chaskiwasi/classification/strategies/base_strategy.py +43 -0
  15. chaskiwasi-0.2.0/src/chaskiwasi/classification/strategies/context_overlap_strategy.py +21 -0
  16. chaskiwasi-0.2.0/src/chaskiwasi/classification/strategies/cpu_regex_strategy.py +62 -0
  17. chaskiwasi-0.2.0/src/chaskiwasi/classification/strategies/gemini_streamer.py +99 -0
  18. chaskiwasi-0.2.0/src/chaskiwasi/classification/strategies/llm_router_strategy.py +71 -0
  19. chaskiwasi-0.2.0/src/chaskiwasi/config/__init__.py +0 -0
  20. chaskiwasi-0.2.0/src/chaskiwasi/config/settings.py +19 -0
  21. chaskiwasi-0.2.0/src/chaskiwasi/config/taxonomy_registry.py +55 -0
  22. chaskiwasi-0.2.0/src/chaskiwasi/consolidation/consolidator.py +158 -0
  23. chaskiwasi-0.2.0/src/chaskiwasi/ingestion/__init__.py +0 -0
  24. chaskiwasi-0.2.0/src/chaskiwasi/ingestion/docling_parser.py +64 -0
  25. chaskiwasi-0.2.0/src/chaskiwasi/query_engine/__init__.py +0 -0
  26. chaskiwasi-0.2.0/src/chaskiwasi/query_engine/cross_filter.py +74 -0
  27. chaskiwasi-0.2.0/src/chaskiwasi/query_engine/ollama_streamer.py +64 -0
  28. chaskiwasi-0.2.0/src/chaskiwasi/storage/__init__.py +0 -0
  29. chaskiwasi-0.2.0/src/chaskiwasi/storage/chroma_persistent.py +67 -0
  30. chaskiwasi-0.2.0/tests/conftest.py +42 -0
  31. chaskiwasi-0.2.0/tests/test_cascade_factory.py +134 -0
  32. chaskiwasi-0.2.0/tests/test_chroma_persistent.py +101 -0
  33. chaskiwasi-0.2.0/tests/test_chunk_tokenizer.py +99 -0
  34. chaskiwasi-0.2.0/tests/test_consolidation.py +168 -0
  35. chaskiwasi-0.2.0/tests/test_consolidator_batch.py +73 -0
  36. chaskiwasi-0.2.0/tests/test_core.py +2 -0
  37. chaskiwasi-0.2.0/tests/test_cross_filter.py +83 -0
  38. chaskiwasi-0.2.0/tests/test_docling_parser.py +86 -0
  39. chaskiwasi-0.2.0/tests/test_gemini_streamer.py +121 -0
  40. chaskiwasi-0.2.0/tests/test_llm_router_strategy.py +115 -0
  41. chaskiwasi-0.2.0/tests/test_ollama_streamer.py +159 -0
@@ -0,0 +1,31 @@
1
+ name: CI (Tests)
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main", "master" ]
6
+ pull_request:
7
+ branches: [ "main", "master" ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.10"
23
+
24
+ - name: Install dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ pip install pytest
28
+ pip install .
29
+ - name: Run tests
30
+ run: |
31
+ pytest
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch: # Permite activarlo manualmente desde la interfaz de GitHub
7
+
8
+ permissions:
9
+ id-token: write # Requerido obligatoriamente para Trusted Publishers (OIDC)
10
+
11
+ jobs:
12
+ pypi-publish:
13
+ name: Upload release to PyPI
14
+ runs-on: ubuntu-latest
15
+ environment:
16
+ name: pypi
17
+ url: https://pypi.org/p/chaskiwasi # Reemplaza con el nombre real de tu paquete en PyPI
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.10"
25
+
26
+ - name: Install build dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install build
30
+
31
+ - name: Build package
32
+ run: python -m build
33
+
34
+ - name: Publish package to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,142 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # Installer logs
30
+ pip-log.txt
31
+ pip-delete-this-directory.txt
32
+
33
+ # Unit test / coverage reports
34
+ htmlcov/
35
+ .tox/
36
+ .nox/
37
+ .coverage
38
+ .coverage.*
39
+ .cache
40
+ nosetests.xml
41
+ coverage.xml
42
+ *.cover
43
+ *.py,cover
44
+ .hypothesis/
45
+ .pytest_cache/
46
+ cover/
47
+
48
+ # Translations
49
+ *.mo
50
+ *.pot
51
+
52
+ # Django stuff
53
+ *.log
54
+ local_settings.py
55
+ db.sqlite3
56
+ db.sqlite3-journal
57
+
58
+ # Flask stuff
59
+ instance/
60
+ .webassets-cache
61
+
62
+ # Scrapy stuff
63
+ .scrapy
64
+
65
+ # Sphinx documentation
66
+ docs/_build/
67
+
68
+ # PyBuilder
69
+ .pybuilder/
70
+ target/
71
+
72
+ # Jupyter Notebook
73
+ .ipynb_checkpoints
74
+
75
+ # IPython
76
+ profile_default/
77
+ ipython_config.py
78
+
79
+ # pyenv
80
+ # For a library or package, you might want to ignore these files since the code is
81
+ # intended to run in multiple environments; otherwise, let them be committed:
82
+ .python-version
83
+
84
+ # Pipenv
85
+ #Rely on Pipenv to maintain the version lockfile, but commit locked dependencies:
86
+ #Pipfile.lock
87
+
88
+ # Poetry
89
+ #Rely on Poetry to maintain the version lockfile, but commit locked dependencies:
90
+ #poetry.lock
91
+
92
+ # pdm
93
+ #Rely on PDM to maintain the version lockfile, but commit locked dependencies:
94
+ #pdm.lock
95
+
96
+ # PEP 582; used by e.g. __pypackages__
97
+ __pypackages__/
98
+
99
+ # Celery stuff
100
+ celerybeat-schedule
101
+ celerybeat.pid
102
+
103
+ # SageMath parsed files
104
+ *.sage.py
105
+
106
+ # Environments
107
+ .env
108
+ .venv
109
+ env/
110
+ venv/
111
+ ENV/
112
+ env.bak/
113
+ venv.bak/
114
+
115
+ # Spyder project settings
116
+ .spyderproject
117
+ .spyproject
118
+
119
+ # Rope project settings
120
+ .ropeproject
121
+
122
+ # mypy
123
+ .mypy_cache/
124
+ .dmypy.json
125
+ .dmypy.root
126
+
127
+ # Pyre type checker
128
+ .pyre/
129
+
130
+ # pytype static type analyzer
131
+ .pytype/
132
+
133
+ # Cython debug symbols
134
+ cython_debug/
135
+
136
+ # IDEs and editors
137
+ .idea/
138
+ .vscode/
139
+ *.swp
140
+ *.swo
141
+ .DS_Store
142
+ Thumbs.db
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: chaskiwasi
3
+ Version: 0.2.0
4
+ Summary: Sistema de consulta semántica para fuentes jurídicas.
5
+ Project-URL: Homepage, https://github.com/Maorda/chaskywasi
6
+ Project-URL: Issues, https://github.com/Maorda/chaskywasi/issues
7
+ Author-email: Luis Maurtua <luis.maurtua@ejemplo.com>
8
+ License: MIT
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: chromadb>=0.5.0
17
+ Requires-Dist: docling>=2.0.0
18
+ Requires-Dist: google-genai>=0.1.0
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: ijson>=3.3.0
21
+ Requires-Dist: requests>=2.32.0
22
+ Requires-Dist: streamlit>=1.30.0
23
+ Requires-Dist: tiktoken>=0.7.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: anyio>=4.0.0; extra == 'dev'
26
+ Requires-Dist: faker>=20.0.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ GOLA
@@ -0,0 +1 @@
1
+ GOLA
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chaskiwasi" # <-- Cambiado al nombre real de tu librería
7
+ version = "0.2.0"
8
+ description = "Sistema de consulta semántica para fuentes jurídicas."
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "Luis Maurtua", email = "luis.maurtua@ejemplo.com" }
12
+ ]
13
+ license = { text = "MIT" }
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ requires-python = ">=3.10"
23
+ dependencies = [
24
+ "docling>=2.0.0",
25
+ "tiktoken>=0.7.0",
26
+ "chromadb>=0.5.0",
27
+ "ijson>=3.3.0",
28
+ "requests>=2.32.0",
29
+ "streamlit>=1.30.0",
30
+ "google-genai>=0.1.0",
31
+ "httpx>=0.27.0"
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0.0",
37
+ "pytest-asyncio>=0.23.0",
38
+ "anyio>=4.0.0",
39
+ "faker>=20.0.0"
40
+ ]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/Maorda/chaskywasi"
44
+ Issues = "https://github.com/Maorda/chaskywasi/issues"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/chaskiwasi"]
File without changes
File without changes
File without changes
@@ -0,0 +1,43 @@
1
+ # dantesito/chasky/chunking/chunk_tokenizer.py
2
+
3
+ from typing import List
4
+
5
+ import tiktoken
6
+
7
+ from chaskiwasi.config.settings import Settings
8
+
9
+
10
+ class ChunkTokenizer:
11
+ """Divide textos en fragmentos mediante ventanas de tokens de tiktoken."""
12
+
13
+ def __init__(self) -> None:
14
+ self._encoding = tiktoken.get_encoding(Settings.TIKTOKEN_ENCODING)
15
+
16
+ def split_text(self, text: str) -> List[str]:
17
+ """Divide el texto en chunks determinados exclusivamente por tokens."""
18
+ if not isinstance(text, str) or not text:
19
+ return []
20
+
21
+ tokens: List[int] = self._encoding.encode(text)
22
+
23
+ if not tokens:
24
+ return []
25
+
26
+ chunk_size: int = Settings.CHUNK_SIZE
27
+ step: int = Settings.CHUNK_SIZE - Settings.CHUNK_OVERLAP
28
+
29
+ chunks: List[str] = []
30
+ start: int = 0
31
+ total_tokens: int = len(tokens)
32
+
33
+ while start < total_tokens:
34
+ end: int = min(start + chunk_size, total_tokens)
35
+ chunk_tokens: List[int] = tokens[start:end]
36
+ chunks.append(self._encoding.decode(chunk_tokens))
37
+
38
+ if end >= total_tokens:
39
+ break
40
+
41
+ start += step
42
+
43
+ return chunks
@@ -0,0 +1,68 @@
1
+ import logging
2
+ from typing import Any, List, Optional, Tuple
3
+
4
+ from chaskiwasi.classification.strategies.base_strategy import BaseStrategy
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class CascadeFactory:
9
+ """
10
+ Fábrica y orquestador de clasificación en cascada agnóstico para chaskywasi.
11
+ Aplica una arquitectura de enrutamiento por estrategias ordenadas jerárquicamente.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ strategies: Optional[List[BaseStrategy]] = None,
17
+ default_source: Optional[Any] = None,
18
+ default_section: Optional[Any] = None,
19
+ ) -> None:
20
+ self._strategies: List[BaseStrategy] = strategies or []
21
+ self.default_source = default_source
22
+ self.default_section = default_section
23
+
24
+ def process_chunk(
25
+ self,
26
+ text: Optional[str],
27
+ current_source: Optional[Any] = None,
28
+ ) -> Tuple[Optional[Any], Optional[Any]]:
29
+ """Procesa un fragmento individual de texto iterando sobre la lista de estrategias."""
30
+ effective_default_source = current_source or self.default_source
31
+
32
+ if not text or not text.strip():
33
+ return effective_default_source, self.default_section
34
+
35
+ for strategy in self._strategies:
36
+ try:
37
+ src_res, sec_res = strategy.classify(
38
+ text, current_source=effective_default_source
39
+ )
40
+
41
+ if sec_res is not None:
42
+ final_source = src_res or effective_default_source
43
+ return final_source, sec_res
44
+ except Exception as e:
45
+ logger.warning(
46
+ "Estrategia %s falló durante la clasificación: %s",
47
+ strategy.__class__.__name__,
48
+ str(e),
49
+ )
50
+
51
+ return effective_default_source, self.default_section
52
+
53
+ def process_chunks_batch(
54
+ self,
55
+ chunks: List[str],
56
+ current_source: Optional[Any] = None,
57
+ ) -> List[Tuple[Optional[Any], Optional[Any]]]:
58
+ """Procesa una lista de fragmentos secuencialmente manteniendo el contexto actual."""
59
+ results = []
60
+ active_source = current_source or self.default_source
61
+
62
+ for chunk in chunks:
63
+ src, sec = self.process_chunk(chunk, current_source=active_source)
64
+ if src is not None:
65
+ active_source = src
66
+ results.append((src, sec))
67
+
68
+ return results
@@ -0,0 +1,43 @@
1
+ # dantesito/chasky/classification/strategies/base_strategy.py
2
+ from abc import ABC, abstractmethod
3
+ from typing import Tuple, Optional, Any
4
+
5
+ # 🚀 CONTRATO ACTIVO: Importación obligatoria para control de tipos dinámicos
6
+ from chaskiwasi.config.taxonomy_registry import TaxonomyRegistry
7
+
8
+
9
+ class BaseStrategy(ABC):
10
+ """
11
+ Interfaz contractual agnóstica para todas las estrategias de inferencia semántica.
12
+
13
+ Expone de forma segura las clases de Enums inyectadas por el cliente mediante
14
+ propiedades dinámicas de solo lectura, eliminando la fragilidad de constructores.
15
+ """
16
+
17
+ @property
18
+ def source_enum_class(self) -> Any:
19
+ """
20
+ Devuelve de forma dinámica la clase SourceEnum del cliente.
21
+ 🛡️ CORTAFUEGOS EN CALIENTE: Lanza RuntimeError si no fue inyectada al arrancar.
22
+ """
23
+ return TaxonomyRegistry.get_source_enum()
24
+
25
+ @property
26
+ def section_enum_class(self) -> Any:
27
+ """
28
+ Devuelve de forma dinámica la clase SectionEnum del cliente.
29
+ 🛡️ CORTAFUEGOS EN CALIENTE: Lanza RuntimeError si no fue inyectada al arrancar.
30
+ """
31
+ return TaxonomyRegistry.get_section_enum()
32
+
33
+ @abstractmethod
34
+ def classify(self, chunk: str, current_source: Optional[str] = None) -> Tuple[Optional[Any], Optional[Any]]:
35
+ """
36
+ Analiza un fragmento y devuelve una tupla conteniendo las instancias
37
+ de los Enums dinámicos del cliente (SourceEnum, SectionEnum).
38
+
39
+ :param chunk: Fragmento de texto Markdown a evaluar.
40
+ :param current_source: Contexto de origen acumulado o arrastrado.
41
+ :return: Tupla conteniendo (Instancia de SourceEnum, Instancia de SectionEnum)
42
+ """
43
+ pass
@@ -0,0 +1,21 @@
1
+ # dantesito/chasky/classification/strategies/context_overlap_strategy.py
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ from chaskiwasi.classification.strategies.base_strategy import BaseStrategy
6
+ from chaskiwasi.config.taxonomy_registry import SectionEnum, SourceEnum
7
+
8
+
9
+ class ContextOverlapStrategy(BaseStrategy):
10
+ """Arrastra la fuente institucional desde el contexto del chunk anterior."""
11
+
12
+ def classify(
13
+ self,
14
+ chunk_text: str,
15
+ current_source: Optional[SourceEnum] = None,
16
+ ) -> Tuple[Optional[SourceEnum], Optional[SectionEnum]]:
17
+ """Mantiene la fuente previa sin inspeccionar el contenido del chunk."""
18
+ if current_source is not None:
19
+ return current_source, None
20
+
21
+ return None, None
@@ -0,0 +1,62 @@
1
+ import re
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, List, Optional, Pattern, Tuple
4
+ from chaskiwasi.classification.strategies.base_strategy import BaseStrategy
5
+
6
+
7
+ @dataclass
8
+ class RegexRule:
9
+ """Estructura de datos para definir reglas de clasificación por Regex."""
10
+ source: Any
11
+ section: Any
12
+ patterns: List[str]
13
+ flags: int = re.IGNORECASE | re.DOTALL
14
+ compiled_patterns: List[Pattern[str]] = field(init=False, repr=False)
15
+
16
+ def __post_init__(self) -> None:
17
+ compiled: List[Pattern[str]] = []
18
+ for pattern in self.patterns:
19
+ try:
20
+ compiled.append(re.compile(pattern, self.flags))
21
+ except re.error as err:
22
+ raise ValueError(
23
+ f"Patrón Regex inválido '{pattern}' para la regla ({self.source}, {self.section}): {err}"
24
+ ) from err
25
+ self.compiled_patterns = compiled
26
+
27
+
28
+ class CPURegexStrategy(BaseStrategy):
29
+ """Estrategia genérica que ejecuta reglas Regex inyectadas."""
30
+
31
+ def __init__(self, rules: Optional[List[RegexRule]] = None) -> None:
32
+ self.rules: List[RegexRule] = list(rules) if rules else []
33
+
34
+ def add_rule(self, rule: RegexRule) -> None:
35
+ """Permite registrar reglas dinámicamente."""
36
+ self.rules.append(rule)
37
+
38
+ def classify(
39
+ self,
40
+ chunk_text: str,
41
+ current_source: Optional[Any] = None,
42
+ ) -> Tuple[Optional[Any], Optional[Any]]:
43
+ if not isinstance(chunk_text, str) or not chunk_text:
44
+ return None, None
45
+
46
+ # Evaluación con preferencia contextual por current_source si aplica
47
+ for rule in self.rules:
48
+ if current_source and rule.source != current_source:
49
+ continue
50
+
51
+ if any(pattern.search(chunk_text) for pattern in rule.compiled_patterns):
52
+ return rule.source, rule.section
53
+
54
+ # Segunda pasada para reglas generales si no coincidió en el contexto actual
55
+ if current_source:
56
+ for rule in self.rules:
57
+ if rule.source == current_source:
58
+ continue
59
+ if any(pattern.search(chunk_text) for pattern in rule.compiled_patterns):
60
+ return rule.source, rule.section
61
+
62
+ return None, None
@@ -0,0 +1,99 @@
1
+ import logging
2
+ import os
3
+ from typing import Any, Dict, Optional, Tuple
4
+
5
+ from google import genai
6
+ from google.genai import types
7
+
8
+ from chaskiwasi.classification.strategies.base_strategy import BaseStrategy
9
+
10
+ logging.getLogger("google.genai").setLevel(logging.ERROR)
11
+ logging.getLogger("google.genai._api_client").setLevel(logging.ERROR)
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GeminiStreamer(BaseStrategy):
17
+ """Estrategia de inferencia remota en la nube totalmente agnóstica al dominio."""
18
+
19
+ def __init__(
20
+ self,
21
+ system_instruction: str,
22
+ label_mapping: Dict[str, Any],
23
+ ) -> None:
24
+ self.system_instruction = system_instruction
25
+ self.label_mapping = label_mapping
26
+
27
+ api_key = ""
28
+ env_key = os.environ.get("GEMINI_API_KEY", "").strip()
29
+ if env_key:
30
+ api_key = env_key
31
+
32
+ if not api_key:
33
+ env_path = ".env"
34
+ if os.path.exists(env_path):
35
+ with open(env_path, "r", encoding="utf-8") as f:
36
+ for line in f:
37
+ clean_line = line.strip()
38
+ if clean_line.startswith("GEMINI_API_KEY"):
39
+ try:
40
+ parsed_tokens = clean_line.split("=", 1)
41
+ if len(parsed_tokens) == 2:
42
+ parsed_key = parsed_tokens[1].strip().strip('"').strip("'")
43
+ if parsed_key:
44
+ api_key = parsed_key
45
+ break
46
+ except Exception:
47
+ pass
48
+
49
+ if not api_key or not (api_key.startswith("AIzaSy") or api_key.startswith("AQ.")):
50
+ raise ValueError(
51
+ "🚨 ERROR CRÍTICO DE CONFIGURACIÓN: La variable 'GEMINI_API_KEY' "
52
+ "no está definida en el entorno ni en el archivo .env raíz."
53
+ )
54
+
55
+ self.client = genai.Client(api_key=api_key)
56
+ self.model_name = "gemini-3.5-flash-lite"
57
+
58
+ def classify(
59
+ self, chunk_text: str, current_source: Optional[Any] = None
60
+ ) -> Tuple[Optional[Any], Optional[Any]]:
61
+ if not chunk_text or not chunk_text.strip():
62
+ return None, None
63
+
64
+ try:
65
+ response = self.client.models.generate_content(
66
+ model=self.model_name,
67
+ contents=chunk_text,
68
+ config=types.GenerateContentConfig(
69
+ system_instruction=self.system_instruction,
70
+ temperature=0.1,
71
+ ),
72
+ )
73
+
74
+ if not response or not hasattr(response, "text") or not response.text:
75
+ logger.warning("Respuesta vacía o no válida recibida de la API de Gemini.")
76
+ return None, None
77
+
78
+ label = response.text.strip().lower()
79
+
80
+ if label == "desconocido":
81
+ return None, None
82
+
83
+ section_enum = self.label_mapping.get(label)
84
+ if section_enum is None:
85
+ logger.warning(
86
+ "Etiqueta desconocida o fuera de taxonomía devuelta por Gemini: '%s'", label
87
+ )
88
+ return None, None
89
+
90
+ # Asumimos que Gemini por ahora solo devuelve la sección, manteniendo la firma original
91
+ return None, section_enum
92
+
93
+ except Exception as e:
94
+ logger.error(
95
+ "Error durante la inferencia remota con el cliente Google GenAI: %s",
96
+ str(e),
97
+ exc_info=True,
98
+ )
99
+ return None, None