{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "f1400112",
   "metadata": {},
   "source": [
    "# Libraries"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7d593da4",
   "metadata": {},
   "outputs": [],
   "source": [
    "from dataclasses import dataclass\n",
    "from typing import List, Protocol, Callable, Dict, Any\n",
    "import numpy as np\n",
    "from sentence_transformers import SentenceTransformer\n",
    "import tiktoken\n",
    "from transformers import AutoTokenizer"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "157bead3",
   "metadata": {},
   "source": [
    "# Chunking Methods"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4555f236",
   "metadata": {},
   "outputs": [],
   "source": [
    "@dataclass\n",
    "class Chunk:\n",
    "    text: str\n",
    "    meta: Dict[str, Any]\n",
    "\n",
    "\n",
    "class Chunker(Protocol):\n",
    "    name: str\n",
    "\n",
    "    def chunk(self, text: str, meta: Dict[str, Any]) -> List[Chunk]: ...\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "177e3b00",
   "metadata": {},
   "outputs": [],
   "source": [
    "class TokenChunker:\n",
    "    def __init__(\n",
    "        self, name: str, encoding_name=\"cl100k_base\", chunk_size=400, overlap=60\n",
    "    ):\n",
    "        self.name = name\n",
    "        self.enc = tiktoken.get_encoding(encoding_name)\n",
    "        self.chunk_size = chunk_size\n",
    "        self.overlap = overlap\n",
    "\n",
    "    def chunk(self, text: str, meta: Dict[str, Any]) -> List[Chunk]:\n",
    "        tokens = self.enc.encode(text)\n",
    "        chunks = []\n",
    "        start = 0\n",
    "        i = 0\n",
    "        while start < len(tokens):\n",
    "            end = min(start + self.chunk_size, len(tokens))\n",
    "            piece_tokens = tokens[start:end]\n",
    "            piece_text = self.enc.decode(piece_tokens)\n",
    "            chunks.append(\n",
    "                Chunk(piece_text, {**meta, \"chunk_index\": i, \"method\": self.name})\n",
    "            )\n",
    "            i += 1\n",
    "            start = end - self.overlap\n",
    "            if start < 0:\n",
    "                start = 0\n",
    "            if start >= len(tokens):\n",
    "                break\n",
    "        return chunks\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9bda4930",
   "metadata": {},
   "outputs": [],
   "source": [
    "def cosine(a, b):\n",
    "    a = a / (np.linalg.norm(a) + 1e-12)\n",
    "    b = b / (np.linalg.norm(b) + 1e-12)\n",
    "    return float(np.dot(a, b))\n",
    "\n",
    "class SemanticChunker:\n",
    "    def __init__(\n",
    "        self,\n",
    "        name: str,\n",
    "        embedder: SentenceTransformer,\n",
    "        max_tokens_fn: Callable[[str], int],\n",
    "        max_tokens=400,\n",
    "        similarity_threshold=0.78,\n",
    "        min_unit_chars=80,\n",
    "    ):\n",
    "        self.name = name\n",
    "        self.embedder = embedder\n",
    "        self.max_tokens_fn = max_tokens_fn\n",
    "        self.max_tokens = max_tokens\n",
    "        self.similarity_threshold = similarity_threshold\n",
    "        self.min_unit_chars = min_unit_chars\n",
    "\n",
    "    def _units(self, text: str) -> List[str]:\n",
    "        # Párrafos como unidad base (suele ser buen punto de partida)\n",
    "        parts = [p.strip() for p in text.split(\"\\n\\n\") if p.strip()]\n",
    "        # opcional: fusiona “párrafos enanos”\n",
    "        merged = []\n",
    "        buf = \"\"\n",
    "        for p in parts:\n",
    "            if len(buf) < self.min_unit_chars:\n",
    "                buf = (buf + \"\\n\\n\" + p).strip() if buf else p\n",
    "            else:\n",
    "                merged.append(buf)\n",
    "                buf = p\n",
    "        if buf:\n",
    "            merged.append(buf)\n",
    "        return merged\n",
    "\n",
    "    def chunk(self, text: str, meta: Dict[str, Any]) -> List[Chunk]:\n",
    "        units = self._units(text)\n",
    "        if not units:\n",
    "            return []\n",
    "\n",
    "        unit_embs = self.embedder.encode(units, normalize_embeddings=True)\n",
    "\n",
    "        chunks: List[Chunk] = []\n",
    "        cur_texts = [units[0]]\n",
    "        cur_center = unit_embs[0]\n",
    "        cur_tokens = self.max_tokens_fn(units[0])\n",
    "        chunk_index = 0\n",
    "\n",
    "        for i in range(1, len(units)):\n",
    "            u = units[i]\n",
    "            e = unit_embs[i]\n",
    "            u_tokens = self.max_tokens_fn(u)\n",
    "\n",
    "            sim = float(np.dot(cur_center, e))  # ya normalizado\n",
    "            would_tokens = cur_tokens + u_tokens\n",
    "\n",
    "            if sim >= self.similarity_threshold and would_tokens <= self.max_tokens:\n",
    "                cur_texts.append(u)\n",
    "                # actualiza “centro” como media (normalizada)\n",
    "                cur_center = cur_center + e\n",
    "                cur_center = cur_center / (np.linalg.norm(cur_center) + 1e-12)\n",
    "                cur_tokens = would_tokens\n",
    "            else:\n",
    "                chunks.append(Chunk(\"\\n\\n\".join(cur_texts), {**meta, \"chunk_index\": chunk_index, \"method\": self.name}))\n",
    "                chunk_index += 1\n",
    "                cur_texts = [u]\n",
    "                cur_center = e\n",
    "                cur_tokens = u_tokens\n",
    "\n",
    "        chunks.append(Chunk(\"\\n\\n\".join(cur_texts), {**meta, \"chunk_index\": chunk_index, \"method\": self.name}))\n",
    "        return chunks"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6c0fbd36",
   "metadata": {},
   "outputs": [],
   "source": [
    "def run_chunking(texts: List[Dict[str, Any]], chunkers: List[Chunker]) -> Dict[str, List[Chunk]]:\n",
    "    \"\"\"\n",
    "    texts: lista de {\"text\": ..., \"meta\": {...}}\n",
    "    \"\"\"\n",
    "    results = {}\n",
    "    for ch in chunkers:\n",
    "        all_chunks = []\n",
    "        for doc in texts:\n",
    "            all_chunks.extend(ch.chunk(doc[\"text\"], doc.get(\"meta\", {})))\n",
    "        results[ch.name] = all_chunks\n",
    "        print(f\"{ch.name}: {len(all_chunks)} chunks\")\n",
    "    return results"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a9c72266",
   "metadata": {},
   "outputs": [],
   "source": [
    "enc = tiktoken.get_encoding(\"cl100k_base\")\n",
    "def count_tokens(s: str) -> int:\n",
    "    return len(enc.encode(s))\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "90e0a6d7",
   "metadata": {},
   "source": [
    "### Prueba Chunk"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8a75f0e1",
   "metadata": {},
   "outputs": [],
   "source": [
    "embedder = SentenceTransformer(\"Qwen/Qwen3-Embedding-4B-GGUF\")\n",
    "\n",
    "token_chunker = TokenChunker(name=\"token_400_60\", chunk_size=400, overlap=60)\n",
    "semantic_chunker = SemanticChunker(\n",
    "    name=\"semantic_400_thr0.78\",\n",
    "    embedder=embedder,\n",
    "    max_tokens_fn=count_tokens,\n",
    "    max_tokens=400,\n",
    "    similarity_threshold=0.78,\n",
    ")\n",
    "\n",
    "texts = [\n",
    "    {\"text\": open(\"doc1.txt\", \"r\", encoding=\"utf-8\").read(), \"meta\": {\"source\": \"doc1.txt\"}},\n",
    "    {\"text\": open(\"doc2.txt\", \"r\", encoding=\"utf-8\").read(), \"meta\": {\"source\": \"doc2.txt\"}},\n",
    "]\n",
    "\n",
    "chunked = run_chunking(texts, [token_chunker, semantic_chunker])\n"
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
