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
+262
-77
No files matched your search
@@ -40,6 +40,10 @@ Read this file before answering any question about knowledge, memory, or researc
|
|||||||
| "Find a function/class named X" | `memory_search_code` |
|
| "Find a function/class named X" | `memory_search_code` |
|
||||||
| "Show relations / graph edges" | `memory_query_graph` — `kind` is an **edge** type (`imports`/`depends_on`/`calls`/`inherits`/`related`), not an atom type |
|
| "Show relations / graph edges" | `memory_query_graph` — `kind` is an **edge** type (`imports`/`depends_on`/`calls`/`inherits`/`related`), not an atom type |
|
||||||
| "Remember to research X later" | `memory_add_gap` |
|
| "Remember to research X later" | `memory_add_gap` |
|
||||||
|
| "Edit a gap" (text/priority/reason) | `memory_update_gap` |
|
||||||
|
| "Close / take a gap" | `memory_update_gap` with `status` = `researching` / `resolved` / `dismissed` |
|
||||||
|
| "Show one gap" | `memory_get_gap` |
|
||||||
|
| "Delete a gap" | `memory_delete_gap` |
|
||||||
| "Run logical inference" | `memory_infer` |
|
| "Run logical inference" | `memory_infer` |
|
||||||
|
|
||||||
**Ambiguity rule:** if the question is about *knowledge content* → memory tools.
|
**Ambiguity rule:** if the question is about *knowledge content* → memory tools.
|
||||||
@@ -53,6 +57,7 @@ If it is about *the outside world right now* → `web_search` (built-in).
|
|||||||
1. DETECT memory_stats / memory_list_gaps → what is missing?
|
1. DETECT memory_stats / memory_list_gaps → what is missing?
|
||||||
2. CHECK memory_search → do I already know it?
|
2. CHECK memory_search → do I already know it?
|
||||||
3. PLAN memory_add_gap(question) → register the gap
|
3. PLAN memory_add_gap(question) → register the gap
|
||||||
|
TRACK memory_update_gap(id, status) → researching → resolved/dismissed
|
||||||
4. ACQUIRE web_search / web_fetch / read URL → find sources
|
4. ACQUIRE web_search / web_fetch / read URL → find sources
|
||||||
5. EXTRACT memory_ingest(text|url|path) → distill into atoms
|
5. EXTRACT memory_ingest(text|url|path) → distill into atoms
|
||||||
6. VERIFY memory_verify → candidate → verified/contested
|
6. VERIFY memory_verify → candidate → verified/contested
|
||||||
@@ -102,3 +107,28 @@ Every atom needs a **claim** (one short sentence) and, where possible, provenanc
|
|||||||
- If a fact is `candidate` (unverified), say so.
|
- If a fact is `candidate` (unverified), say so.
|
||||||
- If the base has nothing, say "not in the knowledge base" and offer to research it.
|
- If the base has nothing, say "not in the knowledge base" and offer to research it.
|
||||||
- Never present web content as memory, and never present memory as live web data.
|
- Never present web content as memory, and never present memory as live web data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Managing gaps (full lifecycle)
|
||||||
|
|
||||||
|
A **gap** is a question you know you cannot answer yet. Manage them, don't just collect them:
|
||||||
|
|
||||||
|
| Operation | Tool |
|
||||||
|
|---|---|
|
||||||
|
| Create | `memory_add_gap(question, reason, priority)` |
|
||||||
|
| List (filter by status) | `memory_list_gaps(status?, limit?)` |
|
||||||
|
| Read one | `memory_get_gap(gap_id)` |
|
||||||
|
| Edit text / reason / priority / status | `memory_update_gap(gap_id, ...)` |
|
||||||
|
| Delete | `memory_delete_gap(gap_id)` |
|
||||||
|
|
||||||
|
**Statuses:** `open` → `researching` → `resolved` | `dismissed`
|
||||||
|
|
||||||
|
**Reasons:** `low_coverage` · `low_confidence` · `stale` · `contested` · `user_query`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- When you start working on a gap → set `status='researching'`.
|
||||||
|
- When the knowledge is in the base → set `status='resolved'`.
|
||||||
|
- If the question turned out irrelevant → `status='dismissed'` (prefer this over deleting).
|
||||||
|
- Raise `priority` when the user asks about the topic; lower it when it stops mattering.
|
||||||
|
- Keep questions **specific and answerable** ("How does HNSW build its graph?" not "HNSW").
|
||||||
+8
-5
@@ -1,7 +1,7 @@
|
|||||||
# Pegas Memory — Architecture
|
# Pegas Memory — Architecture
|
||||||
|
|
||||||
Pegas Memory is the long-term memory and knowledge base for the **Pegas** research agent.
|
Pegas Memory is the long-term memory and knowledge base for the **Pegas** research agent.
|
||||||
It is a Python service exposed to the agent as an **MCP server** (13 tools).
|
It is a Python service exposed to the agent as an **MCP server** (16 tools).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ It is a Python service exposed to the agent as an **MCP server** (13 tools).
|
|||||||
└───────────────────┬──────────────────────┘
|
└───────────────────┬──────────────────────┘
|
||||||
│ JSON-RPC 2.0 / stdio
|
│ JSON-RPC 2.0 / stdio
|
||||||
┌───────────────────▼──────────────────────┐
|
┌───────────────────▼──────────────────────┐
|
||||||
│ mcp_server.py (tool layer, 13 tools) │
|
│ mcp_server.py (tool layer, 16 tools) │
|
||||||
└───────────────────┬──────────────────────┘
|
└───────────────────┬──────────────────────┘
|
||||||
│ Python API
|
│ Python API
|
||||||
┌────────────────────────────┼────────────────────────────┐
|
┌────────────────────────────┼────────────────────────────┐
|
||||||
@@ -96,7 +96,7 @@ agent's experience, atoms are curated facts.
|
|||||||
| `outbox_sync.py` | CDC: PostgreSQL outbox → Qdrant and Memgraph |
|
| `outbox_sync.py` | CDC: PostgreSQL outbox → Qdrant and Memgraph |
|
||||||
| `dedup.py` | semantic deduplication by embedding cosine (threshold 0.90) |
|
| `dedup.py` | semantic deduplication by embedding cosine (threshold 0.90) |
|
||||||
| `verify.py` | self-critique (LLM verdict) + cross-confirmation (multiple sources) |
|
| `verify.py` | self-critique (LLM verdict) + cross-confirmation (multiple sources) |
|
||||||
| `research.py` | gap-filling loop: web search → ingest → commit |
|
| `research.py` | research-gap management (PostgreSQL only): add / list / get / update / delete |
|
||||||
| `code_symbols.py` | tree-sitter symbol extraction |
|
| `code_symbols.py` | tree-sitter symbol extraction |
|
||||||
| `code_graph.py` | tree-sitter dependency extraction (calls/imports/inherits) |
|
| `code_graph.py` | tree-sitter dependency extraction (calls/imports/inherits) |
|
||||||
| `inference.py` | forward-chaining logical rules over the graph until fixpoint |
|
| `inference.py` | forward-chaining logical rules over the graph until fixpoint |
|
||||||
@@ -153,7 +153,7 @@ Only single-token kinds (no spaces) are accepted — human-readable rules from d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. MCP tools (13)
|
## 6. MCP tools (16)
|
||||||
|
|
||||||
| Tool | Purpose |
|
| Tool | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -164,7 +164,10 @@ Only single-token kinds (no spaces) are accepted — human-readable rules from d
|
|||||||
| `memory_ingest` | ingest text/URL/file → atoms |
|
| `memory_ingest` | ingest text/URL/file → atoms |
|
||||||
| `memory_verify` | self-critique + cross-confirmation |
|
| `memory_verify` | self-critique + cross-confirmation |
|
||||||
| `memory_add_gap` | register a research gap |
|
| `memory_add_gap` | register a research gap |
|
||||||
| `memory_list_gaps` | list open gaps |
|
| `memory_list_gaps` | list gaps (optional status filter) |
|
||||||
|
| `memory_get_gap` | read one gap by id |
|
||||||
|
| `memory_update_gap` | edit question / reason / priority / status |
|
||||||
|
| `memory_delete_gap` | delete a gap |
|
||||||
| `memory_store_code` | extract + store code symbols |
|
| `memory_store_code` | extract + store code symbols |
|
||||||
| `memory_search_code` | lexical search over code symbols |
|
| `memory_search_code` | lexical search over code symbols |
|
||||||
| `memory_code_graph` | build symbol + dependency graph |
|
| `memory_code_graph` | build symbol + dependency graph |
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ schema.sql — DDL PostgreSQL
|
|||||||
|
|
||||||
- **Сервер**: [mcp_server.py](./mcp_server.py) — чистый stdlib, JSON-RPC 2.0, stdio.
|
- **Сервер**: [mcp_server.py](./mcp_server.py) — чистый stdlib, JSON-RPC 2.0, stdio.
|
||||||
- **Инструменты** (видны модели как `mcp__pegas-memory__*`):
|
- **Инструменты** (видны модели как `mcp__pegas-memory__*`):
|
||||||
`memory_search`, `memory_store`, `memory_ingest`, `memory_add_gap`,
|
`memory_search`, `memory_store`, `memory_ingest`, `memory_stats`, `memory_get_context`,
|
||||||
`memory_list_gaps`, `memory_stats`, `memory_get_context`.
|
`memory_verify`, `memory_store_code`, `memory_search_code`, `memory_code_graph`,
|
||||||
|
`memory_infer`, `memory_query_graph`,
|
||||||
|
`memory_add_gap`, `memory_list_gaps`, `memory_get_gap`, `memory_update_gap`, `memory_delete_gap`.
|
||||||
- **Регистрация**: в `~/.dsh/profiles/web/cordis.patch.yml` через `@deepseek-ai/dsh-mcp-client`.
|
- **Регистрация**: в `~/.dsh/profiles/web/cordis.patch.yml` через `@deepseek-ai/dsh-mcp-client`.
|
||||||
- **Live-reload**: профиль web имеет `patchReload: live` — изменения конфига
|
- **Live-reload**: профиль web имеет `patchReload: live` — изменения конфига
|
||||||
подхватываются без рестарта (проверено: сервер спавнится автоматически).
|
подхватываются без рестарта (проверено: сервер спавнится автоматически).
|
||||||
@@ -75,3 +77,20 @@ export PROXY_URL="socks5://127.0.0.1:1080" # hysteria2
|
|||||||
```
|
```
|
||||||
Когда прокси задан, `parse_web` ходит через него; иначе — напрямую.
|
Когда прокси задан, `parse_web` ходит через него; иначе — напрямую.
|
||||||
Поддержка SOCKS5 уже установлена (`httpx[socks]`).
|
Поддержка SOCKS5 уже установлена (`httpx[socks]`).
|
||||||
|
|
||||||
|
## Управление пробелами (gaps)
|
||||||
|
|
||||||
|
Пробел — вопрос, на который в базе пока нет ответа. Полный жизненный цикл:
|
||||||
|
|
||||||
|
| Операция | Инструмент |
|
||||||
|
|---|---|
|
||||||
|
| Создать | `memory_add_gap(question, reason, priority)` |
|
||||||
|
| Список (с фильтром) | `memory_list_gaps(status?, limit?)` |
|
||||||
|
| Прочитать один | `memory_get_gap(gap_id)` |
|
||||||
|
| Редактировать | `memory_update_gap(gap_id, question?/reason?/priority?/status?)` |
|
||||||
|
| Удалить | `memory_delete_gap(gap_id)` |
|
||||||
|
|
||||||
|
Статусы: `open` → `researching` → `resolved` | `dismissed`.
|
||||||
|
Причины: `low_coverage`, `low_confidence`, `stale`, `contested`, `user_query`.
|
||||||
|
|
||||||
|
Работает целиком на PostgreSQL — без веб-поиска. Поиск выполняет модель встроенным `web_search`.
|
||||||
+110
-7
@@ -35,6 +35,13 @@ def _conn():
|
|||||||
|
|
||||||
|
|
||||||
def dict_row(cursor):
|
def dict_row(cursor):
|
||||||
|
"""Row factory: dict по именам колонок.
|
||||||
|
|
||||||
|
cursor.description может быть None (UPDATE/DELETE без RETURNING) —
|
||||||
|
в этом случае возвращаем строку как есть, иначе падаем на итерации None.
|
||||||
|
"""
|
||||||
|
if cursor.description is None:
|
||||||
|
return lambda row: row
|
||||||
cols = [d.name for d in cursor.description]
|
cols = [d.name for d in cursor.description]
|
||||||
return lambda row: dict(zip(cols, row))
|
return lambda row: dict(zip(cols, row))
|
||||||
|
|
||||||
@@ -114,16 +121,67 @@ def _tool_add_gap(args):
|
|||||||
question = str(args.get("question") or "").strip()
|
question = str(args.get("question") or "").strip()
|
||||||
if not question:
|
if not question:
|
||||||
return json.dumps({"error": "пустой question"}, ensure_ascii=False), True
|
return json.dumps({"error": "пустой question"}, ensure_ascii=False), True
|
||||||
research.add_gap(_conn(), question,
|
try:
|
||||||
|
gap_id = research.add_gap(_conn(), question,
|
||||||
reason=str(args.get("reason") or "user_query"),
|
reason=str(args.get("reason") or "user_query"),
|
||||||
priority=float(args.get("priority") or 0.5))
|
priority=float(args.get("priority") or 0.5))
|
||||||
return json.dumps({"added": question}, ensure_ascii=False), False
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}, ensure_ascii=False), True
|
||||||
|
return json.dumps({"gap_id": gap_id, "question": question, "status": "open"},
|
||||||
|
ensure_ascii=False), False
|
||||||
|
|
||||||
|
|
||||||
def _tool_list_gaps(args):
|
def _tool_list_gaps(args):
|
||||||
from pegas_memory import research
|
from pegas_memory import research
|
||||||
gaps = research.detect_gaps(_conn())
|
status = args.get("status") or None
|
||||||
return json.dumps({"gaps": gaps}, ensure_ascii=False, indent=2), False
|
limit = int(args.get("limit") or 50)
|
||||||
|
try:
|
||||||
|
gaps = research.list_gaps(_conn(), status=status, limit=limit)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}, ensure_ascii=False), True
|
||||||
|
return json.dumps({"status": status or "all", "count": len(gaps), "gaps": gaps},
|
||||||
|
ensure_ascii=False, indent=2, default=str), False
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_get_gap(args):
|
||||||
|
from pegas_memory import research
|
||||||
|
gap_id = args.get("gap_id")
|
||||||
|
if gap_id is None:
|
||||||
|
return json.dumps({"error": "нужен gap_id"}, ensure_ascii=False), True
|
||||||
|
gap = research.get_gap(_conn(), int(gap_id))
|
||||||
|
if gap is None:
|
||||||
|
return json.dumps({"error": f"пробел {gap_id} не найден"}, ensure_ascii=False), True
|
||||||
|
return json.dumps(gap, ensure_ascii=False, indent=2, default=str), False
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_update_gap(args):
|
||||||
|
from pegas_memory import research
|
||||||
|
gap_id = args.get("gap_id")
|
||||||
|
if gap_id is None:
|
||||||
|
return json.dumps({"error": "нужен gap_id"}, ensure_ascii=False), True
|
||||||
|
fields = {k: args[k] for k in ("question", "reason", "priority", "status")
|
||||||
|
if args.get(k) is not None}
|
||||||
|
if not fields:
|
||||||
|
return json.dumps({"error": "нечего обновлять (укажи question/reason/priority/status)"},
|
||||||
|
ensure_ascii=False), True
|
||||||
|
try:
|
||||||
|
ok = research.update_gap(_conn(), int(gap_id), **fields)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}, ensure_ascii=False), True
|
||||||
|
if not ok:
|
||||||
|
return json.dumps({"error": f"пробел {gap_id} не найден"}, ensure_ascii=False), True
|
||||||
|
return json.dumps({"updated": int(gap_id), "gap": research.get_gap(_conn(), int(gap_id))},
|
||||||
|
ensure_ascii=False, indent=2, default=str), False
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_delete_gap(args):
|
||||||
|
from pegas_memory import research
|
||||||
|
gap_id = args.get("gap_id")
|
||||||
|
if gap_id is None:
|
||||||
|
return json.dumps({"error": "нужен gap_id"}, ensure_ascii=False), True
|
||||||
|
if not research.delete_gap(_conn(), int(gap_id)):
|
||||||
|
return json.dumps({"error": f"пробел {gap_id} не найден"}, ensure_ascii=False), True
|
||||||
|
return json.dumps({"deleted": int(gap_id)}, ensure_ascii=False), False
|
||||||
|
|
||||||
|
|
||||||
def _tool_stats(args):
|
def _tool_stats(args):
|
||||||
@@ -296,9 +354,51 @@ TOOLS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "memory_list_gaps",
|
"name": "memory_list_gaps",
|
||||||
"description": "Список открытых пробелов (вопросов, на которые пока нет ответа) в базе, "
|
"description": "Список пробелов (вопросов без ответа). По умолчанию — открытые, по приоритету. "
|
||||||
"отсортированный по приоритету. Вызывай, чтобы узнать, что ещё нужно исследовать.",
|
"Вызывай, чтобы узнать, что ещё нужно исследовать.",
|
||||||
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {"type": "string", "description": "Фильтр: open | researching | resolved | dismissed (опционально)."},
|
||||||
|
"limit": {"type": "integer", "default": 50, "description": "Сколько вернуть (1-200)."},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "memory_get_gap",
|
||||||
|
"description": "Получить один пробел по id (все поля).",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"gap_id": {"type": "integer", "description": "ID пробела."}},
|
||||||
|
"required": ["gap_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "memory_update_gap",
|
||||||
|
"description": "Отредактировать пробел: текст вопроса, причину, приоритет и/или статус. "
|
||||||
|
"Статусы: open (открыт) | researching (взят в работу) | resolved (закрыт) | dismissed (не актуален).",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"gap_id": {"type": "integer", "description": "ID пробела."},
|
||||||
|
"question": {"type": "string", "description": "Новая формулировка вопроса."},
|
||||||
|
"reason": {"type": "string", "description": "low_coverage|low_confidence|stale|contested|user_query."},
|
||||||
|
"priority": {"type": "number", "description": "Новый приоритет 0-1."},
|
||||||
|
"status": {"type": "string", "description": "open | researching | resolved | dismissed."},
|
||||||
|
},
|
||||||
|
"required": ["gap_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "memory_delete_gap",
|
||||||
|
"description": "Удалить пробел по id (если он больше не нужен). "
|
||||||
|
"Обычно вместо удаления лучше перевести в status=dismissed или resolved.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"gap_id": {"type": "integer", "description": "ID пробела."}},
|
||||||
|
"required": ["gap_id"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "memory_stats",
|
"name": "memory_stats",
|
||||||
@@ -441,6 +541,9 @@ def handle(msg):
|
|||||||
"memory_ingest": _tool_ingest,
|
"memory_ingest": _tool_ingest,
|
||||||
"memory_add_gap": _tool_add_gap,
|
"memory_add_gap": _tool_add_gap,
|
||||||
"memory_list_gaps": _tool_list_gaps,
|
"memory_list_gaps": _tool_list_gaps,
|
||||||
|
"memory_get_gap": _tool_get_gap,
|
||||||
|
"memory_update_gap": _tool_update_gap,
|
||||||
|
"memory_delete_gap": _tool_delete_gap,
|
||||||
"memory_stats": _tool_stats,
|
"memory_stats": _tool_stats,
|
||||||
"memory_store_code": _tool_store_code,
|
"memory_store_code": _tool_store_code,
|
||||||
"memory_search_code": _tool_search_code,
|
"memory_search_code": _tool_search_code,
|
||||||
|
|||||||
+93
-63
@@ -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
|
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:
|
with conn.cursor() as cur:
|
||||||
|
if status:
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, question, reason, priority, status FROM pm_research_gap "
|
f"SELECT {_COLS} FROM pm_research_gap WHERE status = %s "
|
||||||
"WHERE status = 'open' ORDER BY priority DESC, id LIMIT 20"
|
"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()
|
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:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
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),
|
(question, reason, priority),
|
||||||
)
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
conn.commit()
|
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. веб-поиск;
|
Типичные переходы: status='researching' (взял в работу),
|
||||||
2. для каждого источника: полная загрузка URL; при недоступности (гео-блок,
|
status='resolved' (закрыт), status='dismissed' (не актуален).
|
||||||
нет прокси) — дистилляция сниппета из поиска;
|
|
||||||
3. пометка вопроса resolved.
|
|
||||||
"""
|
"""
|
||||||
result = {"question": question, "sources": [], "atoms_created": 0}
|
sets: list[str] = []
|
||||||
sources = web_search(question)
|
vals: list = []
|
||||||
result["sources"] = [s["url"] for s in sources if s.get("url")]
|
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:
|
if not sets:
|
||||||
url = s.get("url")
|
return get_gap(conn, gap_id) is not None
|
||||||
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}")
|
|
||||||
|
|
||||||
|
vals.append(gap_id)
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(f"UPDATE pm_research_gap SET {', '.join(sets)} WHERE id = %s", vals)
|
||||||
"UPDATE pm_research_gap SET status = 'resolved' WHERE question = %s AND status = 'open'",
|
found = cur.rowcount > 0
|
||||||
(question,),
|
|
||||||
)
|
|
||||||
conn.commit()
|
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