update
This commit is contained in:
@@ -199,6 +199,73 @@ CREATE TABLE IF NOT EXISTS sub_artefakte (
|
||||
)
|
||||
"""
|
||||
|
||||
# Kanban dataflow (boards 'inventory' + 'artefacts'): ONE generic card table for all card kinds
|
||||
# (title/cluster/block). `stage` is the queue key — a worker pulls WHERE stage = <its input stage>.
|
||||
# `payload` is a JSON blob (title, description, sources, readers, mentions, parent_norm, journal …);
|
||||
# retries/not_before/last_error implement backoff + dead-letter (stage 'dead' after MAX_CARD_RETRIES).
|
||||
CREATE_KANBAN_CARDS = """
|
||||
CREATE TABLE IF NOT EXISTS kanban_cards (
|
||||
topic TEXT NOT NULL,
|
||||
board TEXT NOT NULL,
|
||||
card_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
not_before TEXT NOT NULL DEFAULT '',
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, board, card_id)
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_KANBAN_PULL_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
|
||||
"""
|
||||
|
||||
# title_norm → cluster membership (one title belongs to exactly one cluster).
|
||||
CREATE_KANBAN_MEMBERS = """
|
||||
CREATE TABLE IF NOT EXISTS kanban_members (
|
||||
topic TEXT NOT NULL,
|
||||
member_id TEXT NOT NULL,
|
||||
group_id TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, member_id)
|
||||
)
|
||||
"""
|
||||
|
||||
# Guide board: one card per block, linear stages (lernziele … lesbarkeit → done).
|
||||
# `md` carries the writer fragment (with kapitel/section/sub markers) between the gates.
|
||||
CREATE_GUIDE_CARDS = """
|
||||
CREATE TABLE IF NOT EXISTS guide_cards (
|
||||
topic TEXT NOT NULL,
|
||||
format TEXT NOT NULL DEFAULT 'Guide',
|
||||
block_norm TEXT NOT NULL,
|
||||
block TEXT NOT NULL,
|
||||
chapter TEXT NOT NULL DEFAULT '',
|
||||
ord INTEGER NOT NULL DEFAULT 0,
|
||||
stage TEXT NOT NULL DEFAULT 'lernziele',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
writer_rounds INTEGER NOT NULL DEFAULT 0,
|
||||
gate_info TEXT NOT NULL DEFAULT '',
|
||||
md TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, format, block_norm)
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_GUIDE_LERNZIELE = """
|
||||
CREATE TABLE IF NOT EXISTS guide_lernziele (
|
||||
topic TEXT NOT NULL,
|
||||
block_norm TEXT NOT NULL,
|
||||
ziel_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
sub_norm TEXT NOT NULL DEFAULT '',
|
||||
covered INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (topic, block_norm, ziel_id)
|
||||
)
|
||||
"""
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
@@ -213,7 +280,9 @@ async def get_db() -> aiosqlite.Connection:
|
||||
async def init_db():
|
||||
db = await get_db()
|
||||
# WAL survives crashes much better; busy_timeout absorbs short locks.
|
||||
# synchronous=NORMAL: safe under WAL, much less fsync — the kanban flow commits often.
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA synchronous=NORMAL")
|
||||
await db.execute("PRAGMA busy_timeout=5000")
|
||||
await db.execute(CREATE_GUIDES)
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
@@ -230,6 +299,11 @@ async def init_db():
|
||||
await db.execute(CREATE_SOURCE)
|
||||
await db.execute(CREATE_GUIDE_OUTLINE)
|
||||
await db.execute(CREATE_SUB_ARTEFAKTE)
|
||||
await db.execute(CREATE_KANBAN_CARDS)
|
||||
await db.execute(CREATE_KANBAN_PULL_INDEX)
|
||||
await db.execute(CREATE_KANBAN_MEMBERS)
|
||||
await db.execute(CREATE_GUIDE_CARDS)
|
||||
await db.execute(CREATE_GUIDE_LERNZIELE)
|
||||
try: # migration for existing DBs without the step column
|
||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
@@ -706,6 +780,378 @@ async def delete_blocks(topic: str) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Kanban dataflow (generic card layer, boards 'inventory' + 'artefacts') ────────
|
||||
def _now_plus(seconds: float) -> str:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
|
||||
|
||||
|
||||
def _card(row, cursor) -> dict:
|
||||
"""Row → dict with the JSON payload decoded (payload keys stay under 'payload')."""
|
||||
d = _row_to_dict(row, cursor)
|
||||
try:
|
||||
d["payload"] = json.loads(d.get("payload") or "{}")
|
||||
except (TypeError, ValueError):
|
||||
d["payload"] = {}
|
||||
return d
|
||||
|
||||
|
||||
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
|
||||
"""Oldest `limit` cards sitting in `stage` whose backoff has expired (FIFO via updated_at)."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
|
||||
ORDER BY updated_at LIMIT ?""",
|
||||
(topic, board, stage, _now(), limit))
|
||||
return [_card(row, cursor) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def kanban_count(topic: str, stages, board: str | None = None) -> int:
|
||||
"""Cards sitting in any of `stages` (str or list) — queue length / quiescence.
|
||||
Cards in backoff still count: their work is not done. `board=None` = across boards."""
|
||||
if isinstance(stages, str):
|
||||
stages = [stages]
|
||||
if not stages:
|
||||
return 0
|
||||
db = await get_db()
|
||||
ph = ",".join("?" * len(stages))
|
||||
sql = f"SELECT count(*) FROM kanban_cards WHERE topic = ? AND stage IN ({ph})"
|
||||
args: tuple = (topic, *stages)
|
||||
if board:
|
||||
sql += " AND board = ?"
|
||||
args += (board,)
|
||||
cursor = await db.execute(sql, args)
|
||||
return (await cursor.fetchone())[0]
|
||||
|
||||
|
||||
async def kanban_advance(topic: str, board: str, card_id: str, stage: str) -> None:
|
||||
"""Move a card to `stage` (next column, or back for rework). Clears backoff/error."""
|
||||
await kanban_advance_many(topic, board, [(card_id, stage)])
|
||||
|
||||
|
||||
async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str]]) -> None:
|
||||
"""Batch stage moves in ONE commit (the flow advances whole packages)."""
|
||||
if not moves:
|
||||
return
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.executemany(
|
||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||
[(stage, now, topic, board, cid) for cid, stage in moves])
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_upsert_card(topic: str, board: str, card_id: str, kind: str, stage: str,
|
||||
payload: dict | None = None) -> None:
|
||||
"""Insert or overwrite a card (stable ids → growing clusters upsert, never duplicate).
|
||||
payload=None keeps the existing payload on conflict."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, COALESCE(?, '{}'), ?)
|
||||
ON CONFLICT(topic, board, card_id) DO UPDATE SET
|
||||
kind = excluded.kind, stage = excluded.stage,
|
||||
payload = COALESCE(?, kanban_cards.payload),
|
||||
retries = 0, not_before = '', last_error = NULL, updated_at = excluded.updated_at""",
|
||||
(topic, board, card_id, kind, stage,
|
||||
json.dumps(payload, ensure_ascii=False) if payload is not None else None, _now(),
|
||||
json.dumps(payload, ensure_ascii=False) if payload is not None else None))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_set_payload(topic: str, board: str, card_id: str, payload: dict) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE kanban_cards SET payload = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?",
|
||||
(json.dumps(payload, ensure_ascii=False), _now(), topic, board, card_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_get_card(topic: str, board: str, card_id: str) -> dict | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?", (topic, board, card_id))
|
||||
row = await cursor.fetchone()
|
||||
return _card(row, cursor) if row else None
|
||||
|
||||
|
||||
async def kanban_cards(topic: str, board: str | None = None, stage: str | None = None,
|
||||
kind: str | None = None) -> list[dict]:
|
||||
db = await get_db()
|
||||
sql, args = "SELECT * FROM kanban_cards WHERE topic = ?", [topic]
|
||||
for col, val in (("board", board), ("stage", stage), ("kind", kind)):
|
||||
if val is not None:
|
||||
sql += f" AND {col} = ?"
|
||||
args.append(val)
|
||||
cursor = await db.execute(sql, args)
|
||||
return [_card(row, cursor) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
|
||||
max_retries: int, backoff_base: float = 30.0) -> bool:
|
||||
"""Register a processing failure: retries++, exponential backoff (not_before), and after
|
||||
`max_retries` → stage 'dead' (dead-letter, requeue-able). → True if the card went dead."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT retries FROM kanban_cards WHERE topic = ? AND board = ? AND card_id = ?",
|
||||
(topic, board, card_id))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
retries = (row[0] or 0) + 1
|
||||
dead = retries >= max_retries
|
||||
if dead:
|
||||
await db.execute(
|
||||
"""UPDATE kanban_cards SET stage = 'dead', retries = ?, not_before = '',
|
||||
last_error = ?, updated_at = ? WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||
(retries, error[:500], _now(), topic, board, card_id))
|
||||
else:
|
||||
await db.execute(
|
||||
"""UPDATE kanban_cards SET retries = ?, not_before = ?, last_error = ?, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
|
||||
topic, board, card_id))
|
||||
await db.commit()
|
||||
return dead
|
||||
|
||||
|
||||
async def kanban_dead(topic: str) -> list[dict]:
|
||||
"""Dead-letter cards across boards (for the board UI + requeue)."""
|
||||
return await kanban_cards(topic, stage="dead")
|
||||
|
||||
|
||||
async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
|
||||
"""dead → `stage` (fresh retries). → number of requeued cards."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND stage = 'dead'""",
|
||||
(stage, _now(), topic, board))
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
async def kanban_stage_counts(topic: str) -> dict[str, dict[str, int]]:
|
||||
"""{board: {stage: count}} — the live board."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT board, stage, count(*) FROM kanban_cards WHERE topic = ? GROUP BY board, stage", (topic,))
|
||||
out: dict[str, dict[str, int]] = {}
|
||||
for board, stage, n in await cursor.fetchall():
|
||||
out.setdefault(board, {})[stage] = n
|
||||
return out
|
||||
|
||||
|
||||
async def kanban_stage_cards(topic: str, board: str, stage: str, limit: int = 20) -> list[dict]:
|
||||
"""Newest `limit` cards of one column (for the live card display)."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ?
|
||||
ORDER BY updated_at DESC LIMIT ?""",
|
||||
(topic, board, stage, limit))
|
||||
return [_card(row, cursor) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def kanban_delete_cards(topic: str, board: str, kind: str | None = None) -> None:
|
||||
"""Delete derived cards (board reset) — kind=None wipes the whole board."""
|
||||
db = await get_db()
|
||||
if kind:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ? AND kind = ?",
|
||||
(topic, board, kind))
|
||||
else:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_reset(topic: str, board: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
if board:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ? AND board = ?", (topic, board))
|
||||
if board == "inventory":
|
||||
await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM kanban_cards WHERE topic = ?", (topic,))
|
||||
await db.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_set_members(topic: str, group_id: str, members: list[str]) -> None:
|
||||
"""Replace the member set of a cluster (one member belongs to exactly one cluster)."""
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id))
|
||||
await db.executemany(
|
||||
"""INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""",
|
||||
[(topic, m, group_id) for m in members])
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_members_of(topic: str, group_id: str) -> list[str]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT member_id FROM kanban_members WHERE topic = ? AND group_id = ?", (topic, group_id))
|
||||
return [r[0] for r in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def kanban_member_group(topic: str, member_id: str) -> str | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT group_id FROM kanban_members WHERE topic = ? AND member_id = ?", (topic, member_id))
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# ── Guide board (one card per block, linear stages) ──────────────────────────────
|
||||
async def upsert_guide_card(topic: str, format: str, block_norm: str, block: str,
|
||||
stage: str = "lernziele") -> None:
|
||||
"""Insert a card; an existing one keeps its stage/progress (resume)."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_cards (topic, format, block_norm, block, stage, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(topic, format, block_norm) DO UPDATE SET
|
||||
block = excluded.block, updated_at = excluded.updated_at""",
|
||||
(topic, format, block_norm, block, stage, _now()))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_guide_cards(topic: str, format: str) -> list[dict]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM guide_cards WHERE topic = ? AND format = ? ORDER BY ord, block_norm",
|
||||
(topic, format))
|
||||
return [_row_to_dict(row, cursor) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def set_guide_card(topic: str, format: str, block_norm: str, **fields) -> None:
|
||||
if not fields:
|
||||
return
|
||||
db = await get_db()
|
||||
cols = ", ".join(f"{k} = ?" for k in fields)
|
||||
await db.execute(
|
||||
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
|
||||
(*fields.values(), _now(), topic, format, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def guide_stage_counts(topic: str, format: str) -> dict[str, int]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT stage, count(*) FROM guide_cards WHERE topic = ? AND format = ? GROUP BY stage",
|
||||
(topic, format))
|
||||
return {stage: n for stage, n in await cursor.fetchall()}
|
||||
|
||||
|
||||
async def reset_guide_cards_from_stage(topic: str, format: str, stages: list[str],
|
||||
to_stage: str, clear_md: bool = False) -> int:
|
||||
"""Cards sitting in any of `stages` → back to `to_stage` (fresh rounds/gate info)."""
|
||||
if not stages:
|
||||
return 0
|
||||
db = await get_db()
|
||||
ph = ",".join("?" * len(stages))
|
||||
md = ", md = ''" if clear_md else ""
|
||||
cursor = await db.execute(
|
||||
f"""UPDATE guide_cards SET stage = ?, status = 'open', writer_rounds = 0,
|
||||
gate_info = ''{md}, updated_at = ?
|
||||
WHERE topic = ? AND format = ? AND stage IN ({ph})""",
|
||||
(to_stage, _now(), topic, format, *stages))
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
async def delete_guide_board(topic: str, format: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
if format:
|
||||
await db.execute("DELETE FROM guide_cards WHERE topic = ? AND format = ?", (topic, format))
|
||||
cursor = await db.execute("SELECT count(*) FROM guide_cards WHERE topic = ?", (topic,))
|
||||
if (await cursor.fetchone())[0] == 0:
|
||||
await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM guide_cards WHERE topic = ?", (topic,))
|
||||
await db.execute("DELETE FROM guide_lernziele WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def put_lernziel(topic: str, block_norm: str, ziel_id: str, text: str, sub_norm: str = "") -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO guide_lernziele (topic, block_norm, ziel_id, text, sub_norm, covered, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?)
|
||||
ON CONFLICT(topic, block_norm, ziel_id) DO UPDATE SET
|
||||
text = excluded.text, sub_norm = excluded.sub_norm, updated_at = excluded.updated_at""",
|
||||
(topic, block_norm, ziel_id, text, sub_norm, _now()))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_lernziele(topic: str, block_norm: str | None = None) -> list[dict]:
|
||||
db = await get_db()
|
||||
if block_norm is None:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM guide_lernziele WHERE topic = ? ORDER BY block_norm, ziel_id", (topic,))
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM guide_lernziele WHERE topic = ? AND block_norm = ? ORDER BY ziel_id",
|
||||
(topic, block_norm))
|
||||
return [_row_to_dict(row, cursor) for row in await cursor.fetchall()]
|
||||
|
||||
|
||||
async def set_ziel_covered(topic: str, block_norm: str, ziel_id: str, covered: bool) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"UPDATE guide_lernziele SET covered = ?, updated_at = ? WHERE topic = ? AND block_norm = ? AND ziel_id = ?",
|
||||
(1 if covered else 0, _now(), topic, block_norm, ziel_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def delete_lernziele(topic: str, block_norm: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM guide_lernziele WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_membership(topic: str) -> dict[str, str]:
|
||||
"""{member_id: group_id} for the whole topic (the cluster worker's working map)."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT member_id, group_id FROM kanban_members WHERE topic = ?", (topic,))
|
||||
return {m: g for m, g in await cursor.fetchall()}
|
||||
|
||||
|
||||
async def kanban_set_member(topic: str, member_id: str, group_id: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO kanban_members (topic, member_id, group_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(topic, member_id) DO UPDATE SET group_id = excluded.group_id""",
|
||||
(topic, member_id, group_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def kanban_add_title(topic: str, board: str, card_id: str, title: str,
|
||||
description: str, source: str, reader: str) -> bool:
|
||||
"""Ingest one research title (stage 'ingest'). An exact dupe folds instead of duplicating:
|
||||
reader-set union, source union, longer description wins; stage stays untouched (a title
|
||||
already consumed into a cluster is not re-queued). NOT concurrency-safe across awaits —
|
||||
callers serialize through one ingest lock. → True if the card is new."""
|
||||
row = await kanban_get_card(topic, board, card_id)
|
||||
if row is None:
|
||||
payload = {"title": title, "description": description,
|
||||
"sources": [source] if source else [], "readers": [reader]}
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO kanban_cards (topic, board, card_id, kind, stage, payload, updated_at)
|
||||
VALUES (?, ?, ?, 'title', 'ingest', ?, ?)""",
|
||||
(topic, board, card_id, json.dumps(payload, ensure_ascii=False), _now()))
|
||||
await db.commit()
|
||||
return True
|
||||
p = row["payload"]
|
||||
p["readers"] = list(dict.fromkeys((p.get("readers") or []) + [reader]))
|
||||
p["sources"] = list(dict.fromkeys((p.get("sources") or []) + ([source] if source else [])))
|
||||
if len(description or "") > len(p.get("description") or ""):
|
||||
p["description"] = description
|
||||
await kanban_set_payload(topic, board, card_id, p)
|
||||
return False
|
||||
|
||||
|
||||
async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
|
||||
Reference in New Issue
Block a user