308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""Wiki 结构化数据存储层。
|
||
|
||
每个 wiki 目录下维护一个 data.db(SQLite),存储所有结构化数据。
|
||
Markdown 页面只负责叙事分析,不在 frontmatter 中存储 data/history。
|
||
|
||
用法:
|
||
from store import WikiStore
|
||
|
||
store = WikiStore(".wiki/my-research/")
|
||
store.upsert_data("alpha-corp", "管理规模", 1200, "亿元", "2025-12", "2026-04-policy-doc")
|
||
store.add_relation("alpha-corp", "受托人市场格局", "part_of")
|
||
|
||
# 查询
|
||
rows = store.query_data(page_slug="alpha-corp")
|
||
timeline = store.query_timeline(field="管理规模")
|
||
|
||
CLI:
|
||
python store.py init .wiki/my-research/
|
||
python store.py dump .wiki/my-research/
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
import sys
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
from typing import Any, Optional
|
||
|
||
|
||
SCHEMA_SQL = """
|
||
CREATE TABLE IF NOT EXISTS pages (
|
||
slug TEXT PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
type TEXT NOT NULL CHECK(type IN ('source','entity','concept','analysis','mental-model')),
|
||
confidence TEXT NOT NULL DEFAULT 'medium' CHECK(confidence IN ('high','medium','low','contested')),
|
||
created TEXT NOT NULL,
|
||
updated TEXT NOT NULL
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS data_points (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
page_slug TEXT NOT NULL REFERENCES pages(slug),
|
||
field TEXT NOT NULL,
|
||
value REAL NOT NULL,
|
||
unit TEXT NOT NULL,
|
||
period TEXT NOT NULL,
|
||
source_slug TEXT NOT NULL,
|
||
scope TEXT,
|
||
verified INTEGER, -- NULL=unknown, 0=false, 1=true
|
||
confidence TEXT DEFAULT 'high' CHECK(confidence IN ('high','medium','low','contested')),
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE(page_slug, field, period) -- 同一页面同一字段同一时段只留一条(upsert 覆盖)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
page_slug TEXT NOT NULL REFERENCES pages(slug),
|
||
field TEXT NOT NULL,
|
||
old_value REAL NOT NULL,
|
||
old_unit TEXT NOT NULL,
|
||
old_source TEXT NOT NULL,
|
||
new_source TEXT,
|
||
reason TEXT NOT NULL,
|
||
date TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS relations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
from_slug TEXT NOT NULL,
|
||
to_slug TEXT NOT NULL,
|
||
type TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
UNIQUE(from_slug, to_slug, type)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_dp_page ON data_points(page_slug);
|
||
CREATE INDEX IF NOT EXISTS idx_dp_field ON data_points(field);
|
||
CREATE INDEX IF NOT EXISTS idx_dp_period ON data_points(period);
|
||
CREATE INDEX IF NOT EXISTS idx_rel_from ON relations(from_slug);
|
||
CREATE INDEX IF NOT EXISTS idx_rel_to ON relations(to_slug);
|
||
CREATE INDEX IF NOT EXISTS idx_hist_page ON history(page_slug);
|
||
"""
|
||
|
||
|
||
class WikiStore:
|
||
"""单个 wiki 的 SQLite 存储接口。"""
|
||
|
||
def __init__(self, wiki_dir: str | Path):
|
||
self.wiki_dir = Path(wiki_dir)
|
||
self.db_path = self.wiki_dir / "data.db"
|
||
self._conn: Optional[sqlite3.Connection] = None
|
||
|
||
@property
|
||
def conn(self) -> sqlite3.Connection:
|
||
if self._conn is None:
|
||
self._conn = sqlite3.connect(str(self.db_path))
|
||
self._conn.row_factory = sqlite3.Row
|
||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||
return self._conn
|
||
|
||
def init_db(self) -> None:
|
||
"""创建表结构(幂等)。"""
|
||
self.conn.executescript(SCHEMA_SQL)
|
||
self.conn.commit()
|
||
|
||
def close(self) -> None:
|
||
if self._conn:
|
||
self._conn.close()
|
||
self._conn = None
|
||
|
||
# ── Pages ──
|
||
|
||
def upsert_page(self, slug: str, title: str, page_type: str,
|
||
confidence: str = "medium",
|
||
created: str = "", updated: str = "") -> None:
|
||
today = date.today().isoformat()
|
||
self.conn.execute("""
|
||
INSERT INTO pages (slug, title, type, confidence, created, updated)
|
||
VALUES (?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(slug) DO UPDATE SET
|
||
title=excluded.title, type=excluded.type,
|
||
confidence=excluded.confidence, updated=excluded.updated
|
||
""", (slug, title, page_type, confidence, created or today, updated or today))
|
||
self.conn.commit()
|
||
|
||
# ── Data Points ──
|
||
|
||
def upsert_data(self, page_slug: str, field: str, value: float,
|
||
unit: str, period: str, source_slug: str,
|
||
scope: str = None, verified: bool = None,
|
||
confidence: str = "high") -> Optional[dict]:
|
||
"""写入数据点。如果同字段同时段已有旧值,自动写入 history 并返回旧记录。"""
|
||
# 查旧值
|
||
old = self.conn.execute(
|
||
"SELECT value, unit, source_slug FROM data_points WHERE page_slug=? AND field=? AND period=?",
|
||
(page_slug, field, period)
|
||
).fetchone()
|
||
|
||
old_record = None
|
||
if old and old["value"] != value:
|
||
old_record = dict(old)
|
||
# 写 history
|
||
self.conn.execute("""
|
||
INSERT INTO history (page_slug, field, old_value, old_unit, old_source, new_source, reason, date)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (page_slug, field, old["value"], old["unit"], old["source_slug"],
|
||
source_slug, f"{field}: {old['value']} → {value}", date.today().isoformat()))
|
||
|
||
# upsert data point
|
||
v_int = None if verified is None else (1 if verified else 0)
|
||
self.conn.execute("""
|
||
INSERT INTO data_points (page_slug, field, value, unit, period, source_slug, scope, verified, confidence)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(page_slug, field, period) DO UPDATE SET
|
||
value=excluded.value, unit=excluded.unit,
|
||
source_slug=excluded.source_slug, scope=excluded.scope,
|
||
verified=excluded.verified, confidence=excluded.confidence
|
||
""", (page_slug, field, value, unit, period, source_slug, scope, v_int, confidence))
|
||
self.conn.commit()
|
||
return old_record
|
||
|
||
# ── Relations ──
|
||
|
||
def add_relation(self, from_slug: str, to_slug: str, rel_type: str) -> None:
|
||
self.conn.execute("""
|
||
INSERT OR IGNORE INTO relations (from_slug, to_slug, type)
|
||
VALUES (?, ?, ?)
|
||
""", (from_slug, to_slug, rel_type))
|
||
self.conn.commit()
|
||
|
||
# ── Queries ──
|
||
|
||
def query_data(self, page_slug: str = None, field: str = None) -> list[dict]:
|
||
"""查询数据点。可按页面或字段过滤。"""
|
||
sql = "SELECT * FROM data_points WHERE 1=1"
|
||
params: list = []
|
||
if page_slug:
|
||
sql += " AND page_slug=?"
|
||
params.append(page_slug)
|
||
if field:
|
||
sql += " AND field=?"
|
||
params.append(field)
|
||
sql += " ORDER BY period DESC"
|
||
return [dict(r) for r in self.conn.execute(sql, params).fetchall()]
|
||
|
||
def query_timeline(self, field: str, page_slug: str = None) -> list[dict]:
|
||
"""查询某字段的时间线(含历史值)。"""
|
||
# 当前值
|
||
sql = "SELECT page_slug, field, value, unit, period, source_slug, 'current' as status FROM data_points WHERE field=?"
|
||
params: list = [field]
|
||
if page_slug:
|
||
sql += " AND page_slug=?"
|
||
params.append(page_slug)
|
||
|
||
# 历史值
|
||
sql2 = "SELECT page_slug, field, old_value as value, old_unit as unit, date as period, old_source as source_slug, 'superseded' as status FROM history WHERE field=?"
|
||
params2: list = [field]
|
||
if page_slug:
|
||
sql2 += " AND page_slug=?"
|
||
params2.append(page_slug)
|
||
|
||
rows = [dict(r) for r in self.conn.execute(sql, params).fetchall()]
|
||
rows += [dict(r) for r in self.conn.execute(sql2, params2).fetchall()]
|
||
rows.sort(key=lambda r: r.get("period", ""), reverse=True)
|
||
return rows
|
||
|
||
def query_relations(self, slug: str = None, rel_type: str = None) -> list[dict]:
|
||
sql = "SELECT * FROM relations WHERE 1=1"
|
||
params: list = []
|
||
if slug:
|
||
sql += " AND (from_slug=? OR to_slug=?)"
|
||
params += [slug, slug]
|
||
if rel_type:
|
||
sql += " AND type=?"
|
||
params.append(rel_type)
|
||
return [dict(r) for r in self.conn.execute(sql, params).fetchall()]
|
||
|
||
def get_page(self, slug: str) -> Optional[dict]:
|
||
row = self.conn.execute("SELECT * FROM pages WHERE slug=?", (slug,)).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
def list_pages(self, page_type: str = None) -> list[dict]:
|
||
sql = "SELECT * FROM pages"
|
||
params: list = []
|
||
if page_type:
|
||
sql += " WHERE type=?"
|
||
params.append(page_type)
|
||
sql += " ORDER BY updated DESC"
|
||
return [dict(r) for r in self.conn.execute(sql, params).fetchall()]
|
||
|
||
def stats(self) -> dict:
|
||
"""返回 wiki 数据库统计。"""
|
||
s: dict[str, Any] = {}
|
||
s["pages"] = self.conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0]
|
||
s["data_points"] = self.conn.execute("SELECT COUNT(*) FROM data_points").fetchone()[0]
|
||
s["relations"] = self.conn.execute("SELECT COUNT(*) FROM relations").fetchone()[0]
|
||
s["contested"] = self.conn.execute("SELECT COUNT(*) FROM data_points WHERE confidence='contested'").fetchone()[0]
|
||
for row in self.conn.execute("SELECT type, COUNT(*) as cnt FROM pages GROUP BY type").fetchall():
|
||
s[f"pages_{row['type']}"] = row["cnt"]
|
||
return s
|
||
|
||
def dump(self) -> str:
|
||
"""输出人类可读的数据库摘要。"""
|
||
st = self.stats()
|
||
lines = [
|
||
f"Wiki Store: {self.wiki_dir.name}",
|
||
f"{'='*50}",
|
||
f"Pages: {st['pages']} | Data Points: {st['data_points']} | Relations: {st['relations']} | Contested: {st['contested']}",
|
||
"",
|
||
]
|
||
# pages by type
|
||
for pt in ["entity", "concept", "source", "analysis", "mental-model"]:
|
||
key = f"pages_{pt}"
|
||
if st.get(key):
|
||
lines.append(f" {pt}: {st[key]}")
|
||
|
||
# recent data points
|
||
recent = self.conn.execute(
|
||
"SELECT page_slug, field, value, unit, period FROM data_points ORDER BY created_at DESC LIMIT 10"
|
||
).fetchall()
|
||
if recent:
|
||
lines += ["", "Recent Data Points:"]
|
||
for r in recent:
|
||
lines.append(f" {r['page_slug']}.{r['field']} = {r['value']} {r['unit']} ({r['period']})")
|
||
|
||
# relations
|
||
rels = self.conn.execute("SELECT * FROM relations ORDER BY created_at DESC LIMIT 10").fetchall()
|
||
if rels:
|
||
lines += ["", "Recent Relations:"]
|
||
for r in rels:
|
||
lines.append(f" {r['from_slug']} --{r['type']}--> {r['to_slug']}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ── CLI ──
|
||
|
||
def main():
|
||
if len(sys.argv) < 3:
|
||
print("Usage:")
|
||
print(" python store.py init <wiki_dir> — initialize data.db")
|
||
print(" python store.py dump <wiki_dir> — dump database summary")
|
||
sys.exit(1)
|
||
|
||
cmd, target = sys.argv[1], Path(sys.argv[2])
|
||
store = WikiStore(target)
|
||
|
||
if cmd == "init":
|
||
store.init_db()
|
||
print(f"Initialized: {store.db_path}")
|
||
elif cmd == "dump":
|
||
if not store.db_path.exists():
|
||
print(f"No data.db found in {target}")
|
||
sys.exit(1)
|
||
print(store.dump())
|
||
else:
|
||
print(f"Unknown command: {cmd}")
|
||
sys.exit(1)
|
||
|
||
store.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|