Gaps: full lifecycle management (get/update/delete), pure PostgreSQL (no web search)
This commit is contained in:
1 parent
adfa630578
commit
a4c4a766c1
5 files changed
+266
-81
No files matched your search
+95
-65
@@ -1,84 +1,114 @@
|
||||
"""Цикл исследования пробелов: detect → plan → acquire → extract → verify → commit."""
|
||||
"""Управление пробелами в знаниях (research gaps).
|
||||
|
||||
Работает **только с PostgreSQL** — никакого веб-поиска. Поиск выполняет сама
|
||||
модель встроенным инструментом \`web_search\` (SearXNG и автоматический
|
||||
research-цикл убраны).
|
||||
|
||||
Жизненный цикл пробела:
|
||||
open → researching → resolved | dismissed
|
||||
|
||||
Поля:
|
||||
question — что нужно исследовать (формулировка)
|
||||
reason — почему пробел обнаружен
|
||||
priority — 0.0 .. 1.0 (больше = важнее)
|
||||
status — open | researching | resolved | dismissed
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import httpx
|
||||
from . import config, db, pipeline
|
||||
|
||||
_client = httpx.Client(timeout=600)
|
||||
VALID_REASONS = ("low_coverage", "low_confidence", "stale", "contested", "user_query")
|
||||
VALID_STATUSES = ("open", "researching", "resolved", "dismissed")
|
||||
|
||||
def detect_gaps(conn) -> list[dict]:
|
||||
"""Найти пробелы в базе знаний."""
|
||||
_COLS = "id, question, reason, priority, status, created_at"
|
||||
|
||||
|
||||
def list_gaps(conn, *, status: str | None = None, limit: int = 50) -> list[dict]:
|
||||
"""Список пробелов. Без фильтра — сначала открытые, затем по приоритету."""
|
||||
limit = max(1, min(int(limit), 200))
|
||||
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"
|
||||
)
|
||||
if status:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||
cur.execute(
|
||||
f"SELECT {_COLS} FROM pm_research_gap WHERE status = %s "
|
||||
"ORDER BY priority DESC, id LIMIT %s",
|
||||
(status, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
f"SELECT {_COLS} FROM pm_research_gap "
|
||||
"ORDER BY (status = 'open') DESC, priority DESC, id LIMIT %s",
|
||||
(limit,),
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
def add_gap(conn, question: str, reason: str = "user_query", priority: float = 0.5):
|
||||
|
||||
def get_gap(conn, gap_id: int) -> dict | None:
|
||||
"""Один пробел по id (или None)."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT {_COLS} FROM pm_research_gap WHERE id = %s", (gap_id,))
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def add_gap(conn, question: str, reason: str = "user_query", priority: float = 0.5) -> int:
|
||||
"""Зарегистрировать пробел. Возвращает id."""
|
||||
question = (question or "").strip()
|
||||
if not question:
|
||||
raise ValueError("question must not be empty")
|
||||
if reason not in VALID_REASONS:
|
||||
raise ValueError(f"reason must be one of {VALID_REASONS}")
|
||||
priority = max(0.0, min(1.0, float(priority)))
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO pm_research_gap (question, reason, priority) VALUES (%s, %s, %s) RETURNING id",
|
||||
"INSERT INTO pm_research_gap (question, reason, priority, status) "
|
||||
"VALUES (%s, %s, %s, 'open') RETURNING id",
|
||||
(question, reason, priority),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return row["id"]
|
||||
|
||||
def web_search(query: str, engine: str = "local") -> list[dict]:
|
||||
"""Веб-поиск (локальный инстанс). Возвращает [{title, url, snippet}]."""
|
||||
# прямого MCP-инструмента searxng больше нет; internal HTTP JSON API остаётся
|
||||
import urllib.parse
|
||||
q = urllib.parse.quote(query)
|
||||
# endpoint локального поискового инстанса
|
||||
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:
|
||||
"""Полный цикл исследования одного вопроса.
|
||||
def update_gap(conn, gap_id: int, *, question: str | None = None,
|
||||
reason: str | None = None, priority: float | None = None,
|
||||
status: str | None = None) -> bool:
|
||||
"""Обновить поля пробела. Возвращает True, если запись найдена.
|
||||
|
||||
1. веб-поиск;
|
||||
2. для каждого источника: полная загрузка URL; при недоступности (гео-блок,
|
||||
нет прокси) — дистилляция сниппета из поиска;
|
||||
3. пометка вопроса resolved.
|
||||
Типичные переходы: status='researching' (взял в работу),
|
||||
status='resolved' (закрыт), status='dismissed' (не актуален).
|
||||
"""
|
||||
result = {"question": question, "sources": [], "atoms_created": 0}
|
||||
sources = web_search(question)
|
||||
result["sources"] = [s["url"] for s in sources if s.get("url")]
|
||||
sets: list[str] = []
|
||||
vals: list = []
|
||||
if question is not None:
|
||||
question = question.strip()
|
||||
if not question:
|
||||
raise ValueError("question must not be empty")
|
||||
sets.append("question = %s"); vals.append(question)
|
||||
if reason is not None:
|
||||
if reason not in VALID_REASONS:
|
||||
raise ValueError(f"reason must be one of {VALID_REASONS}")
|
||||
sets.append("reason = %s"); vals.append(reason)
|
||||
if priority is not None:
|
||||
sets.append("priority = %s"); vals.append(max(0.0, min(1.0, float(priority))))
|
||||
if status is not None:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||
sets.append("status = %s"); vals.append(status)
|
||||
|
||||
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}")
|
||||
if not sets:
|
||||
return get_gap(conn, gap_id) is not None
|
||||
|
||||
vals.append(gap_id)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE pm_research_gap SET status = 'resolved' WHERE question = %s AND status = 'open'",
|
||||
(question,),
|
||||
)
|
||||
cur.execute(f"UPDATE pm_research_gap SET {', '.join(sets)} WHERE id = %s", vals)
|
||||
found = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return result
|
||||
return found
|
||||
|
||||
|
||||
def delete_gap(conn, gap_id: int) -> bool:
|
||||
"""Удалить пробел. Возвращает True, если запись существовала."""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM pm_research_gap WHERE id = %s", (gap_id,))
|
||||
found = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return found
|
||||
Reference in new issue
Block a user