Initial commit: Pegas Memory (PG + Qdrant + Memgraph, MCP server, 14 tools)
This commit is contained in:
commit
83e77ea8c9
24 files changed
+2201
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Pegas Memory — подсистема памяти Alicorn."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Граф зависимостей кода: calls/imports/inherits через tree-sitter.
|
||||
|
||||
Строит ориентированный граф зависимостей между символами кода:
|
||||
- calls: функция A вызывает функцию B
|
||||
- imports: файл импортирует модуль (src = имя файла без расширения)
|
||||
- inherits: класс B наследует класс A
|
||||
|
||||
Извлекает из tree-sitter AST и сохраняет в pm_code_dependency (PostgreSQL).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .code_symbols import _parser
|
||||
|
||||
_FUNCTION_TYPES = ("function_definition", "function_item", "function_declaration", "method_definition")
|
||||
_CLASS_TYPES = ("class_definition", "class_declaration")
|
||||
|
||||
|
||||
def _sym_name(node, sb: bytes) -> str | None:
|
||||
n = node.child_by_field_name("name")
|
||||
return sb[n.start_byte:n.end_byte].decode("utf-8", "replace") if n else None
|
||||
|
||||
|
||||
def _call_target(node, sb: bytes) -> str | None:
|
||||
"""Имя вызываемой функции из call-узла."""
|
||||
fn = node.child_by_field_name("function")
|
||||
if fn is None:
|
||||
return None
|
||||
if fn.type == "identifier":
|
||||
return sb[fn.start_byte:fn.end_byte].decode("utf-8", "replace")
|
||||
if fn.type == "attribute":
|
||||
parts = [sb[c.start_byte:c.end_byte].decode("utf-8", "replace")
|
||||
for c in fn.children if c.type == "identifier"]
|
||||
return parts[-1] if parts else None
|
||||
return None
|
||||
|
||||
|
||||
def _collect_calls(node, owner, sb, add_fn):
|
||||
"""Рекурсивно собрать вызовы внутри тела функции/метода."""
|
||||
if node.type == "call":
|
||||
t = _call_target(node, sb)
|
||||
if t:
|
||||
add_fn(owner, t, "calls")
|
||||
for ch in node.children:
|
||||
_collect_calls(ch, owner, sb, add_fn)
|
||||
|
||||
|
||||
def _collect_inherits(node, owner, sb, add_fn):
|
||||
"""Найти базовые классы (superclasses/heritage_clause)."""
|
||||
for ch in node.children:
|
||||
if ch.type in ("argument_list", "superclasses", "heritage_clause", "base_list"):
|
||||
for sub in _iter(ch):
|
||||
if sub.type == "identifier":
|
||||
add_fn(owner, sb[sub.start_byte:sub.end_byte].decode("utf-8", "replace"), "inherits")
|
||||
elif sub.type == "attribute":
|
||||
parts = [sb[p.start_byte:p.end_byte].decode("utf-8", "replace")
|
||||
for p in sub.children if p.type == "identifier"]
|
||||
if parts:
|
||||
add_fn(owner, parts[-1], "inherits")
|
||||
|
||||
|
||||
def _collect_imports(root, module_name, sb, add_fn):
|
||||
"""Собрать импорты (import X / from Y import Z)."""
|
||||
for node in _iter(root):
|
||||
if node.type == "import_statement":
|
||||
for ch in node.children:
|
||||
if ch.type in ("dotted_name", "identifier"):
|
||||
add_fn(module_name, sb[ch.start_byte:ch.end_byte].decode("utf-8", "replace"), "imports")
|
||||
break
|
||||
elif node.type == "import_from_statement":
|
||||
mod = node.child_by_field_name("module_name")
|
||||
if mod is not None:
|
||||
target = sb[mod.start_byte:mod.end_byte].decode("utf-8", "replace")
|
||||
# отфильтровать артефакты: "." (relative import), "..", пустые
|
||||
if target not in (".", "..", ""):
|
||||
add_fn(module_name, target, "imports")
|
||||
|
||||
|
||||
def _iter(node):
|
||||
yield node
|
||||
for ch in node.children:
|
||||
yield from _iter(ch)
|
||||
|
||||
|
||||
def extract_dependencies(source: str, language: str, repo: str = "", path: str = "") -> list[dict]:
|
||||
"""Вернуть зависимости [{repo,path,language,src,dst,kind}] из исходника."""
|
||||
parser, _ = _parser(language)
|
||||
sb = source.encode("utf-8")
|
||||
tree = parser.parse(sb)
|
||||
deps: list[dict] = []
|
||||
seen: set[tuple] = set()
|
||||
|
||||
def add(src: str, dst: str, kind: str):
|
||||
if not dst:
|
||||
return
|
||||
key = (src, dst, kind)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deps.append({"repo": repo, "path": path, "language": language,
|
||||
"src": src, "dst": dst, "kind": kind})
|
||||
|
||||
module_name = path.rsplit("/", 1)[-1].rsplit(".", 1)[0] if path else "__module__"
|
||||
|
||||
def walk(node):
|
||||
t = node.type
|
||||
if t in _FUNCTION_TYPES:
|
||||
owner = _sym_name(node, sb)
|
||||
body = node.child_by_field_name("body")
|
||||
if body is not None and owner:
|
||||
_collect_calls(body, owner, sb, add)
|
||||
return # не спускаемся в вложенные scope как отдельные
|
||||
if t in _CLASS_TYPES:
|
||||
owner = _sym_name(node, sb)
|
||||
if owner:
|
||||
_collect_inherits(node, owner, sb, add)
|
||||
body = node.child_by_field_name("body")
|
||||
if body is not None:
|
||||
for ch in body.children:
|
||||
walk(ch) # методы класса — вложенные function_definition
|
||||
return
|
||||
for ch in node.children:
|
||||
walk(ch)
|
||||
|
||||
_collect_imports(tree.root_node, module_name, sb, add)
|
||||
walk(tree.root_node)
|
||||
return deps
|
||||
|
||||
|
||||
def store_dependencies(conn, source: str, language: str, repo: str = "", path: str = "",
|
||||
sync_memgraph: bool = True) -> int:
|
||||
"""Извлечь и сохранить зависимости кода (+ рёбра Memgraph).
|
||||
|
||||
Возвращает число сохранённых.
|
||||
"""
|
||||
from . import memgraph_store
|
||||
deps = extract_dependencies(source, language, repo, path)
|
||||
n = 0
|
||||
with conn.cursor() as cur:
|
||||
for d in deps:
|
||||
cur.execute(
|
||||
"""INSERT INTO pm_code_dependency (repo, path, language, src, dst, kind)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (repo, path, src, dst, kind) DO NOTHING""",
|
||||
(d["repo"], d["path"], d["language"], d["src"], d["dst"], d["kind"]),
|
||||
)
|
||||
n += 1
|
||||
if sync_memgraph:
|
||||
try:
|
||||
memgraph_store.upsert_code_dependency(
|
||||
d["src"], d["dst"], d["kind"], repo=d["repo"], path=d["path"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
return n
|
||||
|
||||
|
||||
def store_file_dependencies(conn, path: str, repo: str = "") -> int:
|
||||
"""По расширению файла определить язык и сохранить зависимости."""
|
||||
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
||||
lang = {".py": "python", ".js": "javascript", ".ts": "typescript", ".rs": "rust"}.get("." + ext)
|
||||
if lang is None:
|
||||
return 0
|
||||
source = open(path, encoding="utf-8").read()
|
||||
return store_dependencies(conn, source, lang, repo, path)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Извлечение символов кода через tree-sitter (Фаза 3).
|
||||
|
||||
Грамматики ставятся отдельными пакетами: tree-sitter-python, tree-sitter-javascript,
|
||||
tree-sitter-rust и т.д. Каждый пакет экспортирует language().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
|
||||
# Язык -> (имя модуля, признак function/class узлов)
|
||||
_LANGS = {
|
||||
"python": ("tree_sitter_python", {"function_definition", "class_definition"}),
|
||||
"javascript": ("tree_sitter_javascript", {"function_declaration", "class_declaration", "method_definition"}),
|
||||
"typescript": ("tree_sitter_javascript", {"function_declaration", "class_declaration", "method_definition"}),
|
||||
"rust": ("tree_sitter_rust", {"function_item", "struct_item", "impl_item", "trait_item", "enum_item"}),
|
||||
}
|
||||
|
||||
|
||||
def _parser(language: str):
|
||||
"""Вернуть (Parser, kind_nodes) для языка."""
|
||||
from tree_sitter import Language, Parser
|
||||
spec = _LANGS.get(language)
|
||||
if spec is None:
|
||||
raise ValueError(f"unsupported language: {language}")
|
||||
mod_name, kind_nodes = spec
|
||||
mod = __import__(mod_name)
|
||||
lang = Language(mod.language())
|
||||
return Parser(lang), kind_nodes
|
||||
|
||||
|
||||
def extract_symbols(source: str, language: str, repo: str = "", path: str = "") -> list[dict]:
|
||||
"""Вернуть символы (function/class/method/type/const) из исходника.
|
||||
|
||||
tree-sitter индексирует байтами UTF-8, поэтому слайсим из bytes (src),
|
||||
а не из str (source) — иначе кириллица в комментариях сбивает индексы.
|
||||
"""
|
||||
parser, kind_nodes = _parser(language)
|
||||
src = source.encode("utf-8")
|
||||
tree = parser.parse(src)
|
||||
symbols: list[dict] = []
|
||||
|
||||
def walk(node):
|
||||
if node.type in kind_nodes:
|
||||
name_node = node.child_by_field_name("name")
|
||||
name = src[name_node.start_byte:name_node.end_byte].decode("utf-8", "replace") if name_node else "?"
|
||||
# сигнатура: от начала узла до первого тела/блока (первые 200 байт)
|
||||
sig = src[node.start_byte:min(node.end_byte, node.start_byte + 200)].decode("utf-8", "replace")
|
||||
if "function" in node.type or node.type in ("function_item", "function_declaration", "method_definition"):
|
||||
kind = "function"
|
||||
elif "class" in node.type or node.type in ("struct_item", "impl_item", "trait_item"):
|
||||
kind = "class"
|
||||
elif "enum" in node.type:
|
||||
kind = "type"
|
||||
else:
|
||||
kind = "type"
|
||||
symbols.append({
|
||||
"repo": repo, "path": path, "language": language,
|
||||
"symbol_name": name, "kind": kind,
|
||||
"signature": sig,
|
||||
"body_hash": hashlib.sha256(src[node.start_byte:node.end_byte]).hexdigest(),
|
||||
"line_start": node.start_point[0] + 1, "line_end": node.end_point[0] + 1,
|
||||
})
|
||||
for child in node.children:
|
||||
walk(child)
|
||||
|
||||
walk(tree.root_node)
|
||||
return symbols
|
||||
|
||||
|
||||
def store_symbols(conn, source: str, language: str, repo: str = "", path: str = "",
|
||||
sync_memgraph: bool = True) -> int:
|
||||
"""Извлечь и сохранить символы кода в pm_code_symbol (+ узлы Memgraph).
|
||||
|
||||
Возвращает число сохранённых.
|
||||
"""
|
||||
from . import memgraph_store
|
||||
syms = extract_symbols(source, language, repo, path)
|
||||
n = 0
|
||||
with conn.cursor() as cur:
|
||||
for s in syms:
|
||||
cur.execute(
|
||||
"""INSERT INTO pm_code_symbol (repo, path, language, symbol_name, kind,
|
||||
signature, body_hash, line_start, line_end)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING""",
|
||||
(s["repo"], s["path"], s["language"], s["symbol_name"], s["kind"],
|
||||
s["signature"], s["body_hash"], s["line_start"], s["line_end"]),
|
||||
)
|
||||
n += 1
|
||||
if sync_memgraph:
|
||||
try:
|
||||
memgraph_store.upsert_code_symbol(
|
||||
s["symbol_name"], repo=s["repo"], path=s["path"],
|
||||
language=s["language"], kind=s["kind"],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
return n
|
||||
|
||||
|
||||
def store_file(conn, path: str, repo: str = "") -> int:
|
||||
"""По расширению файла определить язык и сохранить символы."""
|
||||
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
|
||||
lang = {".py": "python", ".js": "javascript", ".ts": "typescript", ".rs": "rust"}.get("." + ext)
|
||||
if lang is None:
|
||||
return 0
|
||||
source = open(path, encoding="utf-8").read()
|
||||
return store_symbols(conn, source, lang, repo, path)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Pegas Memory — конфигурация (адреса сервисов)."""
|
||||
import os
|
||||
|
||||
PG_DSN = os.getenv("PG_DSN", "postgresql://atom:atompass123@127.0.0.1:5434/content_atom")
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333")
|
||||
MEMGRAPH_HOST = os.getenv("MEMGRAPH_HOST", "127.0.0.1")
|
||||
MEMGRAPH_PORT = int(os.getenv("MEMGRAPH_PORT", "7687"))
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||
|
||||
EMBED_MODEL = os.getenv("EMBED_MODEL", "embeddinggemma-2-740m-Q8_0:latest")
|
||||
EMBED_DIM = int(os.getenv("EMBED_DIM", "768"))
|
||||
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "lfm25-24b-a2b:128k")
|
||||
|
||||
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "alicorn_atoms")
|
||||
|
||||
ATOM_TYPES = ("text", "definition", "logical_rule", "code", "table", "fact")
|
||||
ATOM_STATUSES = ("candidate", "verified", "stale", "contested")
|
||||
|
||||
DEDUP_THRESHOLD = float(os.getenv("DEDUP_THRESHOLD", "0.90")) # порог семантической дедупликации
|
||||
|
||||
PROXY_URL = os.getenv("PROXY_URL", "") # опционально: socks5://127.0.0.1:1080 или http://...
|
||||
USER_AGENT = os.getenv("USER_AGENT", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""PostgreSQL — источник истины (psycopg)."""
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
from . import config
|
||||
|
||||
def connect():
|
||||
return psycopg.connect(config.PG_DSN, row_factory=dict_row)
|
||||
|
||||
def _as_text(content) -> str:
|
||||
"""Привести content к строке: dict/list -> JSON, иначе str."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
import json as _json
|
||||
return _json.dumps(content, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
def content_hash(content) -> str:
|
||||
return hashlib.sha256(_as_text(content).encode("utf-8")).hexdigest()
|
||||
|
||||
def upsert_document(conn, *, kind: str, uri: str | None, title: str | None,
|
||||
content_hash: str | None = None, authority: float = 0.5,
|
||||
meta: dict | None = None) -> int:
|
||||
"""Вставить/вернуть документ по uri."""
|
||||
with conn.cursor() as cur:
|
||||
if uri:
|
||||
cur.execute("SELECT id FROM pm_document WHERE uri = %s", (uri,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
cur.execute(
|
||||
"""INSERT INTO pm_document (kind, uri, title, content_hash, authority, meta)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (uri) DO UPDATE SET title = EXCLUDED.title
|
||||
RETURNING id""",
|
||||
(kind, uri, title, content_hash, authority, json.dumps(meta or {}, ensure_ascii=False)),
|
||||
)
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
def upsert_atom(conn, *, type: str, claim: str | None, content,
|
||||
confidence: float = 0.5, status: str = "candidate",
|
||||
doc_id: int | None = None, locator: dict | None = None,
|
||||
metadata: dict | None = None) -> tuple[int, bool]:
|
||||
"""Вставить атом по content_hash. Возвращает (id, created).
|
||||
|
||||
content может быть str (text/fact/definition/code) или dict/list
|
||||
(logical_rule/table) — dict сериализуется в JSON.
|
||||
"""
|
||||
content = _as_text(content)
|
||||
h = content_hash(content)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM pm_atom WHERE content_hash = %s", (h,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row["id"], False
|
||||
cur.execute(
|
||||
"""INSERT INTO pm_atom (type, claim, content, content_hash, confidence,
|
||||
status, doc_id, locator, metadata)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id""",
|
||||
(type, claim, content, h, confidence, status, doc_id,
|
||||
json.dumps(locator or {}, ensure_ascii=False),
|
||||
json.dumps(metadata or {}, ensure_ascii=False)),
|
||||
)
|
||||
return cur.fetchone()["id"], True
|
||||
|
||||
def upsert_link(conn, *, src: int, dst: int, kind: str, confidence: float = 0.5) -> int:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""INSERT INTO pm_atom_link (src, dst, kind, confidence)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (src, dst, kind) DO NOTHING
|
||||
RETURNING id""",
|
||||
(src, dst, kind, confidence),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row["id"] if row else 0
|
||||
|
||||
def enqueue_outbox(conn, *, entity_type: str, entity_id: int, op: str = "upsert"):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO pm_outbox (entity_type, entity_id, op) VALUES (%s, %s, %s)",
|
||||
(entity_type, entity_id, op),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Семантическая дедупликация атомов по эмбеддингам.
|
||||
|
||||
Наивный дедуп по content_hash ловит только точные повторы. LLM генерирует
|
||||
вариации одного факта — их ловим по косинусной близости эмбеддингов в Qdrant.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import config
|
||||
DEFAULT_THRESHOLD = float(getattr(config, "DEDUP_THRESHOLD", "0.90"))
|
||||
|
||||
|
||||
def find_duplicate(text: str, threshold: float = DEFAULT_THRESHOLD, top_k: int = 3) -> int | None:
|
||||
"""Найти существующий атом, близкий к text (косинус > threshold).
|
||||
|
||||
Возвращает atom_id дубликата или None.
|
||||
"""
|
||||
from . import qdrant_store
|
||||
hits = qdrant_store.search(text, top_k=top_k)
|
||||
if not hits:
|
||||
return None
|
||||
best = hits[0]
|
||||
if best["score"] >= threshold:
|
||||
return int(best["id"])
|
||||
return None
|
||||
|
||||
|
||||
def duplicate_score(text: str, top_k: int = 3) -> tuple[int | None, float]:
|
||||
"""Вернуть (atom_id, score) лучшего совпадения (id=None если ниже порога)."""
|
||||
from . import qdrant_store
|
||||
hits = qdrant_store.search(text, top_k=top_k)
|
||||
if not hits:
|
||||
return None, 0.0
|
||||
best = hits[0]
|
||||
return int(best["id"]), best["score"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Дистилляция чанка в типизированные атомы (LLM)."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import httpx
|
||||
from . import config
|
||||
|
||||
_client = httpx.Client(timeout=600)
|
||||
|
||||
SYSTEM = """Ты — экстрактор знаний. Преврати текст в атомарные, типизированные утверждения.
|
||||
Верни ТОЛЬКО JSON-массив без пояснений. Каждый элемент:
|
||||
{
|
||||
"type": "text|definition|logical_rule|code|table|fact",
|
||||
"claim": "короткое каноническое утверждение (одно предложение)",
|
||||
"content": "полное значение атома",
|
||||
"confidence": 0.0-1.0
|
||||
}
|
||||
Правила:
|
||||
- definition: термин и его точное определение.
|
||||
- logical_rule: правило "если X то Y" (content = JSON со ключами if/then).
|
||||
- code: фрагмент кода или сигнатура функции (content = сам код).
|
||||
- table: табличные данные (content = JSON или markdown-таблица).
|
||||
- fact: проверяемый факт с числом или датой.
|
||||
- text: всё остальное ценное.
|
||||
- Отбрасывай воду, повторы, навигацию, рекламу. Только новое и ценное.
|
||||
- Если ничего ценного — верни []. No markdown fences."""
|
||||
|
||||
def distill_chunk(chunk_text: str, model: str | None = None) -> list[dict]:
|
||||
"""Вернуть список атомов (dict) из чанка."""
|
||||
m = model or config.LLM_MODEL
|
||||
r = _client.post(f"{config.OLLAMA_URL}/api/chat", json={
|
||||
"model": m,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM},
|
||||
{"role": "user", "content": chunk_text[:6000]},
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.1},
|
||||
})
|
||||
r.raise_for_status()
|
||||
content = r.json()["message"]["content"]
|
||||
start = content.find("[")
|
||||
end = content.rfind("]")
|
||||
if start == -1 or end == -1:
|
||||
return []
|
||||
try:
|
||||
arr = json.loads(content[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return [a for a in arr if isinstance(a, dict) and a.get("type") and a.get("content")]
|
||||
|
||||
def verify_atom(atom: dict, context_atoms: list[dict], model: str | None = None) -> float:
|
||||
"""Оценить новизну/ценность атома против существующих (0..1)."""
|
||||
m = model or config.LLM_MODEL
|
||||
ctx = "\n".join(f"- {a.get('claim','')}" for a in context_atoms[:8])
|
||||
prompt = (
|
||||
f"Существующие знания:\n{ctx}\n\n"
|
||||
f"Новый атом ({atom.get('type')}): {atom.get('claim')}\n{atom.get('content','')[:500]}\n\n"
|
||||
f"Оцени от 0 до 1, насколько это НОВОЕ и ценное знание (0=дубль/вода, 1=уникальное важное). "
|
||||
f"Верни только число."
|
||||
)
|
||||
r = _client.post(f"{config.OLLAMA_URL}/api/chat", json={
|
||||
"model": m,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.0},
|
||||
})
|
||||
r.raise_for_status()
|
||||
out = r.json()["message"]["content"].strip()
|
||||
try:
|
||||
return float(out)
|
||||
except ValueError:
|
||||
return 0.5
|
||||
@@ -0,0 +1,27 @@
|
||||
"""EmbeddingGemma 2 через Ollama (dim=768)."""
|
||||
from __future__ import annotations
|
||||
import httpx
|
||||
from . import config
|
||||
|
||||
_client = httpx.Client(timeout=120)
|
||||
|
||||
def embed(text: str) -> list[float]:
|
||||
"""Вернуть вектор для одного текста."""
|
||||
r = _client.post(f"{config.OLLAMA_URL}/api/embed", json={
|
||||
"model": config.EMBED_MODEL,
|
||||
"input": text,
|
||||
})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
vec = data["embeddings"][0]
|
||||
assert len(vec) == config.EMBED_DIM, f"unexpected dim {len(vec)}"
|
||||
return vec
|
||||
|
||||
def embed_batch(texts: list[str]) -> list[list[float]]:
|
||||
"""Вернуть векторы для пачки текстов (один запрос)."""
|
||||
r = _client.post(f"{config.OLLAMA_URL}/api/embed", json={
|
||||
"model": config.EMBED_MODEL,
|
||||
"input": texts,
|
||||
})
|
||||
r.raise_for_status()
|
||||
return r.json()["embeddings"]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Логическая инференция: forward-chaining правил в Memgraph.
|
||||
|
||||
Правило — атом типа logical_rule, content = JSON:
|
||||
{"if": "imports", "then": "depends_on"} # одношаговое
|
||||
{"if": ["depends_on", "depends_on"], "then": "depends_on"} # транзитивное (композиция)
|
||||
|
||||
Инференция итерирует до фиксированной точки: пока правила порождают новые рёбра.
|
||||
Выведенные рёбра помечаются inferred=true, source='inference'.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
|
||||
from . import memgraph_store
|
||||
|
||||
|
||||
def _is_kind(token) -> bool:
|
||||
"""kind ребра — одиночный токен без пробелов (imports, depends_on, calls...).
|
||||
|
||||
Отсекает человекочитаемые правила от LLM-дистилляции вида
|
||||
if='Memgraph хранит граф в RAM' — это не kind ребра.
|
||||
"""
|
||||
return isinstance(token, str) and " " not in token and len(token) > 0 and len(token) < 64
|
||||
|
||||
|
||||
def load_rules(conn) -> list[dict]:
|
||||
"""Загрузить машинные правила из pm_atom (type='logical_rule').
|
||||
|
||||
Правило валидно для инференции, только если if/then — kind-рёбра
|
||||
(одиночные токены без пробелов). Человекочитаемые правила от
|
||||
дистилляции отфильтровываются.
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, claim, content FROM pm_atom WHERE type = 'logical_rule' ORDER BY id"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
rules = []
|
||||
for r in rows:
|
||||
try:
|
||||
spec = json.loads(r["content"]) if isinstance(r["content"], str) else r["content"]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if "if" not in spec or "then" not in spec:
|
||||
continue
|
||||
if_ = spec["if"]
|
||||
then = spec["then"]
|
||||
# валидация: then — всегда kind; if — kind или список kind
|
||||
if not _is_kind(then):
|
||||
continue
|
||||
if isinstance(if_, list):
|
||||
if len(if_) != 2 or not all(_is_kind(k) for k in if_):
|
||||
continue
|
||||
elif not _is_kind(if_):
|
||||
continue
|
||||
rules.append({"atom_id": r["id"], "claim": r["claim"], "spec": spec})
|
||||
return rules
|
||||
|
||||
|
||||
def _apply_rule(spec: dict) -> int:
|
||||
"""Применить одно правило. Возвращает число созданных рёбер."""
|
||||
if_ = spec["if"]
|
||||
then = spec["then"]
|
||||
if isinstance(if_, list):
|
||||
# композиция: [k1, k2] -> (a)-[k1]->(b)-[k2]->(c) ⇒ (a)-[then]->(c)
|
||||
if len(if_) == 2:
|
||||
return memgraph_store.apply_rule_chain(if_[0], if_[1], then)
|
||||
return 0
|
||||
# одношаговое: (a)-[if]->(b) ⇒ (a)-[then]->(b)
|
||||
return memgraph_store.apply_rule_one_step(if_, then)
|
||||
|
||||
|
||||
def infer(conn, max_rounds: int = 10) -> dict:
|
||||
"""Запустить инференцию до фиксированной точки.
|
||||
|
||||
Возвращает {rules, rounds, edges_created}.
|
||||
"""
|
||||
rules = load_rules(conn)
|
||||
total = 0
|
||||
rounds = 0
|
||||
for _ in range(max_rounds):
|
||||
created = 0
|
||||
for rule in rules:
|
||||
created += _apply_rule(rule["spec"])
|
||||
total += created
|
||||
rounds += 1
|
||||
if created == 0:
|
||||
break
|
||||
return {"rules": len(rules), "rounds": rounds, "edges_created": total}
|
||||
|
||||
|
||||
def store_rule(conn, *, claim: str, if_: str | list, then: str) -> int:
|
||||
"""Сохранить логическое правило (атом logical_rule) в PG.
|
||||
|
||||
Возвращает id атома. Если правило с тем же content уже есть — вернёт существующий.
|
||||
"""
|
||||
from . import db
|
||||
content = json.dumps({"if": if_, "then": then}, ensure_ascii=False)
|
||||
atom_id, created = db.upsert_atom(
|
||||
conn, type="logical_rule", claim=claim, content=content,
|
||||
confidence=0.9, status="verified",
|
||||
)
|
||||
if created:
|
||||
db.enqueue_outbox(conn, entity_type="atom", entity_id=atom_id)
|
||||
return atom_id
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Парсеры источников: text/pdf/epub/web → чанки."""
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class ParsedChunk:
|
||||
seq: int
|
||||
content: str
|
||||
locator: dict = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class ParsedDoc:
|
||||
kind: str
|
||||
uri: str | None
|
||||
title: str | None
|
||||
chunks: list[ParsedChunk]
|
||||
content_hash: str = ""
|
||||
|
||||
def parse_text(text: str, *, chunk_size: int = 2000, overlap: int = 200,
|
||||
title: str | None = None, uri: str | None = None) -> ParsedDoc:
|
||||
"""Разбить сырой текст на чанки с перекрытием."""
|
||||
text = text.strip()
|
||||
chunks: list[ParsedChunk] = []
|
||||
start = 0
|
||||
seq = 0
|
||||
while start < len(text):
|
||||
end = min(start + chunk_size, len(text))
|
||||
# не резать посреди слова
|
||||
if end < len(text):
|
||||
space = text.rfind(" ", start, end)
|
||||
if space > start + chunk_size // 2:
|
||||
end = space
|
||||
chunks.append(ParsedChunk(seq=seq, content=text[start:end].strip()))
|
||||
seq += 1
|
||||
if end >= len(text):
|
||||
break
|
||||
start = end - overlap
|
||||
return ParsedDoc(kind="text", uri=uri, title=title, chunks=chunks,
|
||||
content_hash=hashlib.sha256(text.encode()).hexdigest())
|
||||
|
||||
def parse_pdf(path: str) -> ParsedDoc:
|
||||
from pypdf import PdfReader
|
||||
reader = PdfReader(path)
|
||||
text = "\n\n".join((page.extract_text() or "") for page in reader.pages)
|
||||
doc = parse_text(text, title=path.split("/")[-1], uri=path)
|
||||
doc.kind = "pdf"
|
||||
return doc
|
||||
|
||||
def parse_epub(path: str) -> ParsedDoc:
|
||||
import ebooklib
|
||||
from ebooklib import epub
|
||||
book = epub.read_epub(path)
|
||||
parts = []
|
||||
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||
parts.append(item.get_content().decode("utf-8", errors="ignore"))
|
||||
text = "\n\n".join(parts)
|
||||
doc = parse_text(text, title=path.split("/")[-1], uri=path)
|
||||
doc.kind = "epub"
|
||||
return doc
|
||||
|
||||
def _http_client():
|
||||
"""httpx.Client с опциональным прокси и единым User-Agent."""
|
||||
import httpx
|
||||
from . import config
|
||||
kwargs = dict(timeout=30, follow_redirects=True,
|
||||
headers={"User-Agent": config.USER_AGENT})
|
||||
if config.PROXY_URL:
|
||||
kwargs["proxy"] = config.PROXY_URL
|
||||
return httpx.Client(**kwargs)
|
||||
|
||||
def parse_web(url: str) -> ParsedDoc:
|
||||
import trafilatura
|
||||
# fetch через httpx с таймаутом + прокси (trafilatura.fetch_url не имеет таймаута и виснет)
|
||||
with _http_client() as client:
|
||||
r = client.get(url)
|
||||
r.raise_for_status()
|
||||
text = trafilatura.extract(r.text, include_comments=False, include_tables=True,
|
||||
url=url)
|
||||
if not text:
|
||||
raise ValueError(f"trafilatura: no main content for {url}")
|
||||
doc = parse_text(text, uri=url, title=url)
|
||||
doc.kind = "web"
|
||||
return doc
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Memgraph — графовый срез (узлы Atom + CodeSymbol, рёбра RELATES).
|
||||
|
||||
Memgraph совместим с Bolt-протоколом Neo4j — используем официальный neo4j драйвер.
|
||||
Рёбра единообразны: (любой узел)-[:RELATES {kind, inferred, source}]->(любой узел).
|
||||
Это позволяет логической инференции работать и над атомами, и над кодом.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from neo4j import GraphDatabase
|
||||
from . import config
|
||||
|
||||
_driver = None
|
||||
|
||||
def driver():
|
||||
global _driver
|
||||
if _driver is None:
|
||||
_driver = GraphDatabase.driver(
|
||||
f"bolt://{config.MEMGRAPH_HOST}:{config.MEMGRAPH_PORT}"
|
||||
)
|
||||
return _driver
|
||||
|
||||
def upsert_atom(atom_id: int, type: str, claim: str | None, status: str) -> None:
|
||||
with driver().session() as s:
|
||||
s.run(
|
||||
"MERGE (a:Atom {id: $id}) SET a.type = $type, a.claim = $claim, a.status = $status",
|
||||
id=atom_id, type=type, claim=claim or "", status=status,
|
||||
)
|
||||
|
||||
def upsert_link(src: int, dst: int, kind: str, *, inferred: bool = False,
|
||||
source: str | None = None, confidence: float = 0.5) -> None:
|
||||
with driver().session() as s:
|
||||
s.run(
|
||||
"MATCH (a:Atom {id: $src}), (b:Atom {id: $dst}) "
|
||||
"MERGE (a)-[r:RELATES {kind: $kind}]->(b) "
|
||||
"SET r.inferred = $inferred, r.source = $source, r.confidence = $confidence",
|
||||
src=src, dst=dst, kind=kind, inferred=inferred, source=source, confidence=confidence,
|
||||
)
|
||||
|
||||
def delete_atom(atom_id: int) -> None:
|
||||
with driver().session() as s:
|
||||
s.run("MATCH (a:Atom {id: $id}) DETACH DELETE a", id=atom_id)
|
||||
|
||||
def upsert_code_symbol(name: str, *, repo: str = "", path: str = "",
|
||||
language: str = "", kind: str = "") -> None:
|
||||
"""Узел CodeSymbol (дедуп по name+repo+path)."""
|
||||
with driver().session() as s:
|
||||
s.run(
|
||||
"MERGE (c:CodeSymbol {name: $name, repo: $repo, path: $path}) "
|
||||
"SET c.language = $language, c.kind = $kind",
|
||||
name=name, repo=repo, path=path, language=language, kind=kind,
|
||||
)
|
||||
|
||||
def upsert_code_dependency(src: str, dst: str, kind: str, *, repo: str = "", path: str = "") -> None:
|
||||
"""Ребро зависимости кода: (src)-[:RELATES {kind}]->(dst)."""
|
||||
with driver().session() as s:
|
||||
# dst может быть внешним (builtin) — создаём как CodeSymbol без repo/path
|
||||
s.run(
|
||||
"MERGE (a:CodeSymbol {name: $src, repo: $repo, path: $path}) "
|
||||
"MERGE (b:CodeSymbol {name: $dst}) "
|
||||
"MERGE (a)-[r:RELATES {kind: $kind}]->(b) "
|
||||
"SET r.source = 'extraction'",
|
||||
src=src, dst=dst, kind=kind, repo=repo, path=path,
|
||||
)
|
||||
|
||||
def apply_rule_one_step(if_kind: str, then_kind: str) -> int:
|
||||
"""Одношаговое правило: (a)-[:RELATES {kind: if}]->(b) ⇒ (a)-[:RELATES {kind: then}]->(b).
|
||||
|
||||
Возвращает число созданных рёбер.
|
||||
"""
|
||||
with driver().session() as s:
|
||||
result = s.run(
|
||||
"MATCH (a)-[r:RELATES {kind: $if_kind}]->(b) "
|
||||
"OPTIONAL MATCH (a)-[x:RELATES {kind: $then_kind}]->(b) "
|
||||
"WITH a, b, x WHERE x IS NULL "
|
||||
"CREATE (a)-[r2:RELATES {kind: $then_kind, inferred: true, source: 'inference'}]->(b) "
|
||||
"RETURN count(r2) AS n",
|
||||
if_kind=if_kind, then_kind=then_kind,
|
||||
)
|
||||
return result.single()["n"]
|
||||
|
||||
def apply_rule_chain(k1: str, k2: str, then_kind: str) -> int:
|
||||
"""Композиция: (a)-[:RELATES {kind: k1}]->(b)-[:RELATES {kind: k2}]->(c)
|
||||
⇒ (a)-[:RELATES {kind: then}]->(c). Транзитивность. Возвращает число созданных."""
|
||||
with driver().session() as s:
|
||||
result = s.run(
|
||||
"MATCH (a)-[:RELATES {kind: $k1}]->(b)-[:RELATES {kind: $k2}]->(c) "
|
||||
"OPTIONAL MATCH (a)-[x:RELATES {kind: $then_kind}]->(c) "
|
||||
"WITH a, b, c, x WHERE x IS NULL "
|
||||
"CREATE (a)-[r2:RELATES {kind: $then_kind, inferred: true, source: 'inference'}]->(c) "
|
||||
"RETURN count(r2) AS n",
|
||||
k1=k1, k2=k2, then_kind=then_kind,
|
||||
)
|
||||
return result.single()["n"]
|
||||
|
||||
def count() -> int:
|
||||
with driver().session() as s:
|
||||
return s.run("MATCH (a:Atom) RETURN count(a) AS n").single()["n"]
|
||||
|
||||
def count_edges() -> int:
|
||||
with driver().session() as s:
|
||||
return s.run("MATCH ()-[r:RELATES]->() RETURN count(r) AS n").single()["n"]
|
||||
|
||||
def count_code_symbols() -> int:
|
||||
with driver().session() as s:
|
||||
return s.run("MATCH (c:CodeSymbol) RETURN count(c) AS n").single()["n"]
|
||||
|
||||
def query_edges(kind: str | None = None, limit: int = 50) -> list[dict]:
|
||||
"""Список рёбер (опционально по kind)."""
|
||||
with driver().session() as s:
|
||||
if kind:
|
||||
r = s.run(
|
||||
"MATCH (a)-[r:RELATES {kind: $kind}]->(b) "
|
||||
"RETURN a.name AS src, labels(a) AS al, b.name AS dst, labels(b) AS bl, "
|
||||
"r.kind AS kind, r.inferred AS inferred LIMIT $lim",
|
||||
kind=kind, lim=limit,
|
||||
)
|
||||
else:
|
||||
r = s.run(
|
||||
"MATCH (a)-[r:RELATES]->(b) "
|
||||
"RETURN a.name AS src, labels(a) AS al, b.name AS dst, labels(b) AS bl, "
|
||||
"r.kind AS kind, r.inferred AS inferred LIMIT $lim",
|
||||
lim=limit,
|
||||
)
|
||||
out = []
|
||||
for rec in r:
|
||||
d = {"src": rec["src"], "dst": rec["dst"]}
|
||||
if "al" in rec and rec["al"]:
|
||||
d["src_label"] = rec["al"][0]
|
||||
if "bl" in rec and rec["bl"]:
|
||||
d["dst_label"] = rec["bl"][0]
|
||||
for key in ("kind", "inferred"):
|
||||
try:
|
||||
d[key] = rec[key]
|
||||
except KeyError:
|
||||
pass
|
||||
out.append(d)
|
||||
return out
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Outbox-синк: PG → Qdrant + Memgraph."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from . import config, db, qdrant_store, memgraph_store
|
||||
|
||||
def sync_batch(conn, limit: int = 100) -> dict:
|
||||
"""Обработать пачку необработанных outbox-записей."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, entity_type, entity_id, op FROM pm_outbox "
|
||||
"WHERE processed = false ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
stats = {"atom": 0, "link": 0, "error": 0}
|
||||
for row in rows:
|
||||
try:
|
||||
if row["entity_type"] == "atom":
|
||||
_sync_atom(conn, row)
|
||||
stats["atom"] += 1
|
||||
elif row["entity_type"] == "link":
|
||||
_sync_link(conn, row)
|
||||
stats["link"] += 1
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE pm_outbox SET processed = true WHERE id = %s", (row["id"],))
|
||||
except Exception as e:
|
||||
stats["error"] += 1
|
||||
print(f"[outbox] error id={row['id']}: {e}")
|
||||
conn.commit()
|
||||
return stats
|
||||
|
||||
def _sync_atom(conn, row) -> None:
|
||||
with conn.cursor() as cur:
|
||||
if row["op"] == "delete":
|
||||
qdrant_store.delete_atom(row["entity_id"])
|
||||
return
|
||||
cur.execute("SELECT id, type, claim, content, confidence, status, doc_id FROM pm_atom WHERE id = %s", (row["entity_id"],))
|
||||
a = cur.fetchone()
|
||||
if not a:
|
||||
return
|
||||
# 1) узел в графе (Memgraph)
|
||||
memgraph_store.upsert_atom(a["id"], a["type"], a["claim"], a["status"])
|
||||
# 2) вектор в Qdrant (эмбеддим claim+content — claim семантически сфокусирован)
|
||||
embed_text = (a["claim"] or "") + " " + a["content"][:1500]
|
||||
payload = {
|
||||
"type": a["type"], "claim": a["claim"] or "",
|
||||
"confidence": a["confidence"], "status": a["status"],
|
||||
"doc_id": a["doc_id"],
|
||||
}
|
||||
qdrant_store.upsert_atom(a["id"], embed_text, payload)
|
||||
|
||||
def _sync_link(conn, row) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT src, dst, kind FROM pm_atom_link WHERE id = %s", (row["entity_id"],))
|
||||
l = cur.fetchone()
|
||||
if not l:
|
||||
return
|
||||
memgraph_store.upsert_link(l["src"], l["dst"], l["kind"])
|
||||
|
||||
def sync_all(conn, batch: int = 100, max_batches: int = 100) -> dict:
|
||||
total = {"atom": 0, "link": 0, "error": 0}
|
||||
for _ in range(max_batches):
|
||||
s = sync_batch(conn, batch)
|
||||
for k in total:
|
||||
total[k] += s[k]
|
||||
if s["atom"] + s["link"] + s["error"] == 0:
|
||||
break
|
||||
return total
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Ингест-пайплайн: источник → чанки → атомы → PG + outbox."""
|
||||
from __future__ import annotations
|
||||
import psycopg
|
||||
from . import db, distill, ingest
|
||||
from .ingest import ParsedDoc
|
||||
|
||||
def ingest_doc(conn, doc: ParsedDoc, *, verify: bool = False,
|
||||
min_confidence: float = 0.4, dedup: bool = True,
|
||||
dedup_threshold: float = 0.93) -> dict:
|
||||
"""Прогнать документ через пайплайн. Возвращает статистику.
|
||||
|
||||
dedup: семантическая дедупликация по эмбеддингам (Qdrant) против
|
||||
существующих атомов — вариации одного факта от LLM не дублируются.
|
||||
"""
|
||||
doc_id = db.upsert_document(
|
||||
conn, kind=doc.kind, uri=doc.uri, title=doc.title,
|
||||
content_hash=doc.content_hash,
|
||||
)
|
||||
stats = {"doc_id": doc_id, "chunks": 0, "atoms_created": 0,
|
||||
"atoms_skipped": 0, "duplicates": 0}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for ch in doc.chunks:
|
||||
cur.execute(
|
||||
"INSERT INTO pm_chunk (doc_id, seq, content, locator) VALUES (%s, %s, %s, %s) RETURNING id",
|
||||
(doc_id, ch.seq, ch.content, __import__("json").dumps(ch.locator, ensure_ascii=False)),
|
||||
)
|
||||
stats["chunks"] += 1
|
||||
|
||||
# дистилляция чанка в атомы
|
||||
atoms = distill.distill_chunk(ch.content)
|
||||
for a in atoms:
|
||||
if a.get("confidence", 0.5) < min_confidence:
|
||||
stats["atoms_skipped"] += 1
|
||||
continue
|
||||
# семантическая дедупликация
|
||||
if dedup:
|
||||
from . import dedup as _dedup
|
||||
dup_id = _dedup.find_duplicate(
|
||||
(a.get("claim") or "") + " " + str(a["content"])[:800],
|
||||
threshold=dedup_threshold,
|
||||
)
|
||||
if dup_id is not None:
|
||||
stats["duplicates"] += 1
|
||||
stats["atoms_skipped"] += 1
|
||||
continue
|
||||
atom_id, created = db.upsert_atom(
|
||||
conn,
|
||||
type=a["type"],
|
||||
claim=a.get("claim"),
|
||||
content=a["content"],
|
||||
confidence=a.get("confidence", 0.5),
|
||||
doc_id=doc_id,
|
||||
locator=ch.locator,
|
||||
metadata=a.get("metadata") or a.get("content_json"),
|
||||
)
|
||||
if created:
|
||||
stats["atoms_created"] += 1
|
||||
db.enqueue_outbox(conn, entity_type="atom", entity_id=atom_id)
|
||||
else:
|
||||
stats["atoms_skipped"] += 1
|
||||
conn.commit()
|
||||
return stats
|
||||
|
||||
def ingest_text(conn, text: str, *, title: str | None = None, uri: str | None = None,
|
||||
**kw) -> dict:
|
||||
doc = ingest.parse_text(text, title=title, uri=uri)
|
||||
return ingest_doc(conn, doc, **kw)
|
||||
|
||||
def ingest_file(conn, path: str, **kw) -> dict:
|
||||
low = path.lower()
|
||||
if low.endswith(".pdf"):
|
||||
doc = ingest.parse_pdf(path)
|
||||
elif low.endswith(".epub"):
|
||||
doc = ingest.parse_epub(path)
|
||||
else:
|
||||
doc = ingest.parse_text(open(path, encoding="utf-8").read(),
|
||||
title=path.split("/")[-1], uri=path)
|
||||
return ingest_doc(conn, doc, **kw)
|
||||
|
||||
def ingest_url(conn, url: str, **kw) -> dict:
|
||||
doc = ingest.parse_web(url)
|
||||
return ingest_doc(conn, doc, **kw)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Qdrant — векторный срез (EmbeddingGemma 2, dim=768)."""
|
||||
from __future__ import annotations
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
|
||||
from . import config
|
||||
from .embedding import embed
|
||||
|
||||
_client: QdrantClient | None = None
|
||||
|
||||
def client() -> QdrantClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = QdrantClient(url=config.QDRANT_URL)
|
||||
return _client
|
||||
|
||||
def ensure_collection():
|
||||
c = client()
|
||||
try:
|
||||
c.get_collection(config.QDRANT_COLLECTION)
|
||||
except Exception:
|
||||
c.create_collection(
|
||||
collection_name=config.QDRANT_COLLECTION,
|
||||
vectors_config=VectorParams(size=config.EMBED_DIM, distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
def upsert_atom(atom_id: int, text: str, payload: dict) -> None:
|
||||
"""text — что эмбеддим; payload — type/confidence/status/doc_id/claim."""
|
||||
vec = embed(text)
|
||||
c = client()
|
||||
c.upsert(
|
||||
collection_name=config.QDRANT_COLLECTION,
|
||||
points=[PointStruct(id=atom_id, vector=vec, payload=payload)],
|
||||
)
|
||||
|
||||
def delete_atom(atom_id: int) -> None:
|
||||
client().delete(
|
||||
collection_name=config.QDRANT_COLLECTION,
|
||||
points_selector=[atom_id],
|
||||
)
|
||||
|
||||
def search(query: str, top_k: int = 5, type_filter: str | None = None,
|
||||
status_filter: str | None = None) -> list[dict]:
|
||||
"""Семантический поиск по атомам."""
|
||||
qvec = embed(query)
|
||||
must = []
|
||||
if type_filter:
|
||||
must.append(FieldCondition(key="type", match=MatchValue(value=type_filter)))
|
||||
if status_filter:
|
||||
must.append(FieldCondition(key="status", match=MatchValue(value=status_filter)))
|
||||
qfilter = Filter(must=must) if must else None
|
||||
hits = client().query_points(
|
||||
collection_name=config.QDRANT_COLLECTION,
|
||||
query=qvec,
|
||||
limit=top_k,
|
||||
query_filter=qfilter,
|
||||
).points
|
||||
return [{"id": h.id, "score": h.score, **h.payload} for h in hits]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Цикл исследования пробелов: detect → plan → acquire → extract → verify → commit."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import httpx
|
||||
from . import config, db, pipeline
|
||||
|
||||
_client = httpx.Client(timeout=600)
|
||||
|
||||
def detect_gaps(conn) -> list[dict]:
|
||||
"""Найти пробелы в базе знаний."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, question, reason, priority, status FROM pm_research_gap "
|
||||
"WHERE status = 'open' ORDER BY priority DESC, id LIMIT 20"
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
def add_gap(conn, question: str, reason: str = "user_query", priority: float = 0.5):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO pm_research_gap (question, reason, priority) VALUES (%s, %s, %s) RETURNING id",
|
||||
(question, reason, priority),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def web_search(query: str, engine: str = "searxng") -> list[dict]:
|
||||
"""Поиск через SearXNG. Возвращает [{title, url, snippet}]."""
|
||||
# локальный SearXNG (MCP mcp__searxng__search); здесь — прямой HTTP JSON API
|
||||
import urllib.parse
|
||||
q = urllib.parse.quote(query)
|
||||
# типичный endpoint локального SearXNG
|
||||
for base in ("http://127.0.0.1:8889", "http://127.0.0.1:8080"):
|
||||
try:
|
||||
r = _client.get(f"{base}/search", params={"q": query, "format": "json"}, timeout=15)
|
||||
r.raise_for_status()
|
||||
results = r.json().get("results", [])
|
||||
return [{"title": x.get("title"), "url": x.get("url"), "snippet": x.get("content")}
|
||||
for x in results[:3]]
|
||||
except Exception:
|
||||
continue
|
||||
return []
|
||||
|
||||
def research_question(conn, question: str, *, top_k: int = 5, model: str | None = None) -> dict:
|
||||
"""Полный цикл исследования одного вопроса.
|
||||
|
||||
1. web-поиск (SearXNG);
|
||||
2. для каждого источника: полная загрузка URL; при недоступности (гео-блок,
|
||||
нет прокси) — дистилляция сниппета из поиска;
|
||||
3. пометка вопроса resolved.
|
||||
"""
|
||||
result = {"question": question, "sources": [], "atoms_created": 0}
|
||||
sources = web_search(question)
|
||||
result["sources"] = [s["url"] for s in sources if s.get("url")]
|
||||
|
||||
for s in sources:
|
||||
url = s.get("url")
|
||||
if not url:
|
||||
continue
|
||||
# попытка полной загрузки
|
||||
ingested = False
|
||||
try:
|
||||
stats = pipeline.ingest_url(conn, url, min_confidence=0.4)
|
||||
result["atoms_created"] += stats["atoms_created"]
|
||||
ingested = True
|
||||
except Exception as e:
|
||||
print(f"[research] ingest {url} failed: {e}")
|
||||
# fallback: сниппет из поиска (когда сайт недоступен напрямую)
|
||||
if not ingested and s.get("snippet"):
|
||||
try:
|
||||
title = s.get("title") or url
|
||||
stats = pipeline.ingest_text(
|
||||
conn, s["snippet"], title=title, uri=url, min_confidence=0.4,
|
||||
)
|
||||
result["atoms_created"] += stats["atoms_created"]
|
||||
except Exception as e:
|
||||
print(f"[research] snippet fallback {url} failed: {e}")
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE pm_research_gap SET status = 'resolved' WHERE question = %s AND status = 'open'",
|
||||
(question,),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Верификация атомов: self-critique + кросс-подтверждение.
|
||||
|
||||
Двухступенчатая верификация знания:
|
||||
1. self-critique (LLM) — вердикт по новизне/противоречию против существующих.
|
||||
2. кросс-подтверждение (эмбеддинги) — тот же факт из N независимых источников.
|
||||
|
||||
Статусы:
|
||||
candidate — новый атом, не проверен
|
||||
verified — подтверждён (self-critique прошёл И/ИЛИ 2+ источника)
|
||||
contested — противоречит существующему знанию
|
||||
stale — устарел (не актуально)
|
||||
|
||||
Верификация не удаляет атомы, только меняет статус/confidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import httpx
|
||||
from . import config, db
|
||||
|
||||
_client = httpx.Client(timeout=600)
|
||||
|
||||
VERDICTS = ("new", "duplicate", "corroborates", "contradicts")
|
||||
|
||||
|
||||
def self_critique(atom: dict, context_atoms: list[dict], model: str | None = None) -> dict:
|
||||
"""LLM-вердикт по атому против существующих знаний.
|
||||
|
||||
Возвращает {"verdict", "confidence", "reason"}.
|
||||
"""
|
||||
m = model or config.LLM_MODEL
|
||||
ctx = "\n".join(f"- {a.get('claim','')[:120]}" for a in context_atoms[:8])
|
||||
prompt = (
|
||||
f"Существующие знания:\n{ctx}\n\n"
|
||||
f"Новый атом ({atom.get('type')}):\n{atom.get('claim')}\n{atom.get('content','')[:400]}\n\n"
|
||||
f"Оцени атом относительно существующих знаний. Верни ТОЛЬКО JSON:\n"
|
||||
f'{{"verdict": "new|duplicate|corroborates|contradicts", "confidence": 0.0-1.0, "reason": "кратко"}}\n'
|
||||
f"- new: уникальное знание, которого нет в контексте\n"
|
||||
f"- duplicate: то же самое, что уже есть\n"
|
||||
f"- corroborates: подтверждает/уточняет существующее\n"
|
||||
f"- contradicts: противоречит существующему"
|
||||
)
|
||||
r = _client.post(f"{config.OLLAMA_URL}/api/chat", json={
|
||||
"model": m,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.0},
|
||||
})
|
||||
r.raise_for_status()
|
||||
out = r.json()["message"]["content"].strip()
|
||||
start, end = out.find("{"), out.rfind("}")
|
||||
if start == -1 or end == -1:
|
||||
return {"verdict": "new", "confidence": 0.5, "reason": "parse error"}
|
||||
try:
|
||||
v = json.loads(out[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return {"verdict": "new", "confidence": 0.5, "reason": "json error"}
|
||||
if v.get("verdict") not in VERDICTS:
|
||||
v["verdict"] = "new"
|
||||
return v
|
||||
|
||||
|
||||
def corroborate_atom(conn, atom_id: int, threshold: float = 0.90) -> int:
|
||||
"""Найти близкие атомы из ДРУГИХ источников (кросс-подтверждение).
|
||||
|
||||
Возвращает число найденных подтверждений (не считая сам атом).
|
||||
"""
|
||||
from . import qdrant_store
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, doc_id, claim, content FROM pm_atom WHERE id = %s", (atom_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return 0
|
||||
text = (row["claim"] or "") + " " + (row["content"] or "")[:800]
|
||||
hits = qdrant_store.search(text, top_k=5)
|
||||
count = 0
|
||||
for h in hits:
|
||||
if int(h["id"]) == atom_id:
|
||||
continue
|
||||
if h["score"] >= threshold and h.get("doc_id") != row["doc_id"]:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def verify_atom(conn, atom_id: int, *, self_critique_enabled: bool = True,
|
||||
model: str | None = None) -> dict:
|
||||
"""Полная верификация одного атома. Возвращает результат.
|
||||
|
||||
- self-critique: вердикт LLM (contradicts → contested).
|
||||
- кросс-подтверждение: N близких из других источников (N>=1 → verified).
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id, type, claim, content, confidence, status, doc_id FROM pm_atom WHERE id = %s", (atom_id,))
|
||||
atom = cur.fetchone()
|
||||
if not atom:
|
||||
return {"atom_id": atom_id, "error": "not found"}
|
||||
|
||||
result = {"atom_id": atom_id, "verdict": None, "corroborations": 0,
|
||||
"new_status": atom["status"], "new_confidence": atom["confidence"]}
|
||||
|
||||
# 1) кросс-подтверждение по эмбеддингам (быстро, без LLM)
|
||||
corr = corroborate_atom(conn, atom_id)
|
||||
result["corroborations"] = corr
|
||||
|
||||
# 2) self-critique (LLM): контекст — ближайшие по смыслу (эмбеддинги), а не по id
|
||||
if self_critique_enabled:
|
||||
from . import qdrant_store
|
||||
qtext = (atom["claim"] or "") + " " + (atom["content"] or "")[:400]
|
||||
hits = qdrant_store.search(qtext, top_k=8)
|
||||
context = [{"claim": h.get("claim", "")} for h in hits if int(h["id"]) != atom_id]
|
||||
v = self_critique(atom, context, model=model)
|
||||
result["verdict"] = v["verdict"]
|
||||
result["reason"] = v.get("reason")
|
||||
|
||||
if v["verdict"] == "contradicts":
|
||||
result["new_status"] = "contested"
|
||||
elif v["verdict"] == "duplicate":
|
||||
result["new_status"] = "stale" # дубликат — пометить неактуальным
|
||||
elif v["verdict"] in ("corroborates", "new") or corr >= 1:
|
||||
result["new_status"] = "verified"
|
||||
elif corr >= 1:
|
||||
result["new_status"] = "verified"
|
||||
|
||||
# confidence: кросс-подтверждение повышает уверенность
|
||||
new_conf = min(1.0, float(atom["confidence"] or 0.5) + 0.1 * corr)
|
||||
result["new_confidence"] = round(new_conf, 2)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE pm_atom SET status = %s, confidence = %s, updated_at = now() WHERE id = %s",
|
||||
(result["new_status"], result["new_confidence"], atom_id),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
|
||||
def verify_all(conn, *, limit: int = 50, self_critique_enabled: bool = True,
|
||||
model: str | None = None) -> dict:
|
||||
"""Верифицировать все candidate-атомы (порциями). Возвращает сводку."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT id FROM pm_atom WHERE status = 'candidate' ORDER BY id LIMIT %s", (limit,))
|
||||
ids = [r["id"] for r in cur.fetchall()]
|
||||
summary = {"total": len(ids), "verified": 0, "contested": 0, "stale": 0, "unchanged": 0}
|
||||
for aid in ids:
|
||||
try:
|
||||
r = verify_atom(conn, aid, self_critique_enabled=self_critique_enabled, model=model)
|
||||
except Exception as e:
|
||||
print(f"[verify] atom {aid} failed: {e}")
|
||||
continue
|
||||
ns = r.get("new_status")
|
||||
if ns == "verified":
|
||||
summary["verified"] += 1
|
||||
elif ns == "contested":
|
||||
summary["contested"] += 1
|
||||
elif ns == "stale":
|
||||
summary["stale"] += 1
|
||||
else:
|
||||
summary["unchanged"] += 1
|
||||
return summary
|
||||
Reference in new issue
Block a user