Compare commits

..
6 Commits
Author SHA1 Message Date
Pegas 51b79d4045 docs: fix ingestion-rule typos in DOCSTRAT.md
- repair 'истина'->'источник', remove injected CJK chars on 'расруит ошибку'
- fix 'основа' spelling after 'Английская'
- no Latin/Cyrillic mixups, no CJK leakage
2026-10-15 23:26:47 +05:00
Pegas b4552df1a6 docs: EN source-of-truth workflow (en2ru + DOCSTRAT)
- en2ru.py: EN markdown -> Russian via Ollama translategemma-4b (tag: translategemma:latest)
  strips code-fence artifacts, prints jargon-review hints to stderr
- DOCSTRAT.md: rule -- EN canonical source, RU for comprehension only,
  never edit on drifted Russian (dedup/receipt mistranslation guard)
- ROADMAP.ru.md: sample RU generated from EN source
- AGENTS.md: section 8 documenting the EN-first workflow
2026-10-15 23:13:12 +05:00
Pegas bc68b68598 fix(pegas-memory): correct Russian words in mem0 roadmap section
- Пернигаемое/Пернигаем -> Перенимаемое/Перенимаем
- Гибрид-раврал -> гибридный retrieval (avoid misspooled transliteration)
- фрагмации -> фрагментации
- (2) -> ⑵ (circled, consistent with doc numbering)
2026-10-15 22:49:35 +05:00
Pegas 726e360926 docs(pegas-memory): roadmap — mem0 comparison, procedural-memory patterns
- Tier 2 ⑵: procedural memory (mem0 'procedural memory') with receipt
  format (goal -> steps -> conditions -> result), atom-based (no Hermes md).
- New section 'Borrow from mem0 (patterns, not taxonomy)': procedural
  receipt, hybrid retrieval (cache+vector+graph rerank), entity resolution.
- Note: mem0 3-type taxonomy not introduced (declarative=atoms, episodic=
  pm_episodic_trace; only procedural is net-new).
2026-10-15 22:40:17 +05:00
Pegas 13e0b87446 fix(pegas-memory): web fetch SOCKS5h proxy fallback + stale_guard supported double-count fix
- ingest.parse_web: direct fetch; on block/timeout retry via socks5h://127.0.0.1:1080 (SOCKS_FALLBACK_PROXY); raises ValueError if both fail.
- config: add SOCKS_FALLBACK_PROXY default (socks5h://127.0.0.1:1080).
- self_improve.stale_guard: supported LLM verdict now sets status_change='supported' (authoritative), no fallthrough to age fallback -> no double-checked/skipped_no_source.
- README: document auto-fallback.
2026-10-15 22:25:22 +05:00
n1kt4r eca8a11e23 feat(pegas-memory): Tier-1 self-improvement tools (commit + staleness)
- memory_commit: closes the 'commit hole' — writes verified answers from
  closed gaps back into atoms (mandatory dedup); leaves gap open if no
  verified answer exists. Reads verified status from PostgreSQL (source of
  truth), not the Qdrant projection.
- memory_stale: staleness guard with two signals — content-staleness
  (re-fetch + LLM verdict, needs proxy for foreign sites) and age-staleness
  (proxy-free fallback by pm_document.fetched_at, max_age_days).
- db: populate pm_document.fetched_at on upsert (freshness signal).
- docs: README/AGENTS/ARCHITECTURE/EXAMPLES updated (18 tools).
- self_improve.py: new module (227 lines).
2026-10-15 22:06:30 +05:00
13 changed files with 797 additions and 19 deletions

No files matched your search

+13
View File
@@ -45,6 +45,8 @@ Read this file before answering any question about knowledge, memory, or researc
| "Show one gap" | `memory_get_gap` | | "Show one gap" | `memory_get_gap` |
| "Delete a gap" | `memory_delete_gap` | | "Delete a gap" | `memory_delete_gap` |
| "Run logical inference" | `memory_infer` | | "Run logical inference" | `memory_infer` |
| "Commit verified answers from closed gaps into atoms" | `memory_commit(gap_ids, dedup?)` — закрывает «дыра commit»: answer пробела пишется в атомы (dedup обязательный); без верифицированного ответа пробел не трогает |
| "Mark stale atoms" | `memory_stale(atom_id?/all?, limit?, sample?, min_confidence?, age_only?, max_age_days?)` — гвард сталкности: content-staleness (рефетч + LLM, нужен прокси для заруб.) или age-staleness (proxy-free по `fetched_at`) |
**Ambiguity rule:** if the question is about *knowledge content* → memory tools. **Ambiguity rule:** if the question is about *knowledge content* → memory tools.
If it is about *the outside world right now* → `web_search` (built-in). If it is about *the outside world right now* → `web_search` (built-in).
@@ -132,3 +134,14 @@ A **gap** is a question you know you cannot answer yet. Manage them, don't just
- If the question turned out irrelevant → `status='dismissed'` (prefer this over deleting). - 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. - 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"). - Keep questions **specific and answerable** ("How does HNSW build its graph?" not "HNSW").
## 8. Documentation language (English source of truth)
All docs (`*.md`) are written in **English = canonical source of truth**. Russian
(`*.ru.md`) is generated for quick comprehension only; terminology may drift and must
NEVER be used as the editing basis (editing on drifted Russian breaks intent).
Workflow: write/edit EN → `python3 en2ru.py file.md` → `file.ru.md`. See
[`DOCSTRAT.md`](./DOCSTRAT.md). `translategemma:latest` renders clean orthography but
mishandles jargon (`dedup`→«дублирование», `receipt`→«результат»), so review the
stderr hints; keep unsure terms in Latin.
+2 -2
View File
@@ -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** (16 tools). It is a Python service exposed to the agent as an **MCP server** (18 tools).
--- ---
@@ -14,7 +14,7 @@ It is a Python service exposed to the agent as an **MCP server** (16 tools).
└───────────────────┬──────────────────────┘ └───────────────────┬──────────────────────┘
│ JSON-RPC 2.0 / stdio │ JSON-RPC 2.0 / stdio
┌───────────────────▼──────────────────────┐ ┌───────────────────▼──────────────────────┐
│ mcp_server.py (tool layer, 16 tools) │ │ mcp_server.py (tool layer, 18 tools) │
└───────────────────┬──────────────────────┘ └───────────────────┬──────────────────────┘
│ Python API │ Python API
┌────────────────────────────┼────────────────────────────┐ ┌────────────────────────────┼────────────────────────────┐
+46
View File
@@ -0,0 +1,46 @@
# Стратегия документов (English source of truth)
## Правило (обязательное)
- **Английский — канонический источник (source of truth).** Все правки вносятся
**только** в исходный `*.md` (EN).
- **Русский — только для бегного понимания.** Это вычислительный **инструмент**;
термиология может «поплыть».
- **Ник не использовать `.ru.md` за основу для правок.** Правим EN → перезапускаем
`en2ru.py`. Нарушение → накаплием ошибочный русский текст и **не можем сделать то,
что задумано** (опора на «поплывшую» термиологию ломает смысл).
### Причина
`translategemma:latest` **чисто пишет орфографию**, но **джаргон** переводит криво —
вплоть до переворота значения: `dedup` → «дублирование» (обратное значение),
`receipt` → «результат». Порусскому — вторичен и нуждается в ревью джаргона.
## Ингест в базу (запрет русского с опечаткой)
- **Ingest только из английских источников.** `memory_ingest` / `memory_store`
принимают **EN**-текст → дестилируются в атомы.
- **Русский в атомы — только после орфографической чистки.** Если русский источник
неизбежен → сначала прогоним через `en2ru.py` / проверку орфографии → только чистый
текст может стать атомом.
- **`.ru.md` — никогда не ingest'ится в атомы.** Это внешний слой понимания, вне базы.
- **Фиаско**, если русское с опечаткой попадёт в атомы: такой атом станет «источник»
для ответов и расруит ошибку по кругу. Английская основа этого не позволяет.
## Как
```bash
python3 en2ru.py ROADMAP.md # -> ROADMAP.ru.md
cat doc.md | python3 en2ru.py - # stdin -> doc.ru.md
```
- Вывод: `<file>.ru.md`.
- Джаргон-ревью (термы, где модель ошибается) — в **stderr**, не в RU-выводе.
- Оффлайн: зависимостей нет (`urllib`), модель уже в Ollama (`translategemma:latest`).
## Порядок работ
1. Пишем/правим **EN** источник.
2. `en2ru.py` → генерим **RU**.
3. Смотри **stderr** — правь flagged-термы (ревью, не авто).
4. Храни **оба** файла рядом: `X.md` (EN, канон) + `X.ru.md` (RU, понимание).
+38
View File
@@ -246,6 +246,44 @@ memory_query_graph(kind = "depends_on", top_k = 10)
--- ---
## Example 9 — Self-improvement (commit + staleness)
Commit a verified answer from a closed gap into atoms (closes the «commit hole»):
```
memory_commit(gap_ids = [3], dedup = true)
```
**Result** (actual, abridged):
```json
{"committed": 1, "resolved": 1, "skipped_empty": 0, "skipped_duplicate": 0,
"atoms": [218]}
```
Mark stale atoms — content-staleness (re-fetch + LLM, needs proxy for foreign sites)
or age-staleness (proxy-free, by `fetched_at`):
```
memory_stale(atom_id = 126)
memory_stale(all = true, limit = 20, sample = true,
min_confidence = 0.5, age_only = true, max_age_days = 90)
```
**Result** (abridged):
```json
{"checked": 0, "stale": 1, "supported": 0, "skipped_no_source": 0,
"atom_ids": [126], "reasons": {"126": "content_age 10d"}}
```
> `age_only = true` skips the network re-fetch and flags atoms whose source
> `fetched_at` is older than `max_age_days` (default 90). For foreign sites
> (memgraph.com, cloudflare/geo-blocked) set `PROXY_URL` or the re-fetch
> returns `ConnectTimeout` and falls back to age-staleness.
---
## Anti-patterns (do not do this) ## Anti-patterns (do not do this)
| Anti-pattern | Why it is wrong | Do instead | | Anti-pattern | Why it is wrong | Do instead |
+24 -2
View File
@@ -45,7 +45,8 @@ schema.sql — DDL PostgreSQL
`memory_search`, `memory_store`, `memory_ingest`, `memory_stats`, `memory_get_context`, `memory_search`, `memory_store`, `memory_ingest`, `memory_stats`, `memory_get_context`,
`memory_verify`, `memory_store_code`, `memory_search_code`, `memory_code_graph`, `memory_verify`, `memory_store_code`, `memory_search_code`, `memory_code_graph`,
`memory_infer`, `memory_query_graph`, `memory_infer`, `memory_query_graph`,
`memory_add_gap`, `memory_list_gaps`, `memory_get_gap`, `memory_update_gap`, `memory_delete_gap`. `memory_add_gap`, `memory_list_gaps`, `memory_get_gap`, `memory_update_gap`, `memory_delete_gap`,
`memory_commit`, `memory_stale`.
- **Регистрация**: в `~/.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,7 +76,10 @@ Cloudflare anti-bot (neo4j.com — 403). Поведение research-цикла:
```bash ```bash
export PROXY_URL="socks5://127.0.0.1:1080" # hysteria2 export PROXY_URL="socks5://127.0.0.1:1080" # hysteria2
``` ```
Когда прокси задан, `parse_web` ходит через него; иначе — напрямую.
**Авто-повор:** `parse_web` сначала грузит URL напрямую; при таймауте/блокировке повторяет запрос через `socks5h://127.0.0.1:1080` (дефолт,
`SOCKS_FALLBACK_PROXY`). Если и проки недоступен — падает с
`ValueError: no main content ... (direct + ... fallback failed)`.
Поддержка SOCKS5 уже установлена (`httpx[socks]`). Поддержка SOCKS5 уже установлена (`httpx[socks]`).
## Управление пробелами (gaps) ## Управление пробелами (gaps)
@@ -94,3 +98,21 @@ export PROXY_URL="socks5://127.0.0.1:1080" # hysteria2
Причины: `low_coverage`, `low_confidence`, `stale`, `contested`, `user_query`. Причины: `low_coverage`, `low_confidence`, `stale`, `contested`, `user_query`.
Работает целиком на PostgreSQL — без веб-поиска. Поиск выполняет модель встроенным `web_search`. Работает целиком на PostgreSQL — без веб-поиска. Поиск выполняет модель встроенным `web_search`.
## Самообучение (self-improvement)
Два инструмента закрывают петлю commit/maintain (см. `ROADMAP.md`):
| Операция | Инструмент |
|---|---|
| Записать верифицированный answer закрытого пробела в атомы | `memory_commit(gap_ids, dedup?)` |
| Пометить устаревшие атомы в `stale` | `memory_stale(atom_id?/all?, limit?, sample?, min_confidence?, age_only?, max_age_days?)` |
- **`memory_commit`** — для каждого `gap_id` ищет верифицированные атомы-ответы, дистиллирует
канонический атом (обязательный `dedup`) и закрывает пробел. Если верифицированного ответа нет —
пробел не трогает (`skipped_empty`).
- **`memory_stale`** — два сигнала: **content-staleness** (рефетч источника + LLM-вердикт, для
зарубежных сайтов нужен `PROXY_URL`) и **age-staleness** (proxy-free, по `fetched_at`, порог
`max_age_days`, default 90). Лимит LLM-вызовов — `limit` + `sample`.
Источники: `web`/`text`/`epub`/`pdf`. Проверено: рефетч memgraph.com без прокси → `ConnectTimeout`.
+81 -1
View File
@@ -92,6 +92,86 @@ outbox — CDC-журнал для синка в Qdrant + Memgraph
- Memgraph in-memory: объём графа ограничен RAM. - Memgraph in-memory: объём графа ограничен RAM.
- Дистилляция зависит от качества локальной LLM — нужна верификация. - Дистилляция зависит от качества локальной LLM — нужна верификация.
## Как проверять ## Самообучение (self-improvement) — статус и планы
### Что работает (4 механизма, но НЕ замкнутый loop)
1. **Верификация** (`verify_atom`/`memory_verify` + `verify.py`). LLM self-critique
(`duplicate | corroborates | contradicts`) + **межисточная конфирмация** (≥1 другой
`doc_id`, cosine ≥ 0.90) → ставка растёт, статус `verified`. Самое зрелое звено.
2. **Логическая инференция** (`memory_infer` + `inference.py`). Применение правил к рёбрам
графа до fixpoint (`imports → depends_on` и т.д.). ⚠️ Даст **только связи**, не новые факты.
Правила **статичные/вручную написанные**, новых не выводит сама.
3. **Синтез контекста** (`memory_get_context`). Ищет Qdrant, кластеризует + суммирует атомы.
Может **создавать gap** из исходного текста → есть *реактивное* появление вопроса из источника.
4. **Эпизодические следы** (`pm_episodic_trace`). План/действие/наблюдение/рефлексия —
отдельно от БЗ. Самый «рефлексивный», но **условный**, не привязанный к gap.
### Что не хватает (дыры в петлях, а не в примитивах)
| Пробел | Почему критично |
|---|---|
| **① Цепочка gap↛answer («дыра commit»)** | При закрытии gap **ответ не автоматически
пишется обратно в атомы** → знания улетучиваются, приходится пере-исследовать. |
| **② Самообучение схемы инференции** | Ничего не *предлагает новые правила* из повторяющихся
`verified`-атомов. Это как раз слабое место Hermes-аналога. |
| **③ Сталкнутость/забвение (staleness guard)** | `verify` ставит `verified`, но **ничто не
помечает `stale`** пере-проверкой по свежему источнику. Путь →`stale` не идёт автоматически. |
| **④ Поверхность противоречий** | `verify` ловит конфликт только в окрестности атома. Ничего
не сканирует весь граф → противоречия не переходят в gap автоматически. |
| **⑤ Процедурная/рецепт-память** | Повторяющиеся успешные воркфлоу не ловятся как переиспользуемые атомы. |
| **⑥ Пассивное создание gap** | Gap создаются через `add_gap` (вручную) или из `memory_get_context`.
Нет авто-обнаружения из не-верифицированного/переносного контента. |
### Приоритет
Hermes-анализ показал: Pegas сильна в **«detect»** (verify, infer), слаба в **«commit»** и
**«maintain»**. Слабые места — **feedback-петли**, а не примитивы. Поэтому максимальный выигрыш:
① дыра «commit» × ③ гвард сталкнутости × ② самообучение правил инференции — это **закрывает loop**.
### Планируемые улучшения (Tier)
- **Tier 1 — высокий приоритет, безопасно / small**
- ① **Закрыть дыру** — после `verify`, подняв атомы из gap, запустить синтез обратно в
`memory_ingest/synthesis` (письмо верифицированного ответа в атомы).
- ③ **Гвард сталкнутости** — `update_status`: пере-`web_search` по URL/date атома;
противоречит/устарел → `stale`. Внедряет путь →`stale`, который сейчас не ходит.
- **Tier 2 — средний приоритет**
- ② **Самообучение правил инференции** — `memory_infer` **предлагает кандидаты**
`logical_rule` на ревью (никогда не применяет автоматически).
- ⑤ **Процедурная/рецепт-память** (паттерн из mem0, `procedural memory`) — agent действует
(это я через `mcp__pegas-memory__*`), поэтому повторы успешных `/запрос` или воркфлоу ≥N
раз с `verified`-результатом → **атомный** паттерн-рецепт (не файлы Hermes, а атомно с
dedup). Формат receipt: **goal → шаги → условия → результат** (переиспользуемый воркфлоу).
- **Tier 3 — структурные**
- ④ **Скунт-скан** — периодический бэч-проход графа на противоречия → авто gap.
- ⑥ **Авто-обнаружение gap** — `verify` открывает gap на конфликт/дыру в источнике.
### Перенимаемое из mem0 (паттерны, а не таксомология)
- **Процедурный receipt** (`goal -> шаги -> условия -> результат`) — структура переиспользуемого
воркфлоу (mem0 `procedural memory`). Хранится как типизированный атом с dedup, не как
накопитель markdown-файлов (как у Hermes) -> no prompt-bloat.
- **гибридный retrieval** (cache + vector + graph rerank) — поднимает recall у слабо-релевантных
и низкоуверенных атомов. Механизм: recent-cache (Qdrant) + reranking по graph-ёбрам.
- **Entity resolution** — объединение вариантов одной entity между источниками (против
фрагментации атомов на «один объект, разные формулировки»).
> Таксомология «эпизод/declarative/процедур» не вводится: declarative = атомы, эпизод =
`pm_episodic_trace`, новый только процедурный ⑵. Перенимаем паттерны, а не метки.
### Atom-efficiency guardrail (обязательное)
Чтобы self-improvement **не скатился в гермесовский файловый балласт**, все записи и
обновления подчинены лимитам эффективности атомной памяти (преимущество Peras над Hermes):
- **Dedup обязателен** при записи (`memory_store`/synthesis): cosine ≥ 0.90, без дублей.
- **Лимит web/пере-записей** (staleness-③): batchами + сэмплом, не per-atom без порога.
- **Bound на рост графа/атомов**: Memgraph in-memory → RAM-лимит, авто-`stale`/архив старых.
- **Атомно, а не файльно**: паттерны-рецепты хранятся как типизированные атомы, не как
накопитель markdown-файлов (как у Hermes) → no prompt-bloat.
### Как проверять
- recall@k на реальных запросах против существующей `knowledge_base` (bge-m3 384-dim). - recall@k на реальных запросах против существующей `knowledge_base` (bge-m3 384-dim).
- Latency эмбеддинга + потребление GPU параллельно с LLM (a2000 12 GB). - Latency эмбеддинга + потребление GPU параллельно с LLM (a2000 12 GB).
- **Самоеобучение**: доля авто-закрытых gap «commit»-петлёй, доля атомов, ушедших в `stale`
за квартал, доля предложенных → принято правил инференции (ревью-соотношение).
+148
View File
@@ -0,0 +1,148 @@
# Pegas Memory — Roadmap
Подсистема памяти и базы знаний агента Alicorn («Pegas»). Реализация на Python как отдельный
сервис, который Rust-ядро Alicorn вызывает через MCP/HTTP. Спецификация — в
`alicorn-agent/ARCHITECTURE.md` (ADR-4, ADR-11, ADR-12).
## Принятые решения (по итогам обсуждения)
1. **PostgreSQL = единственный источник истины.** Текст, метаданные, трейсы, верификация, outbox.
2. **Qdrant = векторный срез** (EmbeddingGemma 2, dim=768). Замена pgvector.
3. **Memgraph = графовый срез** (связи атомов, зависимости кода). Замена Apache AGE.
4. **pgvector и AGE — НЕ используем.** Векторы только в Qdrant, граф только в Memgraph.
5. **Единица знания — типизированный «атом»**, не чистый текст.
Типы: `text | definition | logical_rule | code | table | fact`.
6. **Память агента (episodic) ≠ база знаний (curated).** Разные таблицы/подсистемы.
7. **Код хранится отдельно**: content-addressed исходники + tree-sitter символы + граф зависимостей.
8. **Логические знания** — структурные правила (pattern→conclusion) с инференцией в графе.
9. **Исследование пробелов** — замкнутый цикл: detect→plan→acquire→parse→extract→value→verify→commit.
## Инфраструктура (проверено на сервере)
| Компонент | Адрес | Состояние |
|---|---|---|
| PostgreSQL 16 | atom-db :5434 (user atom, db content_atom) | работает |
| Qdrant | :6333 | работает |
| Memgraph (bolt) | :7687 | работает |
| Ollama | :11434 | работает |
| EmbeddingGemma 2 | `embeddinggemma-2-740m-Q8_0` | dim=768, R@1 0.922 (лучший для ru) |
| LLM-генерация | `lfm25-24b-a2b:128k`, `qwen3.5:4b`, `gemma4:E4B` | работает |
> EmbeddingGemma 2: полная размерность 768 (проверено curl-тестом). Q8_0 — оптимальный
> баланс производительность/память (MRR 0.944, не теряет производительность). Не путать с v1 `embeddinggemma:300m`.
## Модель данных (PostgreSQL — канон)
```
document — источники (web|pdf|epub|text|code_repo), uri, authority, content_hash
chunk — чистые сегменты (доказательства, locator) — НЕ для извлечения напрямую
atom — типизированные единицы знания (заявление + контент + доверие + статус)
atom_link — связи (inference|cause|reference|contradicts|depends_on|calls|defines)
citation — доказательная база (atom → document → locator → quoted_text)
code_symbol — tree-sitter символы (repo, path, language, name, kind, signature)
episodic_trace— память агента (шаги/действия) — отдельно от базы знаний
research_gap — выявленные пробелы для исследования
outbox — CDC-журнал для синхронизации в Qdrant + Memgraph
```
Статусы атома: `candidate → verified | stale | contested`.
Векторы НЕ хранятся в Postgres — только в Qdrant (payload: type/confidence/status/doc_id).
## Фазы
### Фаза 1 — Фундамент (MVP) ✅ ЗАВЕРШЕНА
- [x] Структура проекта + venv + зависимости
- [x] DDL Postgres (schema.sql)
- [x] Клиент эмбеддингов EmbeddingGemma 2 (dim=768)
- [x] Клиент Qdrant (коллекция `alicorn_atoms`)
- [x] Клиент Memgraph (узлы atom + рёбра atom_link)
- [x] Ингест: text/pdf/epub/web → чанки
- [x] Дистилляция чанка → атомы (LLM)
- [x] Синхронизация outbox PG → Qdrant + Memgraph
- [x] Smoke-тест end-to-end
### Фаза 2 — Исследование пробелов ✅ (реализовано, snippet-fallback)
- [x] Детект пробелов (research_gap + add_gap/list_gaps)
- [x] Планирование (приоритетная очередь пробелов)
- [x] Acquire: web-поиск (3 источника) + pdf/epub/text
- [x] Оценка ценности (verify_atom + min_confidence)
- [~] Верификация (verify_atom готов; полный автономный запуск требует прокси для зарубежных сайтов)
### Фаза 3 — Код ✅ / Логика ✅
- [x] tree-sitter: извлечение символов (function/class/method/type) — 43 символа сохранено
- [x] Граф зависимостей кода (calls/imports/inherits) — 310 зависимостей, 162 узла CodeSymbol
- [x] Логические правила + инференция в Memgraph (forward-chaining до fixpoint, 38 рёбер выведено)
### Фаза 4 — Интеграция с DSH (MCP) ✅ ВЫПОЛНЕНА
- [x] MCP-сервер (memory_search/store/ingest/add_gap/list_gaps/research/stats/get_context)
- [x] Подключение к DSH через @deepseek-ai/dsh-mcp-client (cordis.patch.yml, live-reload)
- [ ] Подключение к Rust-ядру Alicorn (Planner/Researcher) — когда ядро будет готово
## Дополнительно реализовано
- Опциональный прокси (PROXY_URL, SOCKS5/HTTP) + улучшенный User-Agent для полной загрузки страниц.
- Фильтрация артефактов графа кода (dst="." от относительных импортов).
- Дедупликация в memory_store (опциональный dedup).
(после MVP)
- Семантическая дедупликация атомов (эмбеддинги EmbeddingGemma 2, порог 0.90, конфигуруемо).
- Фильтрация шумных logical_rule (только kind-рёбры) в инференции.
## Критические риски
- EmbeddingGemma 2 dim=768 — не совместима с bge-m3 (1024) → отдельная коллекция, без миграции.
- Memgraph in-memory: объём графа ограничен RAM.
- Дистилляция зависит от качества локальной LLM — нужна верификация.
## Самообучение (self-improvement) — статус и планы
### Что работает (4 механизма, но НЕ замкнутый loop)
1. **Верификация** (`verify_atom`/`memory_verify` + `verify.py`). LLM self-critique
(`duplicate | corroborates | contradicts`) + **меж-системная подтверждение** (≥1 другой
`doc_id`, cosine ≥ 0.90) → ставка растёт, статус `verified`. Самый зрелый этап.
2. **Логическая инференция** (`memory_infer` + `inference.py`). Применение правил к рёбрам
графа до fixpoint (`imports → depends_on` и т.д.). ⚠️ Даст **только связи**, не новые факты.
Правила **статичные/ручное написание**, новых не выводит сама.
3. **Синтез контекста** (`memory_get_context`). Ищет Qdrant, кластеризует + суммирует атомы.
Может **создавать gap** из исходного текста → есть *реактивное* появление вопроса из источника.
4. **Эпизодические следы** (`pm_episodic_trace`). План/действие/наблюдение/рефлексия —
отдельно от БЗ. Самый «рефлексивный», но **условный**, не привязанный к gap.
### Что не хватает (дыры в петлях, а не в примитивах)
| Пробел | Почему критично |
|---|---|
| **① цепочка gap↛answer («дыра commit»)** | При закрытии вакуума **ответ не автоматически
пишется обратно в атомы** → знания улетучиваются, приходится пере-исследовать. |
| **② Самообучение схемы инференции** | Ничего не *предлагает новые правила* из повторяющихся
`verified`-атомов. Это как раз слабое место Hermes-аналога. |
| **③ Застарелость/забывание (staleness guard)** | `verify` ставит `verified`, но **ничто не
помечает `stale`** пере-проверкой по свежему источнику. Путь →`stale` не идёт автоматически. |
| **④ Поверхность противоречий** | `verify` ловит конфликт только в окрестности атома. Ничего
не сканирует весь граф → противоречия не переходят в gap автоматически. |
| **⑤ Процедурная/рецепт-память** | Повторяющиеся успешные воркфлоу не ловятся как переиспользуемые атомы. |
| **⑥ Пассивное создание gap** | Gap создаются через `add_gap` (ручно) или из `memory_get_context`.
Нет авто-обнаружения из не-верифицированного/переносного контента. |
### Приоритет
Hermes-анализ показал: Pegas сильна в **«detect»** (verify, infer), слаба в **«commit»** и
**«maintain»**. Слабые места — **feedback-петли**, а не примитивы. Поэтому максимальный выигрыш:
① дыра «commit» × ③ гвард сталкнутости × ② самообучение правил инференции — это **закрывает loop**.
### Планируемые улучшения (Tier)
- **Tier 1 — высокий приоритет, безопасно / малый**
- ① **Окончите дырой** — после `verify`, подняв атомы из gap, запустить синтез обратно в
`memory_ingest/synthesis` (надпись верифицированного ответа в атомы).
- ③ **Гвард сталкнутости** — `update_status`: пере-`web_search` по URL/date атома;
противоречит/устарел → `stale`. Реализуйте путь →`stale`, который сейчас не ходит.
- **Tier 2 — средний приоритет**
- ② **Самообучение правил инференции** — `memory_infer` **предлагает кандидаты**
`logical_rule` на ревью (никогда не применяет автоматически).
- ⑥ **Процедурная/рецепт-память** (паттерн из mem0, `procedural memory`) — агент действует
(это я через `mcp__pegas-memory__*`), поэтому повторы успешных `/запрос` или воркфлоу ≥N
раз с `verified`-результатом → **атомный** паттерн-рецепт (не файлы Hermes, а атомно с
dedup). Формат receipt: **goal → шаги → условия → результат** (переиспользуемый воркфлоу).
- **Tier 3 — структурные**
- ④ **Сканирование на противоречия** — периодический бэк-проход графа на противоречия → авто gap.
- ⑥ **Авто-обнаружение gap** — `verify` открывает gap на конфликт/дыру в источнике.
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""en2ru — English-source-of-truth -> Russian comprehension doc.
RULE (see DOCSTRAT.md): English is the CANONICAL source of truth. Russian is
only a comprehension aid; terminology may drift and MUST NEVER be used as the
editing basis. Always edit the `*.md` (EN), then re-run this to refresh
`<file>.ru.md`.
Usage:
python3 en2ru.py path/to/doc.md # writes path/to/doc.ru.md
cat doc.md | python3 en2ru.py - # stdin -> stdout + doc.ru.md
Model: translategemma:latest (Ollama, local @ 127.0.0.1:11434).
Jargon drift hints are printed to STDERR (never inside the RU output).
No external deps (urllib only) so it works offline after the model is pulled.
"""
import sys
import json
import urllib.request
OLLAMA_URL = "http://127.0.0.1:11434/v1/chat/completions"
MODEL = "translategemma:latest"
# Terms TranslateGemma-4b mishandles -> flag for human review (never auto-fix).
# Maps the EN token to the safe RU handling. JAPN is intentional: keep short.
JARGON_HINTS = [
("dedup", "дедупляция / удаление дублей"), # NOT «дублирование» (reversed!)
("receipt", "рецепт (паттерн действий)"), # NOT «результат»
("atom-level", "на уровне атомов"),
("stale", "устаревший (status=stale)"),
("gap", "пробел (gap)"),
("verify", "верификация / verify"),
("rerank", "реранг / reranking"),
("cache", "кэш"),
("inferred", "выведено / inferred"),
("web_search", "web_search (инструмент)"),
("ingest", "ингестия / загрузка / ingest"),
]
def translate(text: str) -> str:
prompt = (
"Translate the following Markdown technical text to Russian.\n"
"Rules:\n"
"- Output ONLY the Russian text. Preserve Markdown structure, code blocks,\n"
" backticks, code identifiers, URLs, command and tool names verbatim.\n"
"- Do NOT translate: code, identifiers, tool names, file extensions, URLs,\n"
" and product names (Memgraph, PostgreSQL, Qdrant, Memgraph).\n"
"- If unsure about a term, keep it as-is in Latin (do not guess).\n"
"- Words you must handle carefully: "
+ ", ".join(en for en, _ in JARGON_HINTS) + ".\n\n"
"TEXT:\n```\n" + text + "\n```"
)
payload = {
"model": MODEL,
"stream": False,
"messages": [{"role": "user", "content": prompt}],
}
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=300) as resp:
return json.loads(resp.read())["choices"][0]["message"]["content"]
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] == "-":
src = sys.stdin.read()
base = "doc"
else:
with open(sys.argv[1], encoding="utf-8") as fh:
src = fh.read()
base = sys.argv[1].rsplit(".", 1)[0]
ru = translate(src)
# model echoes the prompt's ``` fences -> drop leading/trailing fence lines
lines = ru.splitlines()
if lines and lines[0].strip() == "```":
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
ru = "\n".join(lines).strip()
out_path = base + ".ru.md"
with open(out_path, "w", encoding="utf-8") as fh:
fh.write(ru)
low = src.lower()
print("# en2ru -> " + out_path, file=sys.stderr)
for en, hint in JARGON_HINTS:
if en.lower() in low:
print(" review: '%s' -> ensure '%s'" % (en, hint), file=sys.stderr)
print("OK: " + out_path)
if __name__ == "__main__":
main()
+80
View File
@@ -289,6 +289,55 @@ def _tool_verify(args):
return json.dumps(result, ensure_ascii=False, indent=2), False return json.dumps(result, ensure_ascii=False, indent=2), False
def _tool_commit(args):
"""Tier 1 (commit): закрыть дыру — answer закрытого gap пишется в атомы."""
from pegas_memory import self_improve
gap_ids = args.get("gap_ids")
try:
gap_ids = [int(x) for x in (gap_ids or [])]
except (TypeError, ValueError):
return json.dumps({"error": "gap_ids должен быть списком целых id"}, ensure_ascii=False), True
if not gap_ids:
return json.dumps({"error": "нужен gap_ids (список id закрытых пробелов)"}, ensure_ascii=False), True
dedup = bool(args.get("dedup", True))
try:
result = self_improve.commit_gaps(_conn(), gap_ids=gap_ids, dedup=dedup)
except ValueError as e:
return json.dumps({"error": str(e)}, ensure_ascii=False), True
return json.dumps(result, ensure_ascii=False, indent=2), False
def _tool_stale(args):
"""Tier 1 (maintain): гвард сталкнутости — атомы по свежему источнику → stale."""
from pegas_memory import self_improve
atom_id = args.get("atom_id")
all_ = bool(args.get("all", False))
if atom_id is not None:
try:
result = self_improve.stale_guard(
_conn(), atom_id=int(atom_id),
age_only=bool(args.get("age_only", False)),
max_age_days=int(args.get("max_age_days") or 90),
)
except ValueError as e:
return json.dumps({"error": str(e)}, ensure_ascii=False), True
elif all_:
try:
result = self_improve.stale_guard(
_conn(), all=True,
limit=min(int(args.get("limit") or 20), 200),
sample=bool(args.get("sample", True)),
min_confidence=float(args.get("min_confidence") or 0.0),
age_only=bool(args.get("age_only", False)),
max_age_days=int(args.get("max_age_days") or 90),
)
except ValueError as e:
return json.dumps({"error": str(e)}, ensure_ascii=False), True
else:
return json.dumps({"error": "укажи atom_id или all=true"}, ensure_ascii=False), True
return json.dumps(result, ensure_ascii=False, indent=2), False
TOOLS = [ TOOLS = [
{ {
"name": "memory_search", "name": "memory_search",
@@ -492,6 +541,35 @@ TOOLS = [
{
"name": "memory_commit",
"description": "Tier 1 (commit): закрыть «дыру commit» — верифицированный ответ закрытого пробела пишется обратно в атомы (не даёт знанию слетывать). Берет id пробелов, ищет верифицированные атомы-ответы, дистиллирует канонический атом с обязательным dedup и закрывает пробел. Atom-efficiency: dedup по умолчанию.",
"inputSchema": {
"type": "object",
"properties": {
"gap_ids": {"type": "array", "items": {"type": "integer"}, "description": "Список id закрытых пробелов, answer которых надо записать в атомы."},
"dedup": {"type": "boolean", "description": "Семантическая дедупляция при записи (default true)."},
},
"required": ["gap_ids"],
},
},
{
"name": "memory_stale",
"description": "Tier 1 (maintain): гвард сталкнутости. Пере-проверяет атом(ы) по свежу источнику: противоречит/устарел → status='stale'. Лимит LLM-вызовов через limit/sample. atom_id — один атом; all — пачка (с limit/sample).",
"inputSchema": {
"type": "object",
"properties": {
"atom_id": {"type": "integer", "description": "ID атома для проверки на актуальность."},
"all": {"type": "boolean", "description": "Прогнать пачку атомов (с limit/sample)."},
"limit": {"type": "integer", "description": "Лимит атомов в пачке (1-200)."},
"sample": {"type": "boolean", "description": "Сэмпл вместо проверки всех (по умолчанию true при all)."},
"min_confidence": {"type": "number", "description": "Мин. уверенность атомов в пачке (default 0.0)."},
"age_only": {"type": "boolean", "description": "Без рефечя: только age-staleness по fetched_at (proxy-free)."},
"max_age_days": {"type": "integer", "description": "Возраст контента в днях, старше которого → stale (default 90)."},
},
"required": [],
},
},
{ {
"name": "memory_get_context", "name": "memory_get_context",
"description": "Собрать релевантный контекст из твоей базы для ответа (поиск по атомам + форматирование). " "description": "Собрать релевантный контекст из твоей базы для ответа (поиск по атомам + форматирование). "
@@ -551,6 +629,8 @@ def handle(msg):
"memory_infer": _tool_infer, "memory_infer": _tool_infer,
"memory_query_graph": _tool_query_graph, "memory_query_graph": _tool_query_graph,
"memory_verify": _tool_verify, "memory_verify": _tool_verify,
"memory_commit": _tool_commit,
"memory_stale": _tool_stale,
"memory_get_context": _tool_get_context, "memory_get_context": _tool_get_context,
} }
fn = handlers.get(tool) fn = handlers.get(tool)
+2 -1
View File
@@ -19,5 +19,6 @@ ATOM_STATUSES = ("candidate", "verified", "stale", "contested")
DEDUP_THRESHOLD = float(os.getenv("DEDUP_THRESHOLD", "0.90")) # порог семантической дедупликации DEDUP_THRESHOLD = float(os.getenv("DEDUP_THRESHOLD", "0.90")) # порог семантической дедупликации
PROXY_URL = os.getenv("PROXY_URL", "") # опционально: socks5://127.0.0.1:1080 или http://... PROXY_URL = os.getenv("PROXY_URL", "") # явный прокси: используется всегда (socks5://… или http://…)
SOCKS_FALLBACK_PROXY = os.getenv("SOCKS_FALLBACK_PROXY", "socks5h://127.0.0.1:1080") # авто-повор через прокси при ошибке прямого запроса
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") 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")
+2 -2
View File
@@ -30,8 +30,8 @@ def upsert_document(conn, *, kind: str, uri: str | None, title: str | None,
if row: if row:
return row["id"] return row["id"]
cur.execute( cur.execute(
"""INSERT INTO pm_document (kind, uri, title, content_hash, authority, meta) """INSERT INTO pm_document (kind, uri, title, content_hash, authority, meta, fetched_at)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, now())
ON CONFLICT (uri) DO UPDATE SET title = EXCLUDED.title ON CONFLICT (uri) DO UPDATE SET title = EXCLUDED.title
RETURNING id""", RETURNING id""",
(kind, uri, title, content_hash, authority, json.dumps(meta or {}, ensure_ascii=False)), (kind, uri, title, content_hash, authority, json.dumps(meta or {}, ensure_ascii=False)),
+34 -11
View File
@@ -59,26 +59,49 @@ def parse_epub(path: str) -> ParsedDoc:
doc.kind = "epub" doc.kind = "epub"
return doc return doc
def _http_client(): def _http_client(proxy=None, timeout: int = 30):
"""httpx.Client с опциональным прокси и единым User-Agent.""" """httpx.Client с единым User-Agent.
proxy: явный прокси (None = прямой запрос). Если проси не передан, но
config.PROXY_URL задан — используется он.
"""
import httpx import httpx
from . import config from . import config
kwargs = dict(timeout=30, follow_redirects=True, kwargs = dict(timeout=timeout, follow_redirects=True,
headers={"User-Agent": config.USER_AGENT}) headers={"User-Agent": config.USER_AGENT})
if config.PROXY_URL: use_proxy = proxy or (config.PROXY_URL or None)
kwargs["proxy"] = config.PROXY_URL if use_proxy:
kwargs["proxy"] = use_proxy
return httpx.Client(**kwargs) return httpx.Client(**kwargs)
def parse_web(url: str) -> ParsedDoc: def _fetch_text(url: str, proxy: str | None = None) -> str | None:
"""Fetch + extract main content. Tries direct; on error/empty retries `proxy`.
trafilatura.extract has no timeout -> fetch via httpx with timeout.
"""
import trafilatura import trafilatura
# fetch через httpx с таймаутом + прокси (trafilatura.fetch_url не имеет таймаута и виснет) for _lbl, _prx in (("direct", None), ("proxy", proxy)):
with _http_client() as client: try:
with _http_client(proxy=_prx, timeout=30) as client:
r = client.get(url) r = client.get(url)
r.raise_for_status() r.raise_for_status()
text = trafilatura.extract(r.text, include_comments=False, include_tables=True, text = trafilatura.extract(r.text, include_comments=False,
url=url) include_tables=True, url=url)
if text:
return text
except Exception:
continue
return None
def parse_web(url: str) -> ParsedDoc:
from . import config
# direct fetch + socks5h fallback on block/timeout
text = _fetch_text(url, proxy=config.SOCKS_FALLBACK_PROXY)
if not text: if not text:
raise ValueError(f"trafilatura: no main content for {url}") raise ValueError(
f"no main content for {url} "
f"(direct + {config.SOCKS_FALLBACK_PROXY} fallback failed)"
)
doc = parse_text(text, uri=url, title=url) doc = parse_text(text, uri=url, title=url)
doc.kind = "web" doc.kind = "web"
return doc return doc
+229
View File
@@ -0,0 +1,229 @@
"""Самообучение (Tier 1): закрытие «дыры commit» + гвард сталкнутости.
Два механизма, закрывающих петлю commit/maintain (см. ROADMAP.md):
1. commit_gaps — после закрытия gap пишет верифицированный answer обратно
в атомы, чтобы знание не слетивало и не пере-исследовалось.
Atom-efficiency: обязательный dedup.
2. stale_guard — пере-проверяет атомы по свежу источнику; противоречит/
устарел → status='stale'. Два сигнала:
- content-staleness: рефетч источника + LLM-вердикт (нужен proxy
для зарубежных сайтов);
- age-staleness: fallback по `fetched_at` (proxy-free).
Лимит LLM-вызовов (limit/sample).
Лимиты: LLM-вызовы дорогие — каждый вызов инструмента лимитирован.
"""
from __future__ import annotations
import json
import random
from datetime import datetime
from . import config, db, research, qdrant_store
from .ingest import parse_web
def _fresh_source(uri: str) -> str | None:
"""Свежный контент источника (прокси/UA/таймаут через ingest.parse_web)."""
try:
doc = parse_web(uri)
except Exception:
return None
return "\n\n".join(c.content for c in doc.chunks)
def _stale_verdict(atom: dict, fresh_text: str, model: str | None) -> dict:
"""LLM: поддерживает ли свежный источник утверждение атома?
Вернит {"verdict": "supported|contradicts|outdated", "confidence", "reason"}.
"""
import httpx
m = model or config.LLM_MODEL
ctx = (atom.get("claim", "") or "") + "\n" + (atom.get("content", "") or "")[:600]
prompt = (
f"Свежный источник:\n{fresh_text[:4000]}\n\n"
f"Утверждение атома ({atom.get('type')}):\n{ctx[:500]}\n\n"
f"Оцени, свежный источник всё ещё ПОДДЕРЖИВАЕТ утверждение. "
f"Верни ТОЛЬКО JSON:\n"
f'{{"verdict": "supported|contradicts|outdated", "confidence": 0.0-1.0, '
f'"reason": "кратко"}}\n'
f"- supported: источник подтверждает/сохраняет утверждение\n"
f"- contradicts: источник теперь противоречит\n"
f"- outdated: информация устарела в источнике"
)
try:
with httpx.Client(timeout=120) as client:
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": "supported", "confidence": 0.5, "reason": "parse error"}
v = json.loads(out[start:end + 1])
except Exception:
return {"verdict": "supported", "confidence": 0.5, "reason": "llm error"}
if v.get("verdict") not in ("supported", "contradicts", "outdated"):
v["verdict"] = "supported"
return v
def commit_gaps(conn, *, gap_ids=None, dedup=True, model: str | None = None) -> dict:
"""Закрывает дыра «commit»: answer закрытого gap пишется в атомы.
Для каждого gap из gap_ids: ищет verified-атомы, отвечающие на вопрос,
дистиллирует канонический атом-ответ, сохраняет с dedup, resolves gap.
Возвращает статистику. dedup — atom-efficiency guardrail (по умолчанию True).
"""
from . import distill, dedup as _dedup
out = {"committed": 0, "skipped_duplicate": 0, "resolved": 0,
"skipped_empty": 0, "atoms": []}
if not gap_ids:
raise ValueError("нужен gap_ids (список id)")
for gid in gap_ids:
gid = int(gid)
gap = research.get_gap(conn, gid)
if not gap:
continue
q = gap["question"]
# релевантность — из Qdrant (рейтинг), статус — из Postgres (истина):
# verify меняет статус в PG, но не пушит projection, поэтому читаем status оттуда.
hits = qdrant_store.search(q, top_k=8)
if not hits:
out["skipped_empty"] += 1
continue
hit_ids = [int(h["id"]) for h in hits]
with conn.cursor() as cur:
cur.execute(
"SELECT id, claim, content, status FROM pm_atom WHERE id = ANY(%s)",
(hit_ids,),
)
rows = {r["id"]: r for r in cur.fetchall()}
verified = [rows[hid] for hid in hit_ids if rows[hid]["status"] == "verified"]
if not verified:
# нет верифицированного ответа — не резать gap, агент должен его получить
out["skipped_empty"] += 1
continue
text = f"ВОПРОС: {q}\n\nОТВЕТ (верифицированные знания):\n" + "\n".join(
f"- {v.get('claim', '')}\n {str(v.get('content', ''))[:300]}" for v in verified
)
atoms = distill.distill_chunk(text, model=model)
for a in atoms:
if a.get("confidence", 0.5) < 0.4:
continue
if dedup:
dup_id = _dedup.find_duplicate(
(a.get("claim") or "") + " " + str(a["content"])[:800],
threshold=config.DEDUP_THRESHOLD,
)
if dup_id is not None:
out["skipped_duplicate"] += 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), status="verified", doc_id=None,
metadata={"committed_from_gap": gid, "source_question": q},
)
if created:
out["committed"] += 1
out["atoms"].append(atom_id)
db.enqueue_outbox(conn, entity_type="atom", entity_id=atom_id)
research.update_gap(conn, gid, status="resolved")
out["resolved"] += 1
conn.commit()
return out
def stale_guard(conn, *, atom_id=None, all: bool = False, limit: int = 20,
sample: bool = False, min_confidence: float = 0.0,
age_only: bool = False, max_age_days: int = 90,
model: str | None = None) -> dict:
"""Гвард сталкнутости: пере-проверка атомов по свежему источнику.
atom_id — один атом (1 fetch + 1 LLM-вызов).
all — пачка атомов; limit + sample лимитируют число LLM-вызовов.
Противоречит/устарел → status='stale'. Возвращает сводку.
"""
ids = []
if atom_id is not None:
with conn.cursor() as cur:
cur.execute(
"SELECT id, type, claim, content, confidence, status, doc_id "
"FROM pm_atom WHERE id = %s",
(int(atom_id),),
)
row = cur.fetchone()
if row:
ids = [row]
elif all:
with conn.cursor() as cur:
cur.execute(
"SELECT id, type, claim, content, confidence, status, doc_id "
"FROM pm_atom WHERE confidence >= %s",
(min_confidence,),
)
rows = cur.fetchall()
if sample and rows:
n = min(int(limit), len(rows))
ids = random.sample(rows, n)
else:
ids = rows[:int(limit)]
else:
raise ValueError("укажи atom_id или all=true")
summary = {"checked": 0, "stale": 0, "supported": 0,
"skipped_no_source": 0, "atom_ids": [], "reasons": {}}
for atom in ids:
with conn.cursor() as cur:
cur.execute(
"SELECT d.uri, d.fetched_at FROM pm_document d WHERE id = %s",
(atom["doc_id"],),
)
doc = cur.fetchone()
if not doc or not doc.get("uri"):
summary["skipped_no_source"] += 1
continue
status_change = None
reason = None
if not age_only:
fresh = _fresh_source(doc["uri"])
if fresh:
v = _stale_verdict(atom, fresh, model)
summary["checked"] += 1
if v["verdict"] in ("contradicts", "outdated"):
status_change = "stale"
reason = f"source_{v['verdict']}: {v.get('reason', '')}"
else:
# content-staleness re-fetch succeeded -> verdict is authoritative
status_change = "supported"
reason = "supported"
if status_change is None:
# proxy-free fallback: возраст контента
fa = doc.get("fetched_at")
if fa is None:
summary["skipped_no_source"] += 1
continue
tz = fa.tzinfo or datetime.timezone.utc
age_days = max(0, (datetime.now(tz=tz) - fa).days)
if age_days >= max_age_days:
status_change = "stale"
reason = f"content_age {age_days}d"
else:
reason = f"fresh {age_days}d"
if status_change == "stale":
with conn.cursor() as cur:
cur.execute(
"UPDATE pm_atom SET status = 'stale', updated_at = now() WHERE id = %s",
(atom["id"],),
)
summary["stale"] += 1
summary["atom_ids"].append(atom["id"])
else:
summary["supported"] += 1
summary["reasons"][atom["id"]] = reason
conn.commit()
return summary