perplexity-notebooklm 0.2.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.
- notebooklm_write/__init__.py +1 -0
- notebooklm_write/add_source.py +130 -0
- notebooklm_write/browser.py +81 -0
- notebooklm_write/embed.py +49 -0
- notebooklm_write/ledger.py +184 -0
- notebooklm_write/nlm_http.py +284 -0
- perplexity_mcp/__init__.py +1 -0
- perplexity_mcp/adapter.py +67 -0
- perplexity_mcp/agentic.py +122 -0
- perplexity_mcp/auth_login.py +99 -0
- perplexity_mcp/backends/__init__.py +1 -0
- perplexity_mcp/backends/helallao.py +153 -0
- perplexity_mcp/cited_search.py +162 -0
- perplexity_mcp/doctor.py +96 -0
- perplexity_mcp/resilience.py +120 -0
- perplexity_mcp/security.py +63 -0
- perplexity_mcp/server.py +115 -0
- perplexity_mcp/validate.py +33 -0
- perplexity_mcp/verify_citations.py +138 -0
- perplexity_notebooklm-0.2.0.dist-info/METADATA +193 -0
- perplexity_notebooklm-0.2.0.dist-info/RECORD +24 -0
- perplexity_notebooklm-0.2.0.dist-info/WHEEL +4 -0
- perplexity_notebooklm-0.2.0.dist-info/entry_points.txt +4 -0
- perplexity_notebooklm-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Extensão de ESCRITA do NotebookLM (add_source via browser). Fase 3."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Escrita no NotebookLM: adiciona uma fonte (texto colado) a um notebook.
|
|
2
|
+
|
|
3
|
+
Anexa a um Chrome REAL já logado (--remote-debugging-port) — ver browser.py.
|
|
4
|
+
Por que não login automatizado: o Google bloqueia o login sob automação com
|
|
5
|
+
loop de challenge. Anexar numa sessão já logada contorna isso.
|
|
6
|
+
|
|
7
|
+
Ação NÃO-reversível na conta Google → o skill SEMPRE pede checkpoint humano
|
|
8
|
+
antes de chamar isto.
|
|
9
|
+
|
|
10
|
+
Seletores da UI do NotebookLM (pt-BR/en), validados ao vivo. Se a UI mudar,
|
|
11
|
+
ajuste só SELECTORS aqui.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
NOTEBOOKLM_HOME = "https://notebooklm.google.com"
|
|
19
|
+
|
|
20
|
+
SELECTORS = {
|
|
21
|
+
"add_source_button": [
|
|
22
|
+
"button[aria-label*='Adicionar fonte' i]",
|
|
23
|
+
"button[aria-label*='Add source' i]",
|
|
24
|
+
],
|
|
25
|
+
# opção "Texto copiado" / "Copied text" / "Paste text".
|
|
26
|
+
# O rótulo é um <span>; o clicável é o botão/role ancestral. Miramos os dois
|
|
27
|
+
# (o JS-click no span borbulha pro handler do pai).
|
|
28
|
+
"paste_text_option": [
|
|
29
|
+
"//button[.//span[normalize-space()='Texto copiado' or normalize-space()='Copied text' or normalize-space()='Paste text']]",
|
|
30
|
+
"//*[@role='button'][.//*[normalize-space()='Texto copiado' or normalize-space()='Copied text' or normalize-space()='Paste text']]",
|
|
31
|
+
"//span[normalize-space()='Texto copiado' or normalize-space()='Copied text' or normalize-space()='Paste text']",
|
|
32
|
+
],
|
|
33
|
+
"text_area": [
|
|
34
|
+
"textarea[aria-label='Texto colado']",
|
|
35
|
+
"textarea[placeholder*='Cole o texto' i]",
|
|
36
|
+
"textarea[placeholder*='Paste' i]",
|
|
37
|
+
],
|
|
38
|
+
"insert_button": [
|
|
39
|
+
# Escopado ao diálogo p/ não casar um 'Inserir' de outro painel.
|
|
40
|
+
"//*[@role='dialog']//button[normalize-space()='Inserir' or normalize-space()='Insert']",
|
|
41
|
+
"//button[normalize-space()='Inserir' or normalize-space()='Insert']",
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class AddSourceResult:
|
|
48
|
+
notebook_id: str
|
|
49
|
+
ok: bool
|
|
50
|
+
detail: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add_source(notebook_id: str, text: str, *, debug_port: int = 9222) -> AddSourceResult:
|
|
54
|
+
"""Anexa ao Chrome logado, abre o notebook e injeta ``text`` como fonte.
|
|
55
|
+
|
|
56
|
+
Pré-requisito: Chrome aberto com --remote-debugging-port=<debug_port> e logado.
|
|
57
|
+
"""
|
|
58
|
+
import time
|
|
59
|
+
|
|
60
|
+
from selenium.webdriver.common.by import By
|
|
61
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
62
|
+
from selenium.webdriver.support.ui import WebDriverWait
|
|
63
|
+
|
|
64
|
+
from .browser import attach_driver, is_authenticated
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
driver = attach_driver(debug_port)
|
|
68
|
+
# Sempre recarrega: garante estado limpo (fecha diálogos/painéis abertos).
|
|
69
|
+
driver.get(f"{NOTEBOOKLM_HOME}/notebook/{notebook_id}")
|
|
70
|
+
time.sleep(6)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
# Chrome :9222 fechado/inacessível → erro limpo, sem traceback propagado.
|
|
73
|
+
return AddSourceResult(
|
|
74
|
+
notebook_id, False, f"Chrome :{debug_port} inacessível (abra o Chrome logado): {type(exc).__name__}"
|
|
75
|
+
)
|
|
76
|
+
if not is_authenticated(driver):
|
|
77
|
+
return AddSourceResult(notebook_id, False, "Sessão não autenticada no NotebookLM.")
|
|
78
|
+
|
|
79
|
+
wait = WebDriverWait(driver, 20)
|
|
80
|
+
|
|
81
|
+
def click(spec_list):
|
|
82
|
+
# Tenta clique normal; se o elemento existe mas está animando/coberto,
|
|
83
|
+
# cai pra clique via JS (robusto a overlays/transições da UI).
|
|
84
|
+
last = None
|
|
85
|
+
for sel in spec_list:
|
|
86
|
+
by = By.XPATH if sel.startswith("//") else By.CSS_SELECTOR
|
|
87
|
+
try:
|
|
88
|
+
wait.until(EC.element_to_be_clickable((by, sel))).click()
|
|
89
|
+
return
|
|
90
|
+
except Exception as e:
|
|
91
|
+
last = e
|
|
92
|
+
try:
|
|
93
|
+
el = wait.until(EC.presence_of_element_located((by, sel)))
|
|
94
|
+
driver.execute_script("arguments[0].scrollIntoView({block:'center'});", el)
|
|
95
|
+
driver.execute_script("arguments[0].click();", el)
|
|
96
|
+
return
|
|
97
|
+
except Exception as e2:
|
|
98
|
+
last = e2
|
|
99
|
+
raise RuntimeError(f"Nenhum seletor clicável: {spec_list} ({last})")
|
|
100
|
+
|
|
101
|
+
def find(spec_list):
|
|
102
|
+
last = None
|
|
103
|
+
for sel in spec_list:
|
|
104
|
+
by = By.XPATH if sel.startswith("//") else By.CSS_SELECTOR
|
|
105
|
+
try:
|
|
106
|
+
return wait.until(EC.presence_of_element_located((by, sel)))
|
|
107
|
+
except Exception as e:
|
|
108
|
+
last = e
|
|
109
|
+
raise RuntimeError(f"Nenhum seletor presente: {spec_list} ({last})")
|
|
110
|
+
|
|
111
|
+
step = "init"
|
|
112
|
+
try:
|
|
113
|
+
step = "abrir 'Adicionar fonte'"
|
|
114
|
+
click(SELECTORS["add_source_button"])
|
|
115
|
+
time.sleep(3) # diálogo de opções renderiza
|
|
116
|
+
step = "escolher 'Texto copiado'"
|
|
117
|
+
click(SELECTORS["paste_text_option"])
|
|
118
|
+
time.sleep(2)
|
|
119
|
+
step = "localizar textarea"
|
|
120
|
+
area = find(SELECTORS["text_area"])
|
|
121
|
+
area.click()
|
|
122
|
+
area.send_keys(text)
|
|
123
|
+
time.sleep(1)
|
|
124
|
+
step = "clicar 'Inserir'"
|
|
125
|
+
click(SELECTORS["insert_button"])
|
|
126
|
+
time.sleep(4) # ingestão da fonte
|
|
127
|
+
return AddSourceResult(notebook_id, True, "Fonte adicionada (confirme visualmente).")
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
# devolve o erro pro orquestrador com o passo que falhou; não silencia.
|
|
130
|
+
return AddSourceResult(notebook_id, False, f"Falha no passo [{step}]: {exc}")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Launcher Selenium próprio para o NotebookLM.
|
|
2
|
+
|
|
3
|
+
Por que não usar o NotebookLMClient: seu fallback de Selenium normal NÃO amarra
|
|
4
|
+
o perfil persistente (--user-data-dir), então o login nunca persiste. Aqui
|
|
5
|
+
amarramos o perfil explicitamente. Selenium Manager resolve o chromedriver certo
|
|
6
|
+
para a versão instalada do Chrome (149+), sem undetected-chromedriver (que não
|
|
7
|
+
conecta nessa versão).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
NOTEBOOKLM_HOME = "https://notebooklm.google.com"
|
|
16
|
+
DEFAULT_PROFILE_DIR = os.environ.get(
|
|
17
|
+
"NOTEBOOKLM_PROFILE_DIR",
|
|
18
|
+
str(Path.home() / ".notebooklm-mcp" / "chrome-profile"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_driver(profile_dir: str = DEFAULT_PROFILE_DIR, *, headless: bool = False):
|
|
23
|
+
"""Cria um Chrome Selenium amarrado ao perfil persistente.
|
|
24
|
+
|
|
25
|
+
O perfil persistente é o que mantém a sessão Google entre execuções — é o
|
|
26
|
+
ponto que o fallback do notebooklm-mcp erra.
|
|
27
|
+
"""
|
|
28
|
+
from selenium import webdriver
|
|
29
|
+
from selenium.webdriver.chrome.options import Options
|
|
30
|
+
|
|
31
|
+
Path(profile_dir).mkdir(parents=True, exist_ok=True)
|
|
32
|
+
opts = Options()
|
|
33
|
+
opts.add_argument(f"--user-data-dir={profile_dir}")
|
|
34
|
+
opts.add_argument("--no-first-run")
|
|
35
|
+
opts.add_argument("--no-default-browser-check")
|
|
36
|
+
opts.add_argument("--disable-blink-features=AutomationControlled")
|
|
37
|
+
opts.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
38
|
+
opts.add_experimental_option("useAutomationExtension", False)
|
|
39
|
+
if headless:
|
|
40
|
+
opts.add_argument("--headless=new")
|
|
41
|
+
|
|
42
|
+
driver = webdriver.Chrome(options=opts) # Selenium Manager baixa o driver certo
|
|
43
|
+
driver.execute_script(
|
|
44
|
+
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
|
45
|
+
)
|
|
46
|
+
return driver
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def attach_driver(debug_port: int = 9222):
|
|
50
|
+
"""Anexa a um Chrome REAL já aberto com --remote-debugging-port.
|
|
51
|
+
|
|
52
|
+
Caminho robusto: o login acontece num Chrome normal (sem automação → o Google
|
|
53
|
+
não dispara o loop de challenge anti-bot). O Selenium só anexa na sessão já
|
|
54
|
+
logada. Pré-requisito: o usuário abriu o Chrome com a porta de debug e logou.
|
|
55
|
+
"""
|
|
56
|
+
from selenium import webdriver
|
|
57
|
+
from selenium.webdriver.chrome.options import Options
|
|
58
|
+
|
|
59
|
+
opts = Options()
|
|
60
|
+
opts.add_experimental_option("debuggerAddress", f"127.0.0.1:{debug_port}")
|
|
61
|
+
driver = webdriver.Chrome(options=opts)
|
|
62
|
+
_focus_notebooklm(driver)
|
|
63
|
+
return driver
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _focus_notebooklm(driver) -> None:
|
|
67
|
+
"""Foca a aba já aberta do NotebookLM (a sessão logada), não uma about:blank."""
|
|
68
|
+
for handle in driver.window_handles:
|
|
69
|
+
driver.switch_to.window(handle)
|
|
70
|
+
if "notebooklm.google.com" in driver.current_url:
|
|
71
|
+
return
|
|
72
|
+
# nenhuma aba NotebookLM: fica na primeira (será navegada por quem chamou)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_authenticated(driver) -> bool:
|
|
76
|
+
"""Heurística: estamos no NotebookLM e não numa tela de login Google."""
|
|
77
|
+
url = driver.current_url
|
|
78
|
+
signed_out = (
|
|
79
|
+
"accounts.google.com" in url or "/login" in url or "signin" in url
|
|
80
|
+
)
|
|
81
|
+
return (not signed_out) and "notebooklm.google.com" in url
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Embeddings locais (model2vec, estático, SEM API key/torch) p/ dedup semântico.
|
|
2
|
+
|
|
3
|
+
Lazy + GRACIOSO: se model2vec/o modelo não estiverem disponíveis, `embed` retorna
|
|
4
|
+
None e o chamador cai no MinHash. O modelo (~30MB) baixa no primeiro uso.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
_MODEL_NAME = "minishlab/potion-base-8M"
|
|
12
|
+
_model = None
|
|
13
|
+
_failed = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_model():
|
|
17
|
+
global _model, _failed
|
|
18
|
+
if _model is None and not _failed:
|
|
19
|
+
try:
|
|
20
|
+
from model2vec import StaticModel
|
|
21
|
+
|
|
22
|
+
_model = StaticModel.from_pretrained(_MODEL_NAME)
|
|
23
|
+
except Exception:
|
|
24
|
+
_failed = True # sem lib/modelo/rede → degrada p/ MinHash
|
|
25
|
+
return _model
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def available() -> bool:
|
|
29
|
+
return _get_model() is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def embed(text: str) -> list[float] | None:
|
|
33
|
+
"""Vetor do texto (lista de floats) ou None se embedder indisponível."""
|
|
34
|
+
if not (text and text.strip()):
|
|
35
|
+
return None # texto vazio → vetor degenerado; não armazena
|
|
36
|
+
m = _get_model()
|
|
37
|
+
if m is None:
|
|
38
|
+
return None
|
|
39
|
+
vec = m.encode([text])[0]
|
|
40
|
+
return [round(float(x), 5) for x in vec]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cosine(a: list[float] | None, b: list[float] | None) -> float:
|
|
44
|
+
if not a or not b or len(a) != len(b):
|
|
45
|
+
return 0.0
|
|
46
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
47
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
48
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
49
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Ledger de fontes ingeridas + dedup near-duplicate (sem embeddings/API).
|
|
2
|
+
|
|
3
|
+
Evita re-injetar a mesma pesquisa (ou quase-igual) num notebook. Dois sinais:
|
|
4
|
+
- hash exato do conteúdo normalizado (dup idêntica).
|
|
5
|
+
- MinHash sobre shingles de 3 tokens → estimativa de Jaccard (near-dup reescrito).
|
|
6
|
+
|
|
7
|
+
Dependency-free e determinístico (coeficientes derivados do índice, não aleatórios).
|
|
8
|
+
Persistido em ~/.notebooklm/ledger.json. NÃO é dedup semântico real (isso exigiria
|
|
9
|
+
embeddings) — é near-dup lexical, honesto sobre o que detecta.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
_LEDGER = Path.home() / ".notebooklm" / "ledger.json"
|
|
21
|
+
_WORD = re.compile(r"[a-z0-9áàâãéêíóôõúüç]+", re.IGNORECASE)
|
|
22
|
+
_NUM_PERM = 64 # tamanho da assinatura MinHash
|
|
23
|
+
_PRIME = (1 << 61) - 1
|
|
24
|
+
_SHINGLE = 3 # n-gram de tokens
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _norm_tokens(text: str) -> list[str]:
|
|
28
|
+
return _WORD.findall((text or "").lower())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _content_hash(text: str) -> str:
|
|
32
|
+
return hashlib.sha256(" ".join(_norm_tokens(text)).encode("utf-8")).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _query_key(query: str) -> str:
|
|
36
|
+
"""Chave de query normalizada (case/espaço), preservando a ORDEM dos tokens.
|
|
37
|
+
|
|
38
|
+
Só casa re-runs da MESMA query (ex.: 'Fusão 2026' == 'fusao 2026'). NÃO funde
|
|
39
|
+
reordenações nem queries diferentes que reusam as mesmas palavras — evita
|
|
40
|
+
falso-skip (pular pesquisa nova) antes do Pro call. Precisão > recall aqui."""
|
|
41
|
+
return " ".join(_norm_tokens(query))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _shingles(tokens: list[str]) -> set[int]:
|
|
45
|
+
if len(tokens) < _SHINGLE:
|
|
46
|
+
joined = " ".join(tokens)
|
|
47
|
+
return {int(hashlib.md5(joined.encode()).hexdigest(), 16)} if joined else set()
|
|
48
|
+
out = set()
|
|
49
|
+
for i in range(len(tokens) - _SHINGLE + 1):
|
|
50
|
+
s = " ".join(tokens[i : i + _SHINGLE])
|
|
51
|
+
out.add(int(hashlib.md5(s.encode()).hexdigest(), 16))
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _minhash(text: str) -> list[int]:
|
|
56
|
+
"""Assinatura MinHash determinística (coeficientes por índice)."""
|
|
57
|
+
sh = _shingles(_norm_tokens(text))
|
|
58
|
+
if not sh:
|
|
59
|
+
return [0] * _NUM_PERM
|
|
60
|
+
sig = []
|
|
61
|
+
for i in range(_NUM_PERM):
|
|
62
|
+
a = 2 * i + 1
|
|
63
|
+
b = i * i + 1
|
|
64
|
+
sig.append(min(((a * (x % _PRIME) + b) % _PRIME) for x in sh))
|
|
65
|
+
return sig
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _similarity(sig_a: list[int], sig_b: list[int]) -> float:
|
|
69
|
+
if not sig_a or not sig_b or len(sig_a) != len(sig_b):
|
|
70
|
+
return 0.0
|
|
71
|
+
return sum(1 for x, y in zip(sig_a, sig_b) if x == y) / len(sig_a)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class DupCheck:
|
|
76
|
+
is_dup: bool
|
|
77
|
+
similarity: float
|
|
78
|
+
matched_source_id: str | None
|
|
79
|
+
reason: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load(path: Path) -> dict:
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
85
|
+
except Exception:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _save(path: Path, data: dict) -> None:
|
|
90
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_duplicate(
|
|
95
|
+
notebook_id: str,
|
|
96
|
+
content: str,
|
|
97
|
+
*,
|
|
98
|
+
threshold: float = 0.8,
|
|
99
|
+
semantic: bool = False,
|
|
100
|
+
semantic_threshold: float = 0.85,
|
|
101
|
+
path: Path = _LEDGER,
|
|
102
|
+
) -> DupCheck:
|
|
103
|
+
"""Verifica se `content` já foi ingerido (idêntico, near-verbatim, ou semântico).
|
|
104
|
+
|
|
105
|
+
Camadas: hash exato → MinHash (near-verbatim) → embeddings (semântico, se
|
|
106
|
+
`semantic=True` e o embedder local estiver disponível; senão ignora gracioso).
|
|
107
|
+
"""
|
|
108
|
+
entries = _load(path).get(notebook_id, [])
|
|
109
|
+
chash = _content_hash(content)
|
|
110
|
+
sig = _minhash(content)
|
|
111
|
+
|
|
112
|
+
# Embedding do conteúdo (só se pedido e disponível) — caminho semântico.
|
|
113
|
+
cvec = None
|
|
114
|
+
if semantic:
|
|
115
|
+
try:
|
|
116
|
+
from .embed import embed
|
|
117
|
+
|
|
118
|
+
cvec = embed(content)
|
|
119
|
+
except Exception:
|
|
120
|
+
cvec = None
|
|
121
|
+
|
|
122
|
+
best = 0.0
|
|
123
|
+
best_id = None
|
|
124
|
+
best_sem = 0.0
|
|
125
|
+
best_sem_id = None
|
|
126
|
+
for e in entries:
|
|
127
|
+
if e.get("hash") == chash:
|
|
128
|
+
return DupCheck(True, 1.0, e.get("source_id"), "hash idêntico")
|
|
129
|
+
sim = _similarity(sig, e.get("minhash", []))
|
|
130
|
+
if sim > best:
|
|
131
|
+
best, best_id = sim, e.get("source_id")
|
|
132
|
+
if cvec is not None and e.get("embedding"):
|
|
133
|
+
from .embed import cosine
|
|
134
|
+
|
|
135
|
+
csim = cosine(cvec, e["embedding"])
|
|
136
|
+
if csim > best_sem:
|
|
137
|
+
best_sem, best_sem_id = csim, e.get("source_id")
|
|
138
|
+
|
|
139
|
+
if best_sem >= semantic_threshold:
|
|
140
|
+
return DupCheck(True, round(best_sem, 3), best_sem_id, f"semântico (cos={best_sem:.2f})")
|
|
141
|
+
if best >= threshold:
|
|
142
|
+
return DupCheck(True, round(best, 3), best_id, f"near-dup (sim={best:.2f})")
|
|
143
|
+
return DupCheck(False, round(max(best, best_sem), 3), None, "novo")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def query_seen(notebook_id: str, query: str, *, path: Path = _LEDGER) -> str | None:
|
|
147
|
+
"""Retorna o source_id se a MESMA query já foi ingerida nesse notebook, senão None.
|
|
148
|
+
|
|
149
|
+
Pula re-pesquisa do mesmo tema ANTES de gastar a chamada Pro. Só casa queries
|
|
150
|
+
com a mesma chave (tokens únicos ordenados); query nova/diferente → None."""
|
|
151
|
+
qk = _query_key(query)
|
|
152
|
+
if not qk:
|
|
153
|
+
return None
|
|
154
|
+
for e in _load(path).get(notebook_id, []):
|
|
155
|
+
if e.get("query_key") == qk:
|
|
156
|
+
return e.get("source_id") or "(sem id)"
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def record(
|
|
161
|
+
notebook_id: str, content: str, title: str, source_id: str | None, *, query: str | None = None, path: Path = _LEDGER
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Registra uma fonte ingerida (hash + minhash + query_key + metadados)."""
|
|
164
|
+
# Embedding semântico (gracioso: None se o embedder local não estiver disponível).
|
|
165
|
+
emb = None
|
|
166
|
+
try:
|
|
167
|
+
from .embed import embed as _embed
|
|
168
|
+
|
|
169
|
+
emb = _embed(content)
|
|
170
|
+
except Exception:
|
|
171
|
+
emb = None
|
|
172
|
+
|
|
173
|
+
data = _load(path)
|
|
174
|
+
entry = {
|
|
175
|
+
"hash": _content_hash(content),
|
|
176
|
+
"minhash": _minhash(content),
|
|
177
|
+
"query_key": _query_key(query) if query else "",
|
|
178
|
+
"title": title,
|
|
179
|
+
"source_id": source_id,
|
|
180
|
+
}
|
|
181
|
+
if emb is not None:
|
|
182
|
+
entry["embedding"] = emb
|
|
183
|
+
data.setdefault(notebook_id, []).append(entry)
|
|
184
|
+
_save(path, data)
|