Skip to content

Commit 64c1a67

Browse files
committed
動かした文書に、どの回が動かしたかを脇書きとして残す
変更履歴(app/collect_log.py)は回ごとに 1 行で、動いた見出しは頭の 20 件まで。 しかも回ごとの間隔は桁違いなので、短い回の行が長い回の行を押し流す —— 「その回が何を直したのか」は、たいてい流れた後にしか引きに来られない。 文書の側に印を押しておけば、いま手元にあるものについては回の間隔と関係なく読める。 置くのは脇書き。全文検索が索引するのは見出しと本文だけ、AI へ差し込む一覧に 載るのは配信日と出典だけなので、ここへ入れたものはどちらにも出てこない。 本文やタグへ書くと、読む人にも AI にも文書の中身として見える。 残すのは 1 回分だけで、後の回が同じ 1 件に触れば印はそちらへ移る。溜めると 1 件ごとに際限なく伸び、焼き直すたびに全件がそのぶん重くなる。いつ動いたかは updated_at が既に持っているので二重に置かない。 鍵の数の天井より後に押す。あの天井は AI が運ぶ事実が際限なく増えないための もので、こちらの印まで落とすと、脇書きの多い 1 件だけ印が付かない。 引くための索引は集める層のアダプタにだけ足す。式索引なので列を足さずに済み、 SCHEMA_VERSION は動かない —— 動かすとダンプ由来のソースの焼き直しまで要求する。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b943de3 commit 64c1a67

7 files changed

Lines changed: 375 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,21 @@ GeoNames 全世界地名辞典 = `geonames`(いずれも 348 言語版・195 か
669669
—— 1 時間ごとに見出しを溜める回が 24 行を占めれば、4 時間ごとに整理する回の
670670
差分は 1 日ぶんも残らない。**選択肢は控えの側から引く**(`collect_log.sweeps`)
671671
—— 定義の巡回から作ると、名前を変えた回や消した回の行が目の前に出ているのに選べない
672+
- **動かした 1 件には、どの回が動かしたかを脇書きに残す**(`collect._stamped` /
673+
`extra.changed_by` / `extra.change`)。**変更履歴だけでは読めない問いがある** ——
674+
控えは回ごとに 1 行で、動いた見出しは頭の 20 件までしか残らず、しかも短い回が
675+
長い回を押し流す。文書の側に押しておけば、**いま手元にあるものについては
676+
回の間隔と関係なく読める**(画面の「その回が動かしたもの」/ `collect.changed_here`)。
677+
**残すのは 1 回分だけ**(上書きする)—— 溜めると 1 件ごとに際限なく伸び、
678+
焼き直すたびに全件がそのぶん重くなる。「いつ」は `updated_at` が既に持っている。
679+
**置くのは脇書き** —— 全文検索が索引するのは見出しと本文だけ、AI へ差し込む
680+
一覧に載るのは配信日と出典だけなので、ここへ入れたものはどちらにも出てこない
681+
(本文やタグへ書くと、読む人にも AI にも「文書の中身」として見える)。
682+
**鍵の数の天井(`MAX_EXTRA_KEYS`)より後に押す** —— あの天井は AI が運ぶ事実が
683+
際限なく増えないためのもので、こちらの印まで落とすと、脇書きの多い 1 件だけ
684+
印が付かない(いちばん読みたい 1 件がそれになる)。**引くための索引は集める層だけ**
685+
(`ingest/core.py``CHANGED_BY_INDEX_DDL`)—— 式索引なので列を足さずに済み、
686+
`SCHEMA_VERSION` は動かない(動かすと jawiki の焼き直しまで要求することになる)
672687
- **直した件数は足した件数と別に控える**(`last_updated`)。整理の回は足すものが
673688
無くても大量に直っていることがあり、追加だけ見ていると「何もしなかった」ように読める
674689
(`material` は数えていたのに、定義へ書き戻すところで落としていた)

app/collect.py

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,40 @@ def recent(name: str, sources: dict, limit: int = 5) -> list[dict]:
21102110
]
21112111

21122112

2113+
def changed_here(name: str, sources: dict, sweep: str, limit: int = 100) -> list[dict]:
2114+
"""その回が最後に動かしたものを、新しい順に(`_stamped` の印で引く)。
2115+
2116+
**変更履歴(`app/collect_log.py`)では答えられない問いのほう。** あちらは回ごとに
2117+
1 行で、動いた見出しは頭の 20 件までしか残らず、しかも回ごとの間隔は桁違いなので
2118+
短い回の行が長い回の行を押し流す。ここは**いま手元にあるものを文書の側から**引くので、
2119+
その回が何回前に走っていようが読める。
2120+
2121+
**読めるのは最後に動かした回だけ。** 後の回が同じ 1 件に触れば印はそちらに移る
2122+
(印は 1 回分しか持たない)—— 「整理が直したあと見出しが触った」ものは
2123+
見出しのほうに出る。
2124+
"""
2125+
src = sources.get(name)
2126+
if src is None or not sweep or limit <= 0:
2127+
return []
2128+
rows = db.query(
2129+
src.path,
2130+
"SELECT title, opening, tags, updated_at, extra FROM docs"
2131+
f" WHERE json_extract(extra, '$.{CHANGED_BY_KEY}') = ?"
2132+
" ORDER BY updated_at DESC LIMIT ?",
2133+
(sweep, limit),
2134+
)
2135+
return [
2136+
{
2137+
"title": row["title"],
2138+
"opening": row["opening"],
2139+
"tags": load_tags(row["tags"]),
2140+
"updated_at": row["updated_at"],
2141+
"extra": load_json(row["extra"]),
2142+
}
2143+
for row in rows
2144+
]
2145+
2146+
21132147
def doc_versions(name: str, sources: dict, title: str) -> dict:
21142148
"""1 件の見出しについて、**いまの世代と 1 つ前の世代**の中身を返す。
21152149
@@ -2375,6 +2409,7 @@ def stream_docs(
23752409
only_new: bool = False,
23762410
edits: bool = False,
23772411
diff: dict | None = None,
2412+
sweep: str = "",
23782413
):
23792414
"""焼く素材を 1 件ずつ返す。`material` の中身で、**丸ごとは持たない**。
23802415
@@ -2387,6 +2422,8 @@ def stream_docs(
23872422
同じ並びになる(新しいぶんは採番の順で後ろに付く)。
23882423
23892424
`diff` を渡すと、数えた結果をそこへ書く(戻り値にできないため)。
2425+
2426+
**動かした 1 件には、どの回が動かしたかを脇書きに残す**(`_stamped`)。
23902427
"""
23912428
counts = diff if diff is not None else {}
23922429
now = _iso(_now())
@@ -2419,7 +2456,7 @@ def stream_docs(
24192456
# 墓標。**消さずに印を付けて残す**
24202457
removed_titles.append(title)
24212458
updated += 1
2422-
yield _buried(before, raw, now)
2459+
yield _stamped(_buried(before, raw, now), sweep, "removed")
24232460
continue
24242461
doc = _to_doc(raw, now, item.web)
24252462
if doc is None:
@@ -2436,7 +2473,9 @@ def stream_docs(
24362473
else:
24372474
# 集めるほうで同じ見出しが来るのは「もう持っている」の意味
24382475
skipped += 1
2439-
yield {**doc, "doc_id": before["doc_id"]}
2476+
yield {**doc, "doc_id": before["doc_id"]}
2477+
continue
2478+
yield _stamped({**doc, "doc_id": before["doc_id"]}, sweep, "updated")
24402479

24412480
next_id += 1
24422481
for raw in edits_of.rest():
@@ -2455,7 +2494,7 @@ def stream_docs(
24552494
doc["extra"] = _merge_extra({}, doc["extra"])
24562495
added += 1
24572496
added_titles.append(doc["title"])
2458-
yield {**doc, "doc_id": next_id}
2497+
yield _stamped({**doc, "doc_id": next_id}, sweep, "added")
24592498
next_id += 1
24602499

24612500
counts.update({
@@ -2479,6 +2518,7 @@ def material(
24792518
collected: list[dict],
24802519
only_new: bool = False,
24812520
edits: bool = False,
2521+
sweep: str = "",
24822522
) -> tuple[list[dict], dict]:
24832523
"""焼く素材と、前世代との差分を組み立てる。
24842524
@@ -2514,7 +2554,7 @@ def material(
25142554
"""
25152555
counts: dict = {}
25162556
rows = sorted(previous.values(), key=lambda d: d["doc_id"])
2517-
docs = list(stream_docs(item, rows, collected, only_new, edits, counts))
2557+
docs = list(stream_docs(item, rows, collected, only_new, edits, counts, sweep))
25182558
return docs, counts
25192559

25202560

@@ -2618,6 +2658,50 @@ def _buried(doc: dict, raw: dict, now: str) -> dict:
26182658
}
26192659

26202660

2661+
CHANGE_ADDED = "added"
2662+
CHANGE_UPDATED = "updated"
2663+
CHANGE_REMOVED = "removed"
2664+
2665+
# 脇書きに残す「最後に動かした回」の鍵。
2666+
CHANGED_BY_KEY = "changed_by"
2667+
CHANGE_KEY = "change"
2668+
2669+
2670+
def _stamped(doc: dict, sweep: str, change: str) -> dict:
2671+
"""動かした 1 件に、**どの回が・どう動かしたか**を脇書きとして押す。
2672+
2673+
**変更履歴を別に持つだけでは、読みたいほうが読めない。** 控え
2674+
(`app/collect_log.py`)は回ごとに 1 行で、動いた見出しは頭の 20 件しか
2675+
残らない。しかも回ごとの間隔は桁違いなので、短い回の行が長い回の行を押し流す
2676+
—— 「整理が何を直したのか」を引きに来ると、たいてい流れた後になる。
2677+
文書の側に押しておけば、**いま手元にあるものについては回の間隔と関係なく読める**。
2678+
2679+
**残すのは 1 回分だけ**(上書きする)。履歴を溜める場所ではない ——
2680+
溜めると 1 件ごとに際限なく伸び、焼き直すたびに全件がその分だけ重くなる。
2681+
「いつ」は既にある `updated_at` 列が持っているので、ここには持たない。
2682+
2683+
**脇書きに置くのは、検索と本文精査の邪魔をしないため。** 全文検索が索引するのは
2684+
見出しと本文だけ(`ingest/core.py` の `docs_fts`)、AI へ差し込む一覧に載るのも
2685+
配信日と出典だけ(`_current_line`)なので、ここへ入れたものはどちらにも出てこない。
2686+
本文やタグへ書くと、読む人にも AI にも「文書の中身」として見える。
2687+
2688+
**鍵の数の天井(`MAX_EXTRA_KEYS`)より後に押す。** あの天井は AI が運んでくる
2689+
事実が際限なく増えないためのもので、こちらが押す印まで落とすと、
2690+
脇書きの多い 1 件だけ印が付かないことになる(いちばん読みたい 1 件がそれになる)。
2691+
"""
2692+
extra = doc.get("extra") if isinstance(doc.get("extra"), dict) else {}
2693+
stamp = {CHANGE_KEY: change}
2694+
if sweep:
2695+
stamp[CHANGED_BY_KEY] = sweep
2696+
return {**doc, "extra": {**extra, **stamp}}
2697+
2698+
2699+
def changed_by(doc: dict) -> str:
2700+
"""その 1 件を最後に動かした回。持っていなければ空。"""
2701+
extra = doc.get("extra")
2702+
return str((extra or {}).get(CHANGED_BY_KEY) or "") if isinstance(extra, dict) else ""
2703+
2704+
26212705
def removed_reason(doc: dict) -> str:
26222706
"""その 1 件を消した理由。持っていなければ空。"""
26232707
extra = doc.get("extra")
@@ -2912,8 +2996,12 @@ def _count_dropped(item, plan, previous, collected, only_new, edits) -> int:
29122996

29132997

29142998
def bake_lines(item, sources: dict, previous, collected, only_new=False, edits=False,
2915-
survey: dict | None = None):
2916-
"""焼く素材を 1 行ずつ返す。**2 周目**(数えるのは `bake_survey`)。"""
2999+
survey: dict | None = None, sweep: str = ""):
3000+
"""焼く素材を 1 行ずつ返す。**2 周目**(数えるのは `bake_survey`)。
3001+
3002+
**どの回が焼いたかはここで渡す。** 押すのは実際に焼かれる素材のほうで、
3003+
数える 1 周目ではない(あちらは件数しか使わない)。
3004+
"""
29173005
plan = survey or bake_survey(item, sources, previous, collected, only_new, edits)
29183006
rows = _rows_of(previous)
29193007
limit = _expiry_limit(item)
@@ -2926,7 +3014,7 @@ def bake_lines(item, sources: dict, previous, collected, only_new=False, edits=F
29263014
}
29273015
}, ensure_ascii=False)
29283016

2929-
for doc in stream_docs(item, rows(), collected, only_new, edits):
3017+
for doc in stream_docs(item, rows(), collected, only_new, edits, None, sweep):
29303018
if limit is not None and _doc_time(doc) < limit:
29313019
continue
29323020
for n, rule in enumerate(plan["rules"]):

app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ def flow():
650650
try:
651651
yield from collect.bake_lines(
652652
baked_as, sources, previous, items,
653-
focus is None and sweep.only_new, edits, plan,
653+
focus is None and sweep.only_new, edits, plan, label,
654654
)
655655
finally:
656656
if hasattr(items, "close"):

app/views/admin.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,10 @@ def _sweep_fields(sweep, removable: bool, shared_prompt: str = "") -> str:
672672
# 設定の行はこれを全部つないで 1 つのセルにする
673673
SWEEP_COLUMNS = 7
674674

675+
# 「その回が動かしたもの」に出す件数。**全部は出さない** —— 1 回で数千件動く回が
676+
# あるので、天井を置かないと画面が開かなくなる。読みたいのは新しいほうから。
677+
CHANGED_HERE_LIMIT = 200
678+
675679

676680
def _sweep_backend_fields(sweep, backend, mechanical: bool) -> str:
677681
"""頼む相手の欄。**機械で引く巡回には出さない**。
@@ -1020,6 +1024,63 @@ def _collect_running_html(name: str | None = None) -> str:
10201024
)
10211025

10221026

1027+
CHANGE_LABELS = {
1028+
collect.CHANGE_ADDED: "足した",
1029+
collect.CHANGE_UPDATED: "直した",
1030+
collect.CHANGE_REMOVED: "消した",
1031+
}
1032+
1033+
1034+
def _changed_here_html(name: str, sources: dict, sweep: str | None) -> str:
1035+
"""その回が動かしたもの(`collect.changed_here`)。**回を選んだときだけ出す**。
1036+
1037+
**「直近の変更」では届かないところを埋める。** あちらは回ごとに 1 行で、
1038+
動いた見出しは頭の 20 件までしか残らない —— 1 回で数千件動く回では、
1039+
何が動いたのかがほとんど読めない。ここは焼いたものを文書の側から引くので、
1040+
その回が何回前に走っていようが、動かした全部が出る。
1041+
1042+
**出るのは「最後に動かした回」だけ。** 後の回が同じ 1 件に触れば、その 1 件は
1043+
そちらの一覧へ移る —— 印を 1 回分しか持たないため。書いておかないと、
1044+
整理が直したはずのものが見当たらない理由が読めない。
1045+
"""
1046+
if not sweep:
1047+
return ""
1048+
docs = collect.changed_here(name, sources, sweep, limit=CHANGED_HERE_LIMIT)
1049+
head = f"<summary>「{esc(sweep)}」が動かしたもの</summary>"
1050+
if not docs:
1051+
return (
1052+
f'<details id="changed">{head}'
1053+
'<p class="muted">いま手元にあるもののうち、この回が最後に動かしたものは'
1054+
"ありません。</p></details>"
1055+
)
1056+
rows = []
1057+
for doc in docs:
1058+
at = jst.parse(doc["updated_at"] or "")
1059+
change = (doc.get("extra") or {}).get(collect.CHANGE_KEY) or ""
1060+
mark = CHANGE_LABELS.get(str(change), "")
1061+
rows.append(
1062+
f"<tr><td>{esc(jst.format(at)) if at else ''}</td>"
1063+
f'<td>{esc(mark) if mark else "<span class=\"muted\">—</span>"}</td>'
1064+
f"<td>{_doc_link(name, doc['title'])}"
1065+
f'<br><span class="muted">{esc((doc["opening"] or "")[:120])}</span></td></tr>'
1066+
)
1067+
more = (
1068+
f'<p class="muted">新しい順に {CHANGED_HERE_LIMIT} 件まで。</p>'
1069+
if len(docs) >= CHANGED_HERE_LIMIT else ""
1070+
)
1071+
return f"""
1072+
<details id="changed" open>{head}
1073+
<table>
1074+
<thead><tr><th>いつ</th><th>何を</th><th>見出し</th></tr></thead>
1075+
<tbody>{"".join(rows)}</tbody>
1076+
</table>
1077+
{more}
1078+
<p class="muted">印は 1 件につき 1 回分だけなので、<strong>後の回が同じ見出しに触れば
1079+
そちらへ移る</strong>。消えたものもここに出る(読み口からは返らないが、手元には残っている)。</p>
1080+
</details>
1081+
"""
1082+
1083+
10231084
def _changes_filter_html(name: str | None, sweep: str | None) -> str:
10241085
"""「どの回を見るか」の切り替え。**回が 1 本しかなければ出さない**(選ぶ先が無い)。
10251086
@@ -2924,6 +2985,7 @@ def admin_collect_detail(
29242985
{_collect_detail_html(item, disabled, request.app.state.sources)}
29252986
{_collect_running_html(name)}
29262987
{_collect_changes_html(name=name, sweep=sweep)}
2988+
{_changed_here_html(name, request.app.state.sources, sweep)}
29272989
<p class="muted"><a href="/admin/memory#collect">集める の一覧へ戻る</a></p>
29282990
"""
29292991
return HTMLResponse(content=page_shell(name, body))

ingest/core.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,18 @@
135135
CREATE INDEX idx_docs_updated ON docs(updated_at DESC, doc_id DESC);
136136
"""
137137

138+
# 「どの回が最後に動かしたか」で引くための索引(`app/collect.py` の `_stamped`)。
139+
# **これもコアには入れない** —— 押しているのは集める層だけで、ダンプ由来のソースの
140+
# 脇書きにこの鍵は無い。式索引なので列を足さずに済み、`SCHEMA_VERSION` は動かない。
141+
#
142+
# updated_at まで入れているのは、並び順まで索引で満たすため。無いと「その回が
143+
# 動かしたもの」を全部読んでから並べ替えることになり、**回の間隔が短い収集ほど
144+
# 重くなる**(1 回で数千件動く網羅の収集がそれに当たる)。
145+
CHANGED_BY_INDEX_DDL = """
146+
CREATE INDEX idx_docs_changed_by
147+
ON docs(json_extract(extra, '$.changed_by'), updated_at DESC);
148+
"""
149+
138150
# docs 投入後に doc_tags を組み立てる SQL(索引を張る前に流す)。
139151
# docs から作り直す方式にしているのは、docs 側が INSERT OR REPLACE を使う(同じ doc_id が
140152
# 二度来ても最後の 1 件が残る)ため。行ごとに append すると置き換えられた古いタグが残る。

ingest/sources/collect.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import os
2525
from pathlib import Path
2626

27-
from core import RECENCY_INDEX_DDL, SourceAdapter
27+
from core import CHANGED_BY_INDEX_DDL, RECENCY_INDEX_DDL, SourceAdapter
2828
from sources.remote import PluginError, RemotePluginAdapter, RemoteSource, _get_json
2929

3030
log = logging.getLogger("chiezo.ingest")
@@ -54,8 +54,11 @@ class CollectAdapter(RemotePluginAdapter):
5454
"""
5555

5656
# 集めたものは**溜まっていく**ので、新しい順に引けるようにする。
57-
# 「この 1 日で何が入ったか」は、この層でいちばん訊かれる問い
58-
extra_index_ddl = RECENCY_INDEX_DDL
57+
# 「この 1 日で何が入ったか」は、この層でいちばん訊かれる問い。
58+
# **どの回が動かしたか**でも引く —— 変更履歴(`app/collect_log.py`)は回ごとに
59+
# 1 行で、動いた見出しは頭の 20 件しか残らないうえ、短い回の行が長い回の行を
60+
# 押し流す。文書の側の印なら、回の間隔と関係なく引ける
61+
extra_index_ddl = RECENCY_INDEX_DDL + CHANGED_BY_INDEX_DDL
5962

6063
def __init__(self, src: RemoteSource) -> None:
6164
super().__init__(src)

0 commit comments

Comments
 (0)