fix(worker): port objective_etl from psycopg2 → psycopg v3
Worker крашился на старте с `ModuleNotFoundError: No module named 'psycopg2'` при импорте app/workers/tasks/objective_etl, потому что app/services/objective_etl писал на psycopg2 API, а в pyproject.toml есть только psycopg[binary]>=3.2.0. Изменения: - psycopg2 → psycopg (v3) - psycopg2.extras.execute_values → _bulk_upsert helper, эмулирующий тот же behavior через cur.executemany (в psycopg v3 это pipeline-mode, overhead на сетевые round-trips минимален) - psycopg.connect совместим с тем же connection string Тесты: AST parse ✓, ruff ✓, import ✓
This commit is contained in:
parent
b14700e583
commit
769b16df47
5 changed files with 1089 additions and 11 deletions
|
|
@ -28,11 +28,27 @@ from datetime import date
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
import psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bulk_upsert(cur: psycopg.Cursor, sql_template: str, rows: list[tuple]) -> None:
|
||||
"""Эмулирует psycopg2.extras.execute_values для psycopg v3.
|
||||
|
||||
`sql_template` должен содержать литеральное `VALUES %s` — заменим
|
||||
на `VALUES (%s, %s, ..., %s)` с числом placeholder'ов = len(rows[0])
|
||||
и пошлём через executemany. В psycopg3 executemany использует
|
||||
pipeline-mode, так что overhead на сетевые round-trips минимален.
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
n_cols = len(rows[0])
|
||||
values_placeholder = "(" + ",".join(["%s"] * n_cols) + ")"
|
||||
sql = sql_template.replace("VALUES %s", "VALUES " + values_placeholder)
|
||||
cur.executemany(sql, rows)
|
||||
|
||||
|
||||
GROUP_NAME = "Екатеринбург" # У Антона в SQLite нет колонки group_name
|
||||
|
||||
|
||||
|
|
@ -356,14 +372,14 @@ def _etl_lots(
|
|||
for r in rows:
|
||||
batch.append(_row_to_lot(r, snap))
|
||||
if len(batch) >= batch_size:
|
||||
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=batch_size)
|
||||
_bulk_upsert(pg_cur, _LOT_INSERT, batch)
|
||||
pg.commit()
|
||||
n += len(batch)
|
||||
batch.clear()
|
||||
if progress_cb and n % (batch_size * 5) == 0:
|
||||
progress_cb(n)
|
||||
if batch:
|
||||
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=len(batch))
|
||||
_bulk_upsert(pg_cur, _LOT_INSERT, batch)
|
||||
pg.commit()
|
||||
n += len(batch)
|
||||
pg_cur.close()
|
||||
|
|
@ -391,12 +407,12 @@ def _etl_crm(
|
|||
continue
|
||||
batch.append(t)
|
||||
if len(batch) >= batch_size:
|
||||
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=batch_size)
|
||||
_bulk_upsert(pg_cur, _CRM_INSERT, batch)
|
||||
pg.commit()
|
||||
n += len(batch)
|
||||
batch.clear()
|
||||
if batch:
|
||||
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=len(batch))
|
||||
_bulk_upsert(pg_cur, _CRM_INSERT, batch)
|
||||
pg.commit()
|
||||
n += len(batch)
|
||||
pg_cur.close()
|
||||
|
|
@ -424,7 +440,7 @@ def _etl_mapping(sl: sqlite3.Connection, pg) -> tuple[int, int]:
|
|||
if not batch:
|
||||
return 0, skipped_dups
|
||||
pg_cur = pg.cursor()
|
||||
psycopg2.extras.execute_values(pg_cur, _MAP_INSERT, batch, page_size=500)
|
||||
_bulk_upsert(pg_cur, _MAP_INSERT, batch)
|
||||
pg.commit()
|
||||
pg_cur.close()
|
||||
return len(batch), skipped_dups
|
||||
|
|
@ -483,7 +499,7 @@ def run_etl(
|
|||
|
||||
Raises:
|
||||
FileNotFoundError если SQLite не найден
|
||||
psycopg2.Error при ошибках БД
|
||||
psycopg.Error при ошибках БД
|
||||
"""
|
||||
p = Path(sqlite_path)
|
||||
if not p.exists():
|
||||
|
|
@ -492,7 +508,7 @@ def run_etl(
|
|||
logger.info("ETL Anton SQLite → PG: %s", p)
|
||||
sl = sqlite3.connect(p)
|
||||
sl.row_factory = sqlite3.Row
|
||||
pg = psycopg2.connect(db_url)
|
||||
pg = psycopg.connect(db_url)
|
||||
try:
|
||||
if progress_cb:
|
||||
progress_cb("mapping_start", 0)
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ def import_anton_objective(
|
|||
run_id = _start_run(db, triggered_by)
|
||||
logger.info("import_anton_objective started, run_id=%s, sqlite=%s", run_id, sqlite_path_eff)
|
||||
|
||||
# Преобразуем sqlalchemy-style db_url в psycopg2-style
|
||||
# (psycopg2 не принимает 'postgresql+psycopg://').
|
||||
# Преобразуем sqlalchemy-style db_url в обычный postgresql://
|
||||
# (psycopg.connect принимает postgresql://, не postgresql+psycopg://).
|
||||
db_url = settings.database_url.replace("postgresql+psycopg://", "postgresql://")
|
||||
|
||||
# Прогресс-callback пишет в БД счётчики + heartbeat
|
||||
|
|
|
|||
113
scripts/cleanup_ghosts.py
Normal file
113
scripts/cleanup_ghosts.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Удаляет ghost docs из decisions/, sessions/, sources/ в CouchDB —
|
||||
те что есть в CouchDB но отсутствуют в локальном vault.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
VAULT = Path(r"C:\Users\user\Documents\gendesign-memory")
|
||||
BASE = "https://obsidian.gendsgn.ru/gendesign-vault"
|
||||
AUTH = ("obsidian", os.environ["COUCHDB_PASSWORD"])
|
||||
TARGETS = ("decisions/", "sessions/", "sources/")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# 1. Local files (case-insensitive set, forward-slash paths)
|
||||
local: set[str] = set()
|
||||
for top in ("decisions", "sessions", "sources"):
|
||||
d = VAULT / top
|
||||
if d.exists():
|
||||
for f in d.rglob("*.md"):
|
||||
rel = str(f.relative_to(VAULT)).replace(os.sep, "/").lower()
|
||||
local.add(rel)
|
||||
print(f"Local files in target dirs: {len(local)}")
|
||||
|
||||
# 2. Fetch CouchDB docs
|
||||
r = requests.get(f"{BASE}/_all_docs", auth=AUTH,
|
||||
params={"limit": 10000}, timeout=60)
|
||||
rows = r.json()["rows"]
|
||||
|
||||
# 3. Identify ghosts
|
||||
ghosts: list[tuple[str, str]] = []
|
||||
keepers: list[str] = []
|
||||
for row in rows:
|
||||
did = row["id"]
|
||||
if not did.endswith(".md"):
|
||||
continue
|
||||
if not did.startswith(TARGETS):
|
||||
continue
|
||||
if did.lower() in local:
|
||||
keepers.append(did)
|
||||
else:
|
||||
ghosts.append((did, row["value"]["rev"]))
|
||||
|
||||
print(f"Keepers (matched local): {len(keepers)}")
|
||||
print(f"Ghosts (no local match): {len(ghosts)}")
|
||||
for did, _ in ghosts[:30]:
|
||||
print(f" ghost: {did}")
|
||||
if len(ghosts) > 30:
|
||||
print(f" ... +{len(ghosts) - 30} more")
|
||||
|
||||
if not ghosts:
|
||||
print("Nothing to do.")
|
||||
return
|
||||
|
||||
# 4. Сначала найдём chunks которые ссылаются ghosts. Это потенциальные orphans.
|
||||
ghost_chunk_refs: set[str] = set()
|
||||
for did, _ in ghosts:
|
||||
rd = requests.get(f"{BASE}/{quote(did, safe='')}", auth=AUTH, timeout=30)
|
||||
if rd.status_code == 200:
|
||||
d = rd.json()
|
||||
for cid in d.get("children", []):
|
||||
ghost_chunk_refs.add(cid)
|
||||
for cid in d.get("eden", {}):
|
||||
ghost_chunk_refs.add(cid)
|
||||
print(f"Chunks referenced by ghosts: {len(ghost_chunk_refs)}")
|
||||
|
||||
# 5. Проверяем у скольких из них ещё есть alive parent
|
||||
ghost_ids = {did for did, _ in ghosts}
|
||||
alive_md = [r["id"] for r in rows
|
||||
if r["id"].endswith(".md") and r["id"] not in ghost_ids]
|
||||
print(f"Scanning {len(alive_md)} alive .md docs for chunk refs...")
|
||||
alive_chunks: set[str] = set()
|
||||
for i, did in enumerate(alive_md):
|
||||
if i % 100 == 0 and i > 0:
|
||||
print(f" {i}/{len(alive_md)}")
|
||||
rd = requests.get(f"{BASE}/{quote(did, safe='')}", auth=AUTH, timeout=30)
|
||||
if rd.status_code == 200:
|
||||
d = rd.json()
|
||||
for cid in d.get("children", []):
|
||||
alive_chunks.add(cid)
|
||||
for cid in d.get("eden", {}):
|
||||
alive_chunks.add(cid)
|
||||
|
||||
orphans = ghost_chunk_refs - alive_chunks
|
||||
print(f"Orphan chunks (only ghosts reference them): {len(orphans)}")
|
||||
|
||||
# 6. Получаем _rev для orphan chunks
|
||||
chunk_revs: dict[str, str] = {}
|
||||
chunk_id_to_rev = {r["id"]: r["value"]["rev"] for r in rows if r["id"].startswith("h:")}
|
||||
for cid in orphans:
|
||||
if cid in chunk_id_to_rev:
|
||||
chunk_revs[cid] = chunk_id_to_rev[cid]
|
||||
|
||||
# 7. Bulk delete
|
||||
del_docs = [{"_id": did, "_rev": rev, "_deleted": True} for did, rev in ghosts]
|
||||
del_docs.extend({"_id": cid, "_rev": rev, "_deleted": True}
|
||||
for cid, rev in chunk_revs.items())
|
||||
|
||||
print(f"Bulk delete {len(del_docs)} docs ({len(ghosts)} ghost main + "
|
||||
f"{len(chunk_revs)} orphan chunks)...")
|
||||
rb = requests.post(f"{BASE}/_bulk_docs", json={"docs": del_docs},
|
||||
auth=AUTH, timeout=300)
|
||||
if rb.status_code in (200, 201):
|
||||
ok = sum(1 for x in rb.json() if x.get("ok"))
|
||||
print(f"Deleted: {ok}/{len(del_docs)}")
|
||||
else:
|
||||
print(f"Bulk delete failed: HTTP {rb.status_code} {rb.text[:200]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
940
scripts/migrate_kg_to_obsidian.py
Normal file
940
scripts/migrate_kg_to_obsidian.py
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
"""Миграция knowledge graph (memory-gendesign.jsonl) → Obsidian vault через CouchDB.
|
||||
|
||||
Читает текущий KG и записывает каждую entity как `.md` файл прямо в CouchDB
|
||||
в формате Self-hosted LiveSync (Vrtmrz/obsidian-livesync). Клиенты Obsidian
|
||||
с LiveSync plugin автоматически подтянут изменения.
|
||||
|
||||
Формат LiveSync v0.x (classic chunks + children):
|
||||
Chunk document:
|
||||
{_id: "h:<base32-hash>", data: "<содержимое>", type: "leaf"}
|
||||
|
||||
Main document:
|
||||
{_id: "entities/Foo.md", path: "entities/Foo.md",
|
||||
children: ["h:..."], ctime, mtime, size, type:"plain", eden:{}}
|
||||
|
||||
eden — write-side staging area, при чтении не используется. Поэтому контент
|
||||
ОБЯЗАН быть в отдельных chunk-docs (LiveSync читает через children).
|
||||
PUT-ordering: chunks first, main docs second — иначе клиент видит пустой
|
||||
body во время replication race.
|
||||
|
||||
Структура vault:
|
||||
sessions/ — Session_End_* entities
|
||||
entities/ — Module_*, Schema_*, прочие
|
||||
decisions/ — *_Plan_*, *_Strategy_* (ADR-style)
|
||||
runbooks/ — *_Scraper_*, *_Pipeline_* (how-to)
|
||||
sources/ — DataSources_* (источники данных)
|
||||
misc/ — всё остальное
|
||||
|
||||
Запуск:
|
||||
cd backend
|
||||
COUCHDB_URL=https://obsidian.gendsgn.ru \
|
||||
COUCHDB_USER=obsidian \
|
||||
COUCHDB_PASSWORD=<пароль> \
|
||||
COUCHDB_DB=gendesign-vault \
|
||||
uv run --no-project --with requests python -X utf8 \
|
||||
../scripts/migrate_kg_to_obsidian.py [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("kg_to_obsidian")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
KG_PATH = REPO_ROOT / "memory" / "memory-gendesign.jsonl"
|
||||
|
||||
|
||||
_B32 = "0123456789abcdefghijklmnopqrstuv"
|
||||
|
||||
|
||||
def chunk_id_for(content: str) -> str:
|
||||
"""Генерирует chunk_id похожий на LiveSync internal format:
|
||||
`h:` + 13 base32-like chars. Уникальность достаточна — collision space 2^65.
|
||||
|
||||
LiveSync встроенный generator использует свой base32-кодированный hash.
|
||||
Подражаем формату чтобы клиент не отбраковал по validation.
|
||||
"""
|
||||
digest = hashlib.sha256(content.encode("utf-8")).digest()
|
||||
n = int.from_bytes(digest[:8], "big")
|
||||
out = []
|
||||
for _ in range(13):
|
||||
out.append(_B32[n & 0x1F])
|
||||
n >>= 5
|
||||
return "h:" + "".join(out)
|
||||
|
||||
|
||||
_MONTH_MAP = {
|
||||
"jan": "01", "feb": "02", "mar": "03", "apr": "04", "may": "05", "jun": "06",
|
||||
"jul": "07", "aug": "08", "sep": "09", "oct": "10", "nov": "11", "dec": "12",
|
||||
}
|
||||
_DATE_PATTERNS = [
|
||||
# Apr27_2026, Apr30_2026, Apr29_2026_Late
|
||||
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})_(?P<y>20\d{2})\b", re.IGNORECASE),
|
||||
# Apr26 (year implied 2026)
|
||||
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<d>\d{1,2})\b(?!_20)", re.IGNORECASE),
|
||||
# Nov2025
|
||||
re.compile(r"\b(?P<m>Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?P<y>20\d{2})\b", re.IGNORECASE),
|
||||
# Q1_2026, Q2_2026 — quarter→month
|
||||
re.compile(r"\bQ(?P<q>[1-4])_(?P<y>20\d{2})\b"),
|
||||
]
|
||||
|
||||
|
||||
def extract_date(name: str) -> str | None:
|
||||
"""Из имени entity извлекает ISO-дату (YYYY-MM-DD или YYYY-MM).
|
||||
|
||||
Apr26 → 2026-04-26 (год дефолтный 2026 если не указан)
|
||||
Nov2025 → 2025-11
|
||||
Q1_2026 → 2026-Q1 (для квартала)
|
||||
"""
|
||||
for pat in _DATE_PATTERNS:
|
||||
m = pat.search(name)
|
||||
if not m:
|
||||
continue
|
||||
gd = m.groupdict()
|
||||
if "q" in gd:
|
||||
return f"{gd['y']}-Q{gd['q']}"
|
||||
mon = _MONTH_MAP[gd["m"][:3].lower()]
|
||||
if "d" in gd:
|
||||
y = gd.get("y") or "2026"
|
||||
return f"{y}-{mon}-{int(gd['d']):02d}"
|
||||
return f"{gd['y']}-{mon}"
|
||||
return None
|
||||
|
||||
|
||||
# Категоризация: входим в первое совпавшее правило. Порядок важен —
|
||||
# specific raньше generic.
|
||||
def categorize_v2(name: str, entity_type: str) -> str:
|
||||
"""Возвращает многоуровневый путь поддиректории (без имени файла, без расширения).
|
||||
|
||||
Стратегия — group по бизнес-домену, потом по технической роли.
|
||||
"""
|
||||
n = name
|
||||
et = (entity_type or "").lower()
|
||||
|
||||
# 1. Top-level meta — флагманы проекта
|
||||
if n in {"Project_GenDesign", "TechStack_v2.2", "NorthStar_DiscoveryMVP",
|
||||
"Phase0_DiscoveryMVP", "Stages_MVP_v1", "Team_Setup",
|
||||
"Onboarding_Colleague_KG", "WorkSplit_Generative_Stage1"}:
|
||||
return "meta"
|
||||
|
||||
# 2. Sprints
|
||||
if n.startswith("Sprint") and ("_" in n):
|
||||
return "sprints"
|
||||
|
||||
# 3. Sessions — chronological logs
|
||||
if n.startswith("Session") or et == "session":
|
||||
return "sessions"
|
||||
|
||||
# 4. Bug fixes
|
||||
if n.startswith("Bug_") or et == "bug":
|
||||
return "fixes"
|
||||
|
||||
# 5. GitHub
|
||||
if n.startswith("GitHub_"):
|
||||
return "github"
|
||||
|
||||
# 6. Domain: DOM.РФ
|
||||
if n.startswith("DomRF") or n == "DomRf_Kn_Scraper_Apr27":
|
||||
if "_API_" in n or n.startswith("DomRF_API"):
|
||||
return "domains/domrf/api"
|
||||
if any(w in n for w in ("_Loaded_", "_Imported_", "_Wipe_")):
|
||||
return "domains/domrf/events"
|
||||
if "_Schema_" in n or n.endswith("_Schema"):
|
||||
return "domains/domrf/schemas"
|
||||
if "_Scraper" in n:
|
||||
return "domains/domrf/scraper"
|
||||
if "Enums" in n or "Architecture" in n or "Host" in n or "Integration" in n:
|
||||
return "domains/domrf/api"
|
||||
return "domains/domrf"
|
||||
|
||||
# 7. Domain: Cadastre (NSPD + Cad_*)
|
||||
if n.startswith("NSPD") or n.startswith("Cad_") or n == "Cad_Quarter_Backfill_Apr30":
|
||||
return "domains/cadastre"
|
||||
|
||||
# 8. Domain: Rosreestr
|
||||
if (n.startswith("Rosreestr") or n.startswith("Sverdl_") or
|
||||
(n.startswith("DataSource_Rosreestr") or n == "Backfill_District_Median_From_Kn_Flats")):
|
||||
return "domains/rosreestr"
|
||||
|
||||
# 9. Domain: Analytics
|
||||
if any(w in n for w in ("Recommend", "Velocity", "Affordability", "DemandIndex",
|
||||
"AlternativeDeal", "Competitor_Benchmark", "Macro_Context",
|
||||
"Mortgage", "PriceGap", "Tier2", "Tier_Alpha", "Quartirography",
|
||||
"RegionGroups", "Regional_Migration", "RepeatedMortgage",
|
||||
"QualityShift")):
|
||||
if n.startswith("Component_") or n.startswith("Feature_"):
|
||||
return "domains/analytics/ui"
|
||||
if n.startswith("Endpoint_"):
|
||||
return "domains/analytics/api"
|
||||
if n.startswith("Hook_"):
|
||||
return "domains/analytics/code"
|
||||
if n.startswith("Bug_"):
|
||||
return "fixes"
|
||||
if "Trend" in n or "Index" in n or "Report" in n:
|
||||
return "domains/analytics/research"
|
||||
return "domains/analytics"
|
||||
|
||||
# 10. Domain: UI components/routes (общие, не analytics-specific)
|
||||
if n.startswith("Component_") or n.startswith("Feature_") or n.startswith("UI_"):
|
||||
return "domains/ui/components"
|
||||
if n.startswith("Route_"):
|
||||
return "domains/ui/routes"
|
||||
|
||||
# 11. Domain: API endpoints
|
||||
if n.startswith("Endpoint_"):
|
||||
return "domains/api/endpoints"
|
||||
|
||||
# 12. Domain: Geo
|
||||
if (n.startswith("Geo_") or n == "Pattern_OSM_Overpass_Admin_Level"
|
||||
or n == "Limit_District_Geometry_Closed" or n == "File_Ekb_Districts_Geojson_Cache"):
|
||||
return "domains/geo"
|
||||
|
||||
# 13. Domain: Infra (deploy, docker, hosting, etc)
|
||||
if any(w in n for w in ("Docker", "Deploy", "Sentry", "Hosting", "SSH",
|
||||
"OpsBundle", "Domain_Setup", "HTTPS", "Redis",
|
||||
"Caddy", "Celery", "TTL_Lock", "Stealth_Fingerprint",
|
||||
"Runbook_VPS", "Production_Wipe")):
|
||||
return "domains/infra"
|
||||
|
||||
# 14. Code: Modules / Schemas / Patterns / Hooks / Tasks
|
||||
if n.startswith("Module_") or et == "code_module":
|
||||
return "code/modules"
|
||||
if n.startswith("Schema_") or et == "schema":
|
||||
return "code/schemas"
|
||||
if n.startswith("Pattern_"):
|
||||
return "code/patterns"
|
||||
if n.startswith("Hook_"):
|
||||
return "code/hooks"
|
||||
if n.startswith("Task_"):
|
||||
return "code/tasks"
|
||||
|
||||
# 15. Sources (data source research, non-Rosreestr)
|
||||
if n.startswith("DataSource_") or n.startswith("DataSources_"):
|
||||
return "sources"
|
||||
|
||||
# 16. Limitations
|
||||
if n.startswith("Limit_") or n == "Open_Limitations_Apr30" or et == "limitation":
|
||||
return "limitations"
|
||||
|
||||
# 17. Decisions (plans, strategies, policies)
|
||||
if n.startswith("PlanA_") or n.startswith("Plan_") or "_Strategy_" in n:
|
||||
return "decisions"
|
||||
if et in ("policy", "plan", "decision", "decision_pending", "strategy"):
|
||||
return "decisions"
|
||||
|
||||
# 18. Events / milestones / objectives
|
||||
if (n.startswith("Objective_") or "_Loaded_" in n or "_Imported_" in n
|
||||
or n.startswith("Phase_") or et in ("event", "milestone")):
|
||||
return "events"
|
||||
|
||||
# 19. Feedback
|
||||
if n.startswith("Feedback_") or et == "feedback" or "MCP_AddFacts" in n:
|
||||
return "feedback"
|
||||
|
||||
# 20. Research (generic)
|
||||
if et == "research":
|
||||
return "research"
|
||||
|
||||
# 21. Tests
|
||||
if et == "test" or n.endswith("_Test_Apr25"):
|
||||
return "research"
|
||||
|
||||
# 22. Reviews
|
||||
if et == "review":
|
||||
return "research"
|
||||
|
||||
# 23. Build (SiteFinder etc)
|
||||
if et == "build" or n.startswith("SiteFinder"):
|
||||
return "domains/sitefinder"
|
||||
|
||||
# 24. Deliverable — most likely analytics или infra
|
||||
if et == "deliverable":
|
||||
return "events"
|
||||
|
||||
# 25. Infrastructure type
|
||||
if et in ("infrastructure", "infra"):
|
||||
return "domains/infra"
|
||||
|
||||
return "unsorted"
|
||||
|
||||
|
||||
_TAG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def tags_for_path(subdir: str, entity_type: str) -> list[str]:
|
||||
"""Генерит теги из subpath + entity_type. Пример:
|
||||
domains/domrf/api + api_spec → [domrf, api, domrf-api, api-spec]
|
||||
"""
|
||||
parts = [p for p in subdir.split("/") if p and p not in ("domains", "code")]
|
||||
tags = list(dict.fromkeys(parts)) # uniq, preserve order
|
||||
# composite tag — domain-subdomain
|
||||
if len(parts) >= 2:
|
||||
tags.append(f"{parts[0]}-{parts[1]}")
|
||||
if entity_type:
|
||||
et_norm = _TAG_RE.sub("-", entity_type.lower()).strip("-")
|
||||
if et_norm and et_norm not in tags:
|
||||
tags.append(et_norm)
|
||||
return tags
|
||||
|
||||
|
||||
# Старая функция для совместимости с CouchDB-режимом (он сейчас не используется,
|
||||
# но всё ещё есть в коде на случай отката).
|
||||
def categorize(name: str, entity_type: str) -> str:
|
||||
subdir = categorize_v2(name, entity_type)
|
||||
return subdir.split("/")[0]
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-zA-Zа-яА-Я0-9_\- ]+")
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
"""Превращает name в файл-безопасное имя (без / : * etc)."""
|
||||
s = _SLUG_RE.sub("", name).strip().replace(" ", "_")
|
||||
return s[:200] # Obsidian/Windows path limit
|
||||
|
||||
|
||||
def md_for_entity(entity: dict, outgoing: list[dict], incoming: list[dict],
|
||||
name_to_path: dict[str, str], subdir: str | None = None) -> str:
|
||||
"""Генерит .md содержимое entity с frontmatter (теги + дата), observations,
|
||||
relations через короткие [[wiki-links]] (Obsidian резолвит по basename).
|
||||
"""
|
||||
name = entity["name"]
|
||||
etype = entity.get("entityType", "unknown")
|
||||
observations = entity.get("observations") or []
|
||||
date = extract_date(name)
|
||||
tags = tags_for_path(subdir or "", etype) if subdir else []
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# YAML frontmatter
|
||||
lines.append("---")
|
||||
lines.append(f"id: {name}")
|
||||
lines.append(f"type: {etype}")
|
||||
if date:
|
||||
lines.append(f"date: {date}")
|
||||
if tags:
|
||||
lines.append(f"tags: [{', '.join(tags)}]")
|
||||
lines.append("imported_from: knowledge_graph")
|
||||
lines.append(f"imported_at: {time.strftime('%Y-%m-%d')}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"# {name}")
|
||||
lines.append("")
|
||||
meta_bits = [f"**Type:** `{etype}`"]
|
||||
if date:
|
||||
meta_bits.append(f"**Date:** {date}")
|
||||
if subdir:
|
||||
# breadcrumb: накопленный path → его MOC
|
||||
crumbs = []
|
||||
parts = subdir.split("/")
|
||||
for i in range(len(parts)):
|
||||
sub = "/".join(parts[: i + 1])
|
||||
crumbs.append(f"[[{_moc_id(sub)}|{parts[i]}]]")
|
||||
meta_bits.append("**Path:** " + " / ".join(crumbs))
|
||||
lines.append(" · ".join(meta_bits))
|
||||
lines.append("")
|
||||
|
||||
if observations:
|
||||
lines.append("## Observations")
|
||||
lines.append("")
|
||||
for obs in observations:
|
||||
txt = obs.replace("\n", " ").strip()
|
||||
lines.append(f"- {txt}")
|
||||
lines.append("")
|
||||
|
||||
if outgoing:
|
||||
lines.append("## Relations (outgoing)")
|
||||
lines.append("")
|
||||
# группируем по relationType — нагляднее
|
||||
by_rel: dict[str, list[str]] = defaultdict(list)
|
||||
for rel in outgoing:
|
||||
by_rel[rel["relationType"]].append(rel["to"])
|
||||
for rtype, targets in sorted(by_rel.items()):
|
||||
lines.append(f"### {rtype}")
|
||||
for target in targets:
|
||||
if target in name_to_path:
|
||||
lines.append(f"- [[{target}]]")
|
||||
else:
|
||||
lines.append(f"- {target} *(no entity in KG)*")
|
||||
lines.append("")
|
||||
|
||||
if incoming:
|
||||
lines.append("## Relations (incoming)")
|
||||
lines.append("")
|
||||
by_rel2: dict[str, list[str]] = defaultdict(list)
|
||||
for rel in incoming:
|
||||
by_rel2[rel["relationType"]].append(rel["from"])
|
||||
for rtype, sources in sorted(by_rel2.items()):
|
||||
lines.append(f"### {rtype} ← from")
|
||||
for src in sources:
|
||||
if src in name_to_path:
|
||||
lines.append(f"- [[{src}]]")
|
||||
else:
|
||||
lines.append(f"- {src} *(no entity)*")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def put_doc(
|
||||
base_url: str, db: str, auth: tuple[str, str], doc_id: str, doc: dict,
|
||||
dry_run: bool = False,
|
||||
) -> bool:
|
||||
"""PUT/POST документ. При conflict — GET _rev и PUT с _rev."""
|
||||
if dry_run:
|
||||
return True
|
||||
url = f"{base_url}/{db}/{quote(doc_id, safe='')}"
|
||||
r = requests.put(url, json=doc, auth=auth, timeout=30)
|
||||
if r.status_code in (201, 202):
|
||||
return True
|
||||
if r.status_code == 409:
|
||||
# Conflict — try get existing and retry with _rev
|
||||
rg = requests.get(url, auth=auth, timeout=30)
|
||||
if rg.status_code == 200:
|
||||
existing = rg.json()
|
||||
doc["_rev"] = existing["_rev"]
|
||||
r2 = requests.put(url, json=doc, auth=auth, timeout=30)
|
||||
if r2.status_code in (201, 202):
|
||||
return True
|
||||
logger.warning("Retry-after-conflict PUT %s: HTTP %d %s",
|
||||
doc_id, r2.status_code, r2.text[:200])
|
||||
return False
|
||||
logger.warning("PUT %s: HTTP %d %s", doc_id, r.status_code, r.text[:200])
|
||||
return False
|
||||
|
||||
|
||||
def delete_doc(base_url: str, db: str, auth: tuple[str, str], doc_id: str) -> bool:
|
||||
"""DELETE документ (для перезаписи старой broken-версии)."""
|
||||
url = f"{base_url}/{db}/{quote(doc_id, safe='')}"
|
||||
rg = requests.get(url, auth=auth, timeout=30)
|
||||
if rg.status_code != 200:
|
||||
return True # уже нет — OK
|
||||
rev = rg.json()["_rev"]
|
||||
r = requests.delete(f"{url}?rev={rev}", auth=auth, timeout=30)
|
||||
return r.status_code in (200, 202)
|
||||
|
||||
|
||||
def _load_kg() -> tuple[dict[str, dict], list[dict]]:
|
||||
"""Загружает KG из JSONL: returns (entities_by_name, relations_list)."""
|
||||
entities: dict[str, dict] = {}
|
||||
relations: list[dict] = []
|
||||
with KG_PATH.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if d.get("type") == "entity":
|
||||
entities[d["name"]] = d
|
||||
elif d.get("type") == "relation":
|
||||
relations.append(d)
|
||||
return entities, relations
|
||||
|
||||
|
||||
_TOP_LEVEL_DESCRIPTIONS = {
|
||||
"meta": "🧭 Проект, vision, стратегия, команда",
|
||||
"sprints": "🏃 Sprint plans",
|
||||
"sessions": "📅 Журналы сессий разработки",
|
||||
"events": "📌 События — milestones, объективы, deliverables",
|
||||
"decisions": "🎯 Решения, планы, политики (ADR-style)",
|
||||
"domains": "🗺 Бизнес-домены — DomRF, Cadastre, Rosreestr, Analytics, UI, API, Geo, Infra",
|
||||
"code": "🔧 Code modules, schemas, patterns, hooks, tasks",
|
||||
"sources": "💾 Источники данных (research)",
|
||||
"research": "🔬 Generic research, отчёты",
|
||||
"fixes": "🐞 Bug fixes",
|
||||
"limitations": "🚧 Ограничения, что не работает",
|
||||
"github": "🐙 GitHub issues",
|
||||
"feedback": "💬 Feedback / TODO заметки от пользователя",
|
||||
"unsorted": "❓ Не сгруппировано",
|
||||
}
|
||||
|
||||
|
||||
def _moc_id(subdir: str) -> str:
|
||||
"""Уникальный (per-vault) идентификатор MOC-файла для папки subdir.
|
||||
Использует дефис-разделённый path: domains/domrf/api → domrf-api-MOC.
|
||||
Префикс 'domains' опускается у вложенных, но сам domains→domains-MOC.
|
||||
"""
|
||||
parts = subdir.split("/")
|
||||
if len(parts) > 1 and parts[0] == "domains":
|
||||
parts = parts[1:]
|
||||
return "-".join(parts) + "-MOC"
|
||||
|
||||
|
||||
def _moc_for_subdir(subdir: str, entities_in_subdir: list[str],
|
||||
entities_all: dict[str, dict],
|
||||
subdirs_below: list[str]) -> str:
|
||||
"""Генерит MOC.md (Map of Content) для папки subdir."""
|
||||
parts = subdir.split("/")
|
||||
top = parts[0]
|
||||
title = " / ".join(parts) if len(parts) > 1 else parts[0]
|
||||
desc = _TOP_LEVEL_DESCRIPTIONS.get(top, "")
|
||||
moc_id = _moc_id(subdir)
|
||||
|
||||
lines = [
|
||||
"---",
|
||||
f"id: {moc_id}",
|
||||
"type: moc",
|
||||
f"tags: [moc, {top}]",
|
||||
f"imported_at: {time.strftime('%Y-%m-%d')}",
|
||||
"---",
|
||||
"",
|
||||
f"# 🗂 {title}",
|
||||
"",
|
||||
]
|
||||
if desc and len(parts) == 1:
|
||||
lines.append(f"> {desc}")
|
||||
lines.append("")
|
||||
|
||||
if subdirs_below:
|
||||
lines.append("## Подразделы")
|
||||
lines.append("")
|
||||
for sd in sorted(subdirs_below):
|
||||
sub_moc = _moc_id(sd)
|
||||
sd_label = sd.split("/")[-1]
|
||||
lines.append(f"- [[{sub_moc}|{sd_label}/]]")
|
||||
lines.append("")
|
||||
|
||||
if entities_in_subdir:
|
||||
lines.append(f"## Entities ({len(entities_in_subdir)})")
|
||||
lines.append("")
|
||||
# сортируем по дате (новые сверху), затем по имени
|
||||
def sort_key(name: str) -> tuple[str, str]:
|
||||
d = extract_date(name) or "0000"
|
||||
return (d, name)
|
||||
for n in sorted(entities_in_subdir, key=sort_key, reverse=True):
|
||||
ent = entities_all.get(n, {})
|
||||
et = ent.get("entityType", "")
|
||||
d = extract_date(n)
|
||||
d_str = f" · `{d}`" if d else ""
|
||||
et_str = f" · *{et}*" if et else ""
|
||||
lines.append(f"- [[{n}]]{d_str}{et_str}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _global_index_md(entities: dict[str, dict], relations_n: int,
|
||||
by_subdir: dict[str, list[str]]) -> str:
|
||||
"""Главный индекс (00_index.md) — entry point в vault."""
|
||||
lines = [
|
||||
"---",
|
||||
"id: 00_index",
|
||||
"type: index",
|
||||
"tags: [index, hub]",
|
||||
f"imported_at: {time.strftime('%Y-%m-%d')}",
|
||||
"---",
|
||||
"",
|
||||
"# 🗂 Knowledge Graph — GenDesign",
|
||||
"",
|
||||
f"Vault содержит **{len(entities)} entities** и **{relations_n} relations**, "
|
||||
"мигрированных из `memory/memory-gendesign.jsonl`.",
|
||||
"",
|
||||
"## Top-level разделы",
|
||||
"",
|
||||
]
|
||||
# Группируем top-level папки
|
||||
top_level: dict[str, int] = defaultdict(int)
|
||||
for sd, names in by_subdir.items():
|
||||
top_level[sd.split("/")[0]] += len(names)
|
||||
for top in sorted(top_level):
|
||||
desc = _TOP_LEVEL_DESCRIPTIONS.get(top, "")
|
||||
lines.append(f"- [[{_moc_id(top)}|**{top}/**]] ({top_level[top]} entities) — {desc}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Граф")
|
||||
lines.append("")
|
||||
lines.append("Открой Graph View (Ctrl+G) — увидишь паутину связей entities по `[[wiki-links]]`.")
|
||||
lines.append("")
|
||||
lines.append("Фильтр по тегу: `tag:#domrf`, `tag:#analytics`, `tag:#cadastre` и т.д.")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _clean_vault_subdirs(vault_path: Path, keep_files: set[str]) -> int:
|
||||
"""Удаляет из vault'а всё ранее сгенерированное (по old structure).
|
||||
Сохраняет файлы из keep_files (user content: welcome.md и пр).
|
||||
Возвращает кол-во удалённых файлов.
|
||||
"""
|
||||
removed = 0
|
||||
OLD_DIRS = ("sessions", "entities", "decisions", "runbooks", "sources",
|
||||
"meta", "sprints", "events", "domains", "code", "research",
|
||||
"fixes", "limitations", "github", "feedback", "unsorted")
|
||||
for sub in OLD_DIRS:
|
||||
d = vault_path / sub
|
||||
if not d.exists():
|
||||
continue
|
||||
for f in d.rglob("*.md"):
|
||||
try:
|
||||
f.unlink()
|
||||
removed += 1
|
||||
except OSError as e:
|
||||
logger.warning("Не удалить %s: %s", f, e)
|
||||
# удалить пустые папки (rmdir каждый уровень снизу вверх)
|
||||
for sub_d in sorted(d.rglob("*"), key=lambda p: -len(p.parts)):
|
||||
if sub_d.is_dir():
|
||||
try:
|
||||
sub_d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
# Удалить 00_index.md из root если есть
|
||||
idx = vault_path / "00_index.md"
|
||||
if idx.exists() and "00_index.md" not in keep_files:
|
||||
idx.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def _write_to_local_vault(vault_path: Path, limit: int | None) -> int:
|
||||
"""Пишет .md файлы напрямую в локальную папку Obsidian vault с иерархической
|
||||
структурой + MOC-страницы для навигации.
|
||||
|
||||
LiveSync plugin сам подхватит изменения через filesystem watcher и
|
||||
загрузит их в CouchDB в V3-chunked формате.
|
||||
"""
|
||||
if not vault_path.exists() or not vault_path.is_dir():
|
||||
logger.error("Vault path не существует: %s", vault_path)
|
||||
return 2
|
||||
|
||||
entities, relations = _load_kg()
|
||||
logger.info("Loaded %d entities, %d relations", len(entities), len(relations))
|
||||
|
||||
# Cleanup старой структуры (сохраняем user-files)
|
||||
keep = {"welcome.md", "qqq.md", "untitled.md", "2026-05-11.md"}
|
||||
removed = _clean_vault_subdirs(vault_path, keep)
|
||||
logger.info("Cleaned %d old .md files from vault", removed)
|
||||
|
||||
# Relation indices
|
||||
out_by: dict[str, list[dict]] = defaultdict(list)
|
||||
in_by: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in relations:
|
||||
out_by[r["from"]].append(r)
|
||||
in_by[r["to"]].append(r)
|
||||
|
||||
# Path resolution
|
||||
name_to_subdir: dict[str, str] = {}
|
||||
name_to_path: dict[str, str] = {}
|
||||
for name, ent in entities.items():
|
||||
subdir = categorize_v2(name, ent.get("entityType", ""))
|
||||
fname = safe_filename(name) + ".md"
|
||||
name_to_subdir[name] = subdir
|
||||
name_to_path[name] = f"{subdir}/{fname}"
|
||||
|
||||
names = list(entities.keys())
|
||||
if limit:
|
||||
names = names[:limit]
|
||||
logger.info("Will write %d entities", len(names))
|
||||
|
||||
# PASS 1: запись всех entities
|
||||
ok = 0
|
||||
for i, name in enumerate(names, 1):
|
||||
ent = entities[name]
|
||||
rel_path = name_to_path[name]
|
||||
subdir = name_to_subdir[name]
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
out_uniq: list[dict] = []
|
||||
for r in out_by[name]:
|
||||
key = (r["from"], r["to"], r["relationType"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out_uniq.append(r)
|
||||
in_uniq: list[dict] = []
|
||||
for r in in_by[name]:
|
||||
key = (r["from"], r["to"], r["relationType"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
in_uniq.append(r)
|
||||
content = md_for_entity(ent, out_uniq, in_uniq, name_to_path, subdir)
|
||||
|
||||
target = vault_path / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
ok += 1
|
||||
|
||||
logger.info("Entities written: %d", ok)
|
||||
|
||||
# PASS 2: MOC-страницы (для каждой подпапки)
|
||||
by_subdir: dict[str, list[str]] = defaultdict(list)
|
||||
for name in names:
|
||||
by_subdir[name_to_subdir[name]].append(name)
|
||||
|
||||
# Найти все уровни подпапок и их subdirs
|
||||
all_subdirs: set[str] = set()
|
||||
for sd in by_subdir:
|
||||
parts = sd.split("/")
|
||||
for i in range(1, len(parts) + 1):
|
||||
all_subdirs.add("/".join(parts[:i]))
|
||||
|
||||
moc_count = 0
|
||||
for sd in sorted(all_subdirs):
|
||||
entities_here = by_subdir.get(sd, [])
|
||||
# подпапки на уровень ниже
|
||||
subdirs_below = [s for s in all_subdirs
|
||||
if s.startswith(sd + "/") and s.count("/") == sd.count("/") + 1]
|
||||
moc_content = _moc_for_subdir(sd, entities_here, entities, subdirs_below)
|
||||
moc_filename = _moc_id(sd) + ".md"
|
||||
moc_path = vault_path / sd / moc_filename
|
||||
moc_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
moc_path.write_text(moc_content, encoding="utf-8")
|
||||
moc_count += 1
|
||||
|
||||
logger.info("MOC pages written: %d", moc_count)
|
||||
|
||||
# PASS 3: глобальный индекс
|
||||
index_content = _global_index_md(entities, len(relations), by_subdir)
|
||||
(vault_path / "00_index.md").write_text(index_content, encoding="utf-8")
|
||||
|
||||
logger.info("DONE: %d entities + %d MOC + 1 index в %s",
|
||||
ok, moc_count, vault_path)
|
||||
logger.info("Открой Obsidian — LiveSync plugin отсканирует и запушит в CouchDB.")
|
||||
return 0
|
||||
|
||||
|
||||
def _cleanup_orphan_chunks(base_url: str, db: str, auth: tuple[str, str],
|
||||
dry_run: bool) -> int:
|
||||
"""Удаляет h:kg* chunk-документы (legacy from old migration schema).
|
||||
|
||||
Использует _all_docs со startkey/endkey по префиксу 'h:kg'. Чанки уже
|
||||
не нужны после перехода на eden-inline embedding.
|
||||
"""
|
||||
url = f"{base_url}/{db}/_all_docs"
|
||||
params = {"startkey": '"h:kg"', "endkey": '"h:kg\\ufff0"', "limit": 10000}
|
||||
r = requests.get(url, params=params, auth=auth, timeout=60)
|
||||
if r.status_code != 200:
|
||||
logger.error("_all_docs failed: HTTP %d %s", r.status_code, r.text[:200])
|
||||
return 1
|
||||
rows = r.json().get("rows", [])
|
||||
logger.info("Found %d orphan h:kg* chunks", len(rows))
|
||||
if dry_run:
|
||||
return 0
|
||||
# bulk_docs с _deleted:true — куда быстрее чем 549 индивидуальных DELETE.
|
||||
docs = [{"_id": row["id"], "_rev": row["value"]["rev"], "_deleted": True}
|
||||
for row in rows]
|
||||
if not docs:
|
||||
return 0
|
||||
bulk_url = f"{base_url}/{db}/_bulk_docs"
|
||||
rb = requests.post(bulk_url, json={"docs": docs}, auth=auth, timeout=120)
|
||||
if rb.status_code not in (201, 200):
|
||||
logger.error("_bulk_docs failed: HTTP %d %s", rb.status_code, rb.text[:200])
|
||||
return 1
|
||||
results = rb.json()
|
||||
deleted = sum(1 for x in results if x.get("ok"))
|
||||
logger.info("Deleted %d/%d orphan chunks", deleted, len(docs))
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="Только сгенерить .md (вывести count) без PUT в CouchDB")
|
||||
ap.add_argument("--limit", type=int, default=None,
|
||||
help="Загрузить только первые N entities (для smoke-теста)")
|
||||
ap.add_argument("--cleanup-orphan-chunks", action="store_true",
|
||||
help="Удалить осиротевшие h:kg* chunk-документы из предыдущей миграции")
|
||||
ap.add_argument("--local-vault", type=str, default=None,
|
||||
help="Путь к локальной папке Obsidian vault. В этом режиме скрипт "
|
||||
"пишет .md файлы прямо в файловую систему, а LiveSync plugin "
|
||||
"сам корректно загрузит их в CouchDB в правильном V3-chunked "
|
||||
"формате. Не использует CouchDB API.")
|
||||
args = ap.parse_args()
|
||||
|
||||
base_url = os.environ.get("COUCHDB_URL", "https://obsidian.gendsgn.ru")
|
||||
db = os.environ.get("COUCHDB_DB", "gendesign-vault")
|
||||
user = os.environ.get("COUCHDB_USER", "obsidian")
|
||||
password = os.environ.get("COUCHDB_PASSWORD")
|
||||
if not password and not args.dry_run and not args.local_vault:
|
||||
logger.error("COUCHDB_PASSWORD env required (unless --dry-run or --local-vault)")
|
||||
return 1
|
||||
auth = (user, password or "")
|
||||
|
||||
if args.cleanup_orphan_chunks:
|
||||
return _cleanup_orphan_chunks(base_url, db, auth, args.dry_run)
|
||||
|
||||
if not KG_PATH.exists():
|
||||
logger.error("KG not found: %s", KG_PATH)
|
||||
return 2
|
||||
|
||||
if args.local_vault:
|
||||
return _write_to_local_vault(Path(args.local_vault), args.limit)
|
||||
|
||||
# 1. Load KG
|
||||
entities: dict[str, dict] = {}
|
||||
relations: list[dict] = []
|
||||
with KG_PATH.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if d.get("type") == "entity":
|
||||
# Берём LATEST entity если name дублируется (auto-memory append)
|
||||
entities[d["name"]] = d
|
||||
elif d.get("type") == "relation":
|
||||
relations.append(d)
|
||||
logger.info("Loaded %d entities, %d relations", len(entities), len(relations))
|
||||
|
||||
# 2. Build relation indices
|
||||
out_by: dict[str, list[dict]] = defaultdict(list)
|
||||
in_by: dict[str, list[dict]] = defaultdict(list)
|
||||
for r in relations:
|
||||
# Дедуп: одна и та же relation может встречаться много раз в auto-memory log
|
||||
out_by[r["from"]].append(r)
|
||||
in_by[r["to"]].append(r)
|
||||
|
||||
# 3. Compute target paths for [[wiki-links]] resolution
|
||||
name_to_path: dict[str, str] = {}
|
||||
for name, ent in entities.items():
|
||||
subdir = categorize(name, ent.get("entityType", ""))
|
||||
fname = safe_filename(name) + ".md"
|
||||
name_to_path[name] = f"{subdir}/{fname}"
|
||||
|
||||
# 4. Migrate
|
||||
names = list(entities.keys())
|
||||
if args.limit:
|
||||
names = names[: args.limit]
|
||||
logger.info("Will migrate %d entities (--limit=%s)", len(names), args.limit)
|
||||
|
||||
# PASS 1: pre-compute content + chunk_id for every entity, чтобы потом
|
||||
# сделать bulk PUT chunks FIRST, потом — main docs.
|
||||
prepared: list[tuple[str, str, str, int]] = [] # (path, chunk_id, content, size)
|
||||
for name in names:
|
||||
ent = entities[name]
|
||||
path = name_to_path[name]
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
out_uniq: list[dict] = []
|
||||
for r in out_by[name]:
|
||||
key = (r["from"], r["to"], r["relationType"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out_uniq.append(r)
|
||||
in_uniq: list[dict] = []
|
||||
for r in in_by[name]:
|
||||
key = (r["from"], r["to"], r["relationType"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
in_uniq.append(r)
|
||||
content = md_for_entity(ent, out_uniq, in_uniq, name_to_path)
|
||||
size_bytes = len(content.encode("utf-8"))
|
||||
chunk_id = chunk_id_for(content)
|
||||
prepared.append((path, chunk_id, content, size_bytes))
|
||||
|
||||
# PASS 2: bulk PUT chunks (idempotent — если уже есть, CouchDB вернёт conflict,
|
||||
# это OK).
|
||||
unique_chunks: dict[str, str] = {} # chunk_id -> content
|
||||
for _, cid, content, _ in prepared:
|
||||
unique_chunks.setdefault(cid, content)
|
||||
logger.info("Bulk PUT %d unique chunks…", len(unique_chunks))
|
||||
chunk_docs = [{"_id": cid, "data": data, "type": "leaf"}
|
||||
for cid, data in unique_chunks.items()]
|
||||
if not args.dry_run:
|
||||
rb = requests.post(f"{base_url}/{db}/_bulk_docs",
|
||||
json={"docs": chunk_docs, "new_edits": True},
|
||||
auth=auth, timeout=120)
|
||||
if rb.status_code in (201, 200):
|
||||
results = rb.json()
|
||||
n_ok = sum(1 for x in results if x.get("ok"))
|
||||
n_conflict = sum(1 for x in results if x.get("error") == "conflict")
|
||||
logger.info(" chunks: %d created, %d already existed", n_ok, n_conflict)
|
||||
else:
|
||||
logger.error("Bulk chunks PUT failed: HTTP %d %s",
|
||||
rb.status_code, rb.text[:200])
|
||||
return 1
|
||||
|
||||
# PASS 3: PUT main docs sequentially (deleting old eden-only versions first
|
||||
# to avoid _rev conflicts from прошлой неудачной миграции).
|
||||
ok = 0
|
||||
fail = 0
|
||||
now_ms = int(time.time() * 1000)
|
||||
for i, (path, chunk_id, _content, size_bytes) in enumerate(prepared, 1):
|
||||
# delete старую версию (eden-only без children — broken)
|
||||
delete_doc(base_url, db, auth, path)
|
||||
main_doc = {
|
||||
"_id": path,
|
||||
"path": path,
|
||||
"children": [chunk_id],
|
||||
"ctime": now_ms,
|
||||
"mtime": now_ms,
|
||||
"size": size_bytes,
|
||||
"type": "plain",
|
||||
"eden": {},
|
||||
}
|
||||
if put_doc(base_url, db, auth, path, main_doc, args.dry_run):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
if i % 50 == 0:
|
||||
logger.info(" main docs: %d/%d (ok=%d fail=%d)",
|
||||
i, len(prepared), ok, fail)
|
||||
|
||||
# 5. Index page
|
||||
index_md_lines = [
|
||||
"---",
|
||||
"id: 00_index",
|
||||
"type: index",
|
||||
f"imported_at: {time.strftime('%Y-%m-%d')}",
|
||||
"---",
|
||||
"",
|
||||
"# 🗂 Knowledge graph index",
|
||||
"",
|
||||
f"Migrated from `memory/memory-gendesign.jsonl` ({len(entities)} entities,"
|
||||
f" {len(relations)} relations).",
|
||||
"",
|
||||
]
|
||||
by_subdir: dict[str, list[str]] = defaultdict(list)
|
||||
for name, path in name_to_path.items():
|
||||
by_subdir[path.split("/")[0]].append(name)
|
||||
for subdir in sorted(by_subdir):
|
||||
index_md_lines.append(f"## {subdir}/ ({len(by_subdir[subdir])})")
|
||||
index_md_lines.append("")
|
||||
for n in sorted(by_subdir[subdir]):
|
||||
p = name_to_path[n][:-3]
|
||||
index_md_lines.append(f"- [[{p}|{n}]]")
|
||||
index_md_lines.append("")
|
||||
index_content = "\n".join(index_md_lines)
|
||||
index_chunk_id = chunk_id_for(index_content)
|
||||
# chunk first
|
||||
put_doc(base_url, db, auth, index_chunk_id,
|
||||
{"_id": index_chunk_id, "data": index_content, "type": "leaf"},
|
||||
args.dry_run)
|
||||
delete_doc(base_url, db, auth, "00_index.md")
|
||||
put_doc(base_url, db, auth, "00_index.md", {
|
||||
"_id": "00_index.md", "path": "00_index.md",
|
||||
"children": [index_chunk_id], "ctime": now_ms, "mtime": now_ms,
|
||||
"size": len(index_content.encode("utf-8")),
|
||||
"type": "plain", "eden": {},
|
||||
}, args.dry_run)
|
||||
|
||||
logger.info("DONE: %d ok, %d failed", ok, fail)
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -93,3 +93,12 @@ Host gendesign
|
|||
|
||||
|
||||
token admin 15c99983d471718fe5c320f36bad01994d91ff34cb5646ce957b060ec039275e
|
||||
|
||||
|
||||
ключ от обсидиан gendesign@gendesign:~$ PASSWORD=$(openssl rand -base64 32)
|
||||
cho "Sgendesign@gendesign:~$ echo "Save this: $PASSWORD"
|
||||
Save this: femp1ueSrQoD2cV/9m7YC8bmIuMOHz0JMmrpmD5Z1Ns=
|
||||
curl -u obsidian:<femp1ueSrQoD2cV/9m7YC8bmIuMOHz0JMmrpmD5Z1Ns=> https://obsidian.gendsgn.ru/gendesign-vault
|
||||
|
||||
сеть PS C:\Users\user> ssh gendesign 'docker network create gendesign_shared'
|
||||
560a390e7b231eb2e0657ee96c8add1561ce772716b9db5b593b0950669df644
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue