Skip to content

Commit 23ee1e4

Browse files
committed
fix(#213): name conversions by the original's full filename to end stem collisions
ingest derived the conversion path from the original's stem (src.stem + out_suffix), so same-folder/same-stem/different-extension originals (report.hwpx, report.pptx) collided on one runs/sources/report.md: the first converted won, the second was skipped as 'up to date', and its content was silently lost while sources/coverage mispaired both originals to the one file. Name each conversion by the original's *full* filename plus the out-suffix (report.hwpx -> runs/sources/report.hwpx.md, report.pptx -> runs/sources/report.pptx.md), and key source_rel_key() to match: an original keeps its extension, a conversion drops only its out-suffix, so each original pairs 1:1 with exactly its own conversion. Subdir mirroring is preserved (sources/sub/x.pdf -> runs/sources/sub/x.pdf.md). Cross-cutting mapping sites updated for the new key, all with a legacy stem-key fallback so pre-#213 conversions keep pairing until a KB is re-ingested: - factlog/common.py: source_rel_key() (full-name/last-suffix rule) + new source_stem_key() legacy fallback helper - cli.py cmd_ingest: dst = original full name + out_suffix - cli.py cmd_sources, cmd_status, tools/coverage.py: pair on full-name key, fall back to legacy stem key - cli.py eject matches()/--orphans: provenance header stays the primary signal; stem/relkey fallbacks made naming-consistent and a legacy stem-key set added to orphan pairing so legacy mirrored conversions are not false-flagged (preserves #103 cross-subtree orphan detection) Tests: new #213 stem-collision case in test_ingest.sh (both preserved, correct content, idempotent re-run, sources pairing); updated conversion output names in test_ingest/pptx/hwpx and unit test_source_paths for the new key semantics. Docs (README, SKILL.md) updated with the new naming + migration note.
1 parent deb6149 commit 23ee1e4

9 files changed

Lines changed: 229 additions & 61 deletions

File tree

README.md

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -251,23 +251,29 @@ python3 -m factlog init --target ~/wiki # scaffold the KB layout
251251

252252
`factlog ingest` 는 변환된 텍스트를 KB의 **`runs/sources/`** 디렉터리(다른 생성
253253
런 아티팩트와 같은 위치)에 기록합니다 — 사용자의 원본이 그대로 남아 있어야 하는
254-
**`sources/` 에는 결코 쓰지 않습니다**. 하위 디렉터리에 있는 원본은 그 하위
255-
구조를 그대로 미러링하므로(`sources/sub/report.pdf``runs/sources/sub/report.md`),
256-
서로 다른 폴더의 동일 이름 파일이 충돌하지 않습니다. 원본은 손대지 않으며,
257-
변환본에는 출처(provenance) 헤더(소스, 변환기, 날짜)가 붙습니다. `sources/`
258-
`runs/sources/` 모두 추출이 읽는 유효한 소스 루트입니다.
259-
260-
> **업그레이드 안내:** 하위 디렉터리 미러링은 기존의 평면(flat) 레이아웃보다
261-
> 나중에 도입되었습니다. 이전에 적재된 KB는 중첩 원본에 대해 평면 변환본
262-
> (`runs/sources/report.md`)을 가지고 있는데, 이는 더 이상 짝이 맞지 않으므로
263-
> 중첩 바이너리가 커버리지/`factlog sources` 누락으로 다시 나타날 수 있습니다.
264-
> `factlog ingest --scan --force` 를 다시 실행해 변환본을 미러링된 경로로
265-
> 옮기십시오(이후 남은 평면 변환본은 삭제). 최상위(비중첩) 소스는 영향받지 않습니다.
254+
**`sources/` 에는 결코 쓰지 않습니다**. 변환본 파일명은 **원본의 전체 파일명(확장자
255+
포함) + 변환 확장자**로 만들어지므로(`report.hwpx``runs/sources/report.hwpx.md`,
256+
`report.pptx``runs/sources/report.pptx.md`), 같은 폴더에 이름이 같고 확장자만 다른
257+
두 원본이 하나의 변환본으로 **충돌해 유실되는 일이 없습니다**. 하위 디렉터리에 있는
258+
원본은 그 하위 구조를 그대로 미러링하므로(`sources/sub/report.pdf`
259+
`runs/sources/sub/report.pdf.md`), 서로 다른 폴더의 동일 이름 파일도 충돌하지 않습니다.
260+
원본은 손대지 않으며, 변환본에는 출처(provenance) 헤더(소스, 변환기, 날짜)가 붙습니다.
261+
`sources/``runs/sources/` 모두 추출이 읽는 유효한 소스 루트입니다.
262+
263+
> **업그레이드 안내(#213):** 변환본 파일명 규칙이 바뀌었습니다. 예전에는 원본의
264+
> **어간(stem)**만 써서 `report.pdf``runs/sources/report.md` 였지만, 이제는
265+
> 원본의 **전체 이름**을 써서 `runs/sources/report.pdf.md` 로 만듭니다. 이 덕분에
266+
> 같은 폴더의 `report.hwpx`·`report.pptx` 가 각각 별도 변환본으로 보존됩니다.
267+
> 기존에 적재된 KB의 구(舊) 어간 변환본(`runs/sources/report.md`)은 `factlog
268+
> sources`/`coverage`/`status` 가 **어간 기반 폴백으로 계속 원본과 짝지어** 인식하므로
269+
> 조용히 유실되지 않습니다. 새 레이아웃으로 옮기려면 `factlog ingest --scan --force`
270+
> 를 다시 실행하십시오(이후 남은 구 변환본은 `factlog eject --orphans` 로 정리).
271+
> 특히 어간이 충돌하던 KB는 재적재해야 유실되었던 원본이 복원됩니다.
266272
267273
*터미널에서 실행:*
268274

269275
```bash
270-
factlog ingest report.docx --target ~/wiki # → ~/wiki/runs/sources/report.md (pandoc)
276+
factlog ingest report.docx --target ~/wiki # → ~/wiki/runs/sources/report.docx.md (pandoc)
271277
factlog ingest --scan --target ~/wiki # auto-convert every binary under sources/
272278
```
273279

@@ -456,8 +462,9 @@ factlog ignore --remove drafts/*.md # remove a pattern
456462
`factlog eject <source>` 는 적재(ingest)를 되돌립니다. `runs/sources/` 변환본을
457463
삭제하고, 해당 소스에서 추출된 행을 `runs/*.json` 에서 제거하며, 그 소스를 인용하는
458464
사실을 폐기합니다. 소스는 파일명, 어간(stem), 또는 KB 기준 상대 경로로 지정할 수
459-
있습니다 — 바이너리 원본(예: `report.pdf`)을 지정하면 그 `runs/sources/<stem>`
460-
변환본도 함께 매칭되고, 어간만 주면 같은 어간을 가진 모든 소스가 매칭됩니다.
465+
있습니다 — 바이너리 원본(예: `report.pdf`)을 지정하면 그 `runs/sources/<원본이름>.md`
466+
변환본도 함께 매칭되고(변환본의 provenance 헤더로 짝을 확인), 어간만 주면 같은 어간을
467+
가진 모든 소스가 매칭됩니다.
461468

462469
```bash
463470
factlog eject report.pdf # delete conversion; mark citing facts superseded (kept for audit)

factlog/cli.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ def cmd_sources(args: argparse.Namespace) -> int:
597597
import unicodedata
598598
from pathlib import Path
599599

600-
from factlog.common import is_sync_ignored, source_rel_key, sync_ignore_patterns
600+
from factlog.common import is_sync_ignored, source_rel_key, source_stem_key, sync_ignore_patterns
601601

602602
def nfc(s: str) -> str:
603603
return unicodedata.normalize("NFC", s)
@@ -634,7 +634,10 @@ def nfc(s: str) -> str:
634634
if not p.is_file() or p.name.startswith("."):
635635
continue
636636
orig_ref = nfc(p.relative_to(target).as_posix())
637-
conv_ref = conv.get(source_rel_key(orig_ref), "")
637+
# Pair on the full-name key (#213); fall back to the legacy stem key so a
638+
# conversion made before #213 (named by the bare stem) still pairs until
639+
# the KB is re-ingested.
640+
conv_ref = conv.get(source_rel_key(orig_ref)) or conv.get(source_stem_key(orig_ref), "")
638641
fact_ref = conv_ref or orig_ref # facts attach to the conversion when present
639642
entries.append((counts.get(fact_ref, 0), orig_ref, conv_ref))
640643
listed.add(orig_ref)
@@ -1429,7 +1432,12 @@ def cmd_status(args: argparse.Namespace) -> int:
14291432
if ref not in cited
14301433
and ref.startswith("sources/")
14311434
and not common.is_text_source(p)
1432-
and common.source_rel_key(ref) in covered_keys
1435+
# Pair on the full-name key (#213); fall back to the legacy stem key so a
1436+
# pre-#213 conversion (named by the bare stem) still pairs pre-re-ingest.
1437+
and (
1438+
common.source_rel_key(ref) in covered_keys
1439+
or common.source_stem_key(ref) in covered_keys
1440+
)
14331441
)
14341442
covered = direct + via
14351443
total = len(refs)
@@ -1768,15 +1776,21 @@ def cmd_ingest(args: argparse.Namespace) -> int:
17681776

17691777
tool, out_suffix, build = chosen
17701778
# Mirror the original's subdirectory under runs/sources/ so a nested
1771-
# source (sources/sub/x.pdf) converts to runs/sources/sub/x.md — never a
1772-
# flat name that would collide with a same-stem file in another subdir.
1773-
# An explicitly-named path outside sources/ has no subtree to mirror, so
1774-
# it falls back to a flat output name.
1779+
# source (sources/sub/x.pdf) converts to runs/sources/sub/x.pdf.md —
1780+
# never a flat name that would collide with a same-name file in another
1781+
# subdir. An explicitly-named path outside sources/ has no subtree to
1782+
# mirror, so it falls back to a flat output name.
17751783
try:
17761784
rel_parent = src.resolve().relative_to(sources_dir).parent
17771785
except (ValueError, OSError):
17781786
rel_parent = Path()
1779-
dst = derived / rel_parent / (src.stem + out_suffix)
1787+
# Keep the original's *full* filename (extension included) and append the
1788+
# out-suffix, so same-stem/different-extension originals (report.hwpx,
1789+
# report.pptx) convert to distinct outputs (report.hwpx.md,
1790+
# report.pptx.md) instead of colliding on one file and silently dropping
1791+
# the loser (#213). source_rel_key() mirrors this to pair each original
1792+
# with exactly its own conversion.
1793+
dst = derived / rel_parent / (src.name + out_suffix)
17801794
dst.parent.mkdir(parents=True, exist_ok=True)
17811795
if dst.exists() and not args.force and dst.stat().st_mtime >= src.stat().st_mtime:
17821796
print(f"factlog ingest: {dst.relative_to(target).as_posix()} up to date; skipping {src.name}")
@@ -1919,8 +1933,18 @@ def matches(ref: str, name: str) -> bool:
19191933
if not is_conv:
19201934
return rp.name == np_.name # an original with that filename
19211935
origin = conv_origin.get(ref) # the conversion made from this original
1922-
return origin == np_.name if origin else rp.stem == np_.stem
1923-
return rp.stem == np_.stem # bare stem: every source with that stem
1936+
# Provenance is the reliable signal. A headerless conversion falls
1937+
# back to its own name minus the ingest out-suffix: since ingest now
1938+
# keeps the original's extension (report.pptx -> report.pptx.md), the
1939+
# conversion's rp.stem ("report.pptx") is the original's full name.
1940+
return origin == np_.name if origin else rp.stem == np_.name
1941+
# bare stem: every original with that stem, and a conversion made from
1942+
# one (matched via its recorded origin so the source's own extension in
1943+
# the new naming — report.pptx.md — does not defeat the stem compare).
1944+
if is_conv:
1945+
origin = conv_origin.get(ref)
1946+
return Path(origin if origin else rp.name).stem == np_.stem
1947+
return rp.stem == np_.stem
19241948

19251949
matched: set[str] = set()
19261950
if args.orphans:
@@ -1943,8 +1967,26 @@ def matches(ref: str, name: str) -> bool:
19431967
# original ingested without a subtree to mirror) has no subdir, so
19441968
# the provenance basename is the only origin signal; fall back to it
19451969
# and keep erring toward retention.
1970+
from pathlib import PurePosixPath
1971+
1972+
def _legacy_stem_key(r: str) -> str:
1973+
# An original keyed the pre-#213 way: root prefix stripped, suffix
1974+
# dropped (sources/a/report.pdf -> a/report). Pairs a legacy
1975+
# conversion (runs/sources/a/report.md, keyed 'a/report') that
1976+
# predates the new full-name naming with its still-present original.
1977+
for root in ("runs/sources/", "sources/"):
1978+
if r.startswith(root):
1979+
r = r[len(root):]
1980+
break
1981+
return PurePosixPath(r).with_suffix("").as_posix()
1982+
19461983
src_basenames = {Path(r).name for r in disk_refs if not r.startswith("runs/sources/")}
19471984
src_relkeys = {source_rel_key(r) for r in disk_refs if not r.startswith("runs/sources/")}
1985+
# Legacy conversions key by the original's stem, not its full name, so
1986+
# also pair a mirrored conversion against the legacy stem key. Same-stem
1987+
# originals in different subtrees stay distinct (subdir is in the key),
1988+
# so #103's cross-subtree orphan detection is preserved for both layouts.
1989+
src_stemkeys = {_legacy_stem_key(r) for r in disk_refs if not r.startswith("runs/sources/")}
19481990
for ref in all_refs:
19491991
if ref.startswith("runs/sources/"):
19501992
if ref in disk_refs:
@@ -1953,7 +1995,10 @@ def matches(ref: str, name: str) -> bool:
19531995
# (hand-placed conversions are kept).
19541996
if origin is not None:
19551997
ck = source_rel_key(ref)
1956-
paired = ck in src_relkeys if "/" in ck else origin in src_basenames
1998+
if "/" in ck:
1999+
paired = ck in src_relkeys or ck in src_stemkeys
2000+
else:
2001+
paired = origin in src_basenames
19572002
if not paired:
19582003
matched.add(ref) # the original it was made from is gone
19592004
else:

factlog/common.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -217,15 +217,54 @@ def read_csv(path: Path) -> list[dict[str, str]]:
217217

218218

219219
def source_rel_key(ref: str) -> str:
220-
"""A source ref minus its source-root prefix and file suffix.
221-
222-
This is the key that pairs a binary original with its runs/sources/<rel>
223-
conversion now that `factlog ingest` mirrors the original's subdirectory:
224-
'sources/a/report.pdf' -> 'a/report'
225-
'runs/sources/a/report.md' -> 'a/report' (pairs with the line above)
226-
'sources/report.pdf' -> 'report' (top-level: unchanged)
227-
Subdirectory-aware, so same-stem files in different subtrees no longer
228-
collide. NFC-normalised. (Uses PurePosixPath since refs are posix-style.)
220+
"""The key that pairs a binary original with its runs/sources/ conversion.
221+
222+
`factlog ingest` names a conversion by appending the converter's out-suffix
223+
to the original's *full* filename (extension included) and mirrors the
224+
original's subdirectory, so same-stem/different-extension originals no longer
225+
collide on one output file (#213). The pairing key therefore keeps the
226+
original's extension and drops only the conversion's final (out-)suffix:
227+
'sources/a/report.hwpx' -> 'a/report.hwpx'
228+
'runs/sources/a/report.hwpx.md' -> 'a/report.hwpx' (pairs with above)
229+
'sources/report.pptx' -> 'report.pptx'
230+
'runs/sources/report.pptx.md' -> 'report.pptx' (pairs with above)
231+
An original under sources/ keeps its full name; a conversion under
232+
runs/sources/ drops one suffix. Subdirectory-aware, so same-name files in
233+
different subtrees never collide. NFC-normalised. (PurePosixPath: refs are
234+
posix-style.)
235+
236+
Backward compatibility: a legacy conversion made before #213 is named by the
237+
bare stem (`runs/sources/report.md` from `report.pdf`), so its key is the
238+
stem (`report`) and no longer equals the new full-name original key
239+
(`report.pdf`). Such conversions pair through their provenance header where
240+
that signal exists (eject/orphan); otherwise re-run `factlog ingest --force`
241+
to migrate them to the new layout. See the migration note in the #213 PR.
242+
"""
243+
ref = unicodedata.normalize("NFC", ref)
244+
is_conversion = False
245+
for rootname in SOURCE_ROOTS:
246+
prefix = rootname + "/"
247+
if ref.startswith(prefix):
248+
is_conversion = rootname == "runs/sources"
249+
ref = ref[len(prefix):]
250+
break
251+
p = PurePosixPath(ref)
252+
# Conversion: drop the out-suffix (.md/.txt) added by ingest, keeping the
253+
# original's own extension. Original: keep the full name so its extension is
254+
# part of the key and can't be confused with a same-stem sibling.
255+
return (p.with_suffix("") if is_conversion else p).as_posix()
256+
257+
258+
def source_stem_key(ref: str) -> str:
259+
"""The pre-#213 pairing key: source-root prefix stripped, one suffix dropped.
260+
261+
'sources/a/report.pdf' -> 'a/report'
262+
'runs/sources/a/report.md' -> 'a/report' (legacy naming)
263+
264+
Used only as a *fallback* to keep a legacy conversion (named by the bare
265+
stem, before #213 kept the original's extension) pairing with its original.
266+
A fresh/re-ingested KB matches on source_rel_key() and never needs this.
267+
Subdirectory-aware; NFC-normalised. See the #213 migration note.
229268
"""
230269
ref = unicodedata.normalize("NFC", ref)
231270
for rootname in SOURCE_ROOTS:

skills/factlog/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ produced by `factlog ingest`).
4343
Examples:
4444
- `"sources/my-doc.md"`
4545
- `"sources/subdir/notes.md#section-heading"`
46-
- `"runs/sources/report.md"` (a converted `.docx`/`.pdf` original)
46+
- `"runs/sources/report.pdf.md"` (a converted `.docx`/`.pdf` original — the conversion keeps the original's full name, extension included, so same-stem originals never collide; #213)
4747

4848
Bare filenames (e.g. `"my-doc.md"`) are NOT valid and will be silently dropped
4949
by `merge_candidates.py`. Always include the `sources/` or `runs/sources/` prefix.
@@ -216,8 +216,9 @@ first:
216216

217217
`--scan` auto-discovers every binary file under `sources/` and writes a text
218218
conversion (with a provenance header) into `runs/sources/` — never into
219-
`sources/`, mirroring the original's subdirectory (`sources/sub/x.pdf`
220-
`runs/sources/sub/x.md`). It is idempotent (unchanged files are skipped).
219+
`sources/`, mirroring the original's subdirectory and keeping the original's
220+
full name so same-stem originals never collide (`sources/sub/x.pdf`
221+
`runs/sources/sub/x.pdf.md`; #213). It is idempotent (unchanged files are skipped).
221222
Sources matching `policy/sync-ignore.md` are skipped. Then extract from **both**
222223
`sources/` (native text) and `runs/sources/` (conversions).
223224

@@ -439,7 +440,7 @@ it is correctly reported as a gap, not "covered":
439440
`/factlog sync` (or investigate why nothing was extracted).
440441
- **binary gap** — a binary source under `sources/` with **no conversion** at
441442
all: it needs conversion first via `factlog ingest`. A binary that already has
442-
a `runs/sources/<stem>` conversion is **not** a gap — facts attach to the
443+
a `runs/sources/<original-name>.md` conversion is **not** a gap — facts attach to the
443444
conversion, so the original is reported as *covered via conversion* (it counts
444445
toward "covered", with a `(N via conversion)` note in the summary). A binary
445446
under `runs/sources/` is instead flagged as an anomaly (that directory holds

tests/test_hwpx_ingest.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ PY
5656
# --- explicit ingest ----------------------------------------------------------
5757
make_hwpx "$KB/sources/sample.hwpx"
5858
set +e; out="$("$PYTHON" -m factlog ingest --target "$KB" "$KB/sources/sample.hwpx" 2>&1)"; rc=$?; set -e
59-
md="$KB/runs/sources/sample.md"
60-
[ "$rc" -eq 0 ] && [ -f "$md" ] && ok "hwpx ingests to runs/sources/<stem>.md (rc 0)" || bad "hwpx ingest failed (rc=$rc)"
59+
md="$KB/runs/sources/sample.hwpx.md" # #213: conversion keeps the original's full name
60+
[ "$rc" -eq 0 ] && [ -f "$md" ] && ok "hwpx ingests to runs/sources/<name>.md (rc 0)" || bad "hwpx ingest failed (rc=$rc)"
6161
head -1 "$md" | grep -qF "ingested-by-factlog" && head -1 "$md" | grep -qF "factlog-hwpx" && ok "provenance header written" || bad "provenance header missing"
6262
grep -qx "첫째 문단" "$md" && ok "multiple <hp:t> runs join into one line" || bad "runs not joined"
6363
grep -qx "태그제거" "$md" && ok "inline tags stripped" || bad "inline tag not stripped"
@@ -70,7 +70,7 @@ grep -qx "둘째 섹션" "$md" && ok "text from a second section included" || ba
7070
rm -f "$md"
7171
make_hwpx "$KB/sources/scanned.hwpx"
7272
set +e; "$PYTHON" -m factlog ingest --target "$KB" --scan >/dev/null 2>&1; rc=$?; set -e
73-
[ "$rc" -eq 0 ] && [ -f "$KB/runs/sources/scanned.md" ] && ok "--scan auto-discovers and converts hwpx" || bad "--scan did not convert hwpx (rc=$rc)"
73+
[ "$rc" -eq 0 ] && [ -f "$KB/runs/sources/scanned.hwpx.md" ] && ok "--scan auto-discovers and converts hwpx" || bad "--scan did not convert hwpx (rc=$rc)"
7474

7575
# --- corrupt hwpx: explicit fails, --scan reports but does not fail ------------
7676
printf 'not a zip file' > "$KB/sources/broken.hwpx"

0 commit comments

Comments
 (0)