file-observer 1.2.2__tar.gz → 1.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {file_observer-1.2.2/src/file_observer.egg-info → file_observer-1.2.4}/PKG-INFO +1 -1
  2. {file_observer-1.2.2 → file_observer-1.2.4}/pyproject.toml +1 -1
  3. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer/scanner.py +35 -19
  4. {file_observer-1.2.2 → file_observer-1.2.4/src/file_observer.egg-info}/PKG-INFO +1 -1
  5. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer.egg-info/SOURCES.txt +3 -1
  6. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_packaging.py +3 -3
  7. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_unit.py +6 -6
  8. file_observer-1.2.4/tests/test_v1_2_3.py +41 -0
  9. file_observer-1.2.4/tests/test_v1_2_4.py +56 -0
  10. {file_observer-1.2.2 → file_observer-1.2.4}/LICENSE +0 -0
  11. {file_observer-1.2.2 → file_observer-1.2.4}/LICENSE-AGPL +0 -0
  12. {file_observer-1.2.2 → file_observer-1.2.4}/docs/README.md +0 -0
  13. {file_observer-1.2.2 → file_observer-1.2.4}/setup.cfg +0 -0
  14. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer/__init__.py +0 -0
  15. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer.egg-info/dependency_links.txt +0 -0
  16. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer.egg-info/entry_points.txt +0 -0
  17. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer.egg-info/requires.txt +0 -0
  18. {file_observer-1.2.2 → file_observer-1.2.4}/src/file_observer.egg-info/top_level.txt +0 -0
  19. {file_observer-1.2.2 → file_observer-1.2.4}/src/scanner/__init__.py +0 -0
  20. {file_observer-1.2.2 → file_observer-1.2.4}/src/scanner/scanner.py +0 -0
  21. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_edge_cases.py +0 -0
  22. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_golden.py +0 -0
  23. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_integration.py +0 -0
  24. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_v1_1.py +0 -0
  25. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_v1_2.py +0 -0
  26. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_v1_2_1.py +0 -0
  27. {file_observer-1.2.2 → file_observer-1.2.4}/tests/test_v1_2_2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: file-observer
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Not a file watcher — a one-shot, read-only scan that emits a deterministic JSON manifest of what's in a directory before you ingest it.
5
5
  Author-email: Russell Pfister <russalo@russalo.com>
6
6
  License: AGPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "file-observer"
7
- version = "1.2.2"
7
+ version = "1.2.4"
8
8
  description = "Not a file watcher — a one-shot, read-only scan that emits a deterministic JSON manifest of what's in a directory before you ingest it."
9
9
  readme = "docs/README.md"
10
10
  requires-python = ">=3.12"
@@ -71,8 +71,8 @@ except ImportError:
71
71
  _defusedxml_available = False
72
72
 
73
73
 
74
- SCANNER_VERSION = "1.2.2"
75
- LOGIC_VERSION = "1.1.2"
74
+ SCANNER_VERSION = "1.2.4"
75
+ LOGIC_VERSION = "1.1.4"
76
76
  SCHEMA_VERSION = "1.2"
77
77
 
78
78
 
@@ -546,21 +546,21 @@ CHATLOG_TOOL = "chatlog_signals"
546
546
  # any detection regex or extraction algorithm requires bumping METHOD_VERSION
547
547
  # and updating the rules definition string.
548
548
  CHATLOG_VECTOR_ID = "chatlog"
549
- CHATLOG_METHOD_VERSION = 6 # v1.2.2: prose Rule 1 requires a recurring speaker; expanded stop-list (FP fixes)
549
+ CHATLOG_METHOD_VERSION = 8 # v1.2.4: case-insensitive stop-list; embedded-dialogue parity with prose Rule 1
550
550
  CHATLOG_RULES_DEFINITION = (
551
- "detect:speaker_label_re(distinct>=2,total>=3,recurring>=1,stop_list),"
551
+ "detect:speaker_label_re(distinct>=2,total>=3,recurring>=1,stop_list_ci),"
552
552
  "h3_header_re(5+)|section_divider_re(3+)[require speaker_cosignal_distinct>=2],"
553
553
  "json_conversation(role_keys{type,role,from,speaker,author}+content_keys{text,value,content,message,body},"
554
- "line/array/tree,embedded_speaker_labels,require msgs>=3 AND distinct_speakers>=2);"
555
- "extract:turn_count,speaker_labels(freq>=3,stop_list),section_markers,"
554
+ "line/array/tree,embedded_speaker_labels(distinct>=2,total>=3,recurring>=1),require msgs>=3 AND distinct_speakers>=2);"
555
+ "extract:turn_count,speaker_labels(freq>=3,stop_list_ci),section_markers,"
556
556
  "turn_char_stats,speaker_turn_counts,speaker_turn_chars,alternation,"
557
557
  "reference_tokens(at_mentions,wiki_links,code_fence_blocks,url_count),"
558
558
  "top_capitalized_tokens(freq>=3,top20),vocabulary_size_estimate;"
559
- "stop_list:Allow,Arguments,Authorization,Bcc,CAUTION,Cc,Command,Commands,"
560
- "Copyright,Date,Description,Disallow,Distribution,Documentation,Error,Example,"
561
- "Examples,FIXME,Format,From,IMPORTANT,License,Lines,Message,NOTE,Newsgroups,"
562
- "Note,Options,Organization,Parameters,Password,Path,References,Result,Returns,"
563
- "Sender,Subject,Summary,Synopsis,TIP,TODO,To,Usage,Version,Warning"
559
+ "stop_list_ci:allow,answer,arguments,authorization,bcc,caution,cc,command,commands,"
560
+ "copyright,date,description,disallow,distribution,documentation,error,example,examples,"
561
+ "fixme,format,from,important,license,lines,message,newsgroups,note,options,organization,"
562
+ "parameters,password,path,question,references,result,returns,sender,subject,summary,"
563
+ "synopsis,tip,to,todo,usage,version,warning"
564
564
  )
565
565
  CHATLOG_STATIC_TUNING = {
566
566
  "detection_threshold": 3,
@@ -624,7 +624,20 @@ CHATLOG_SPEAKER_STOP_LIST: set[str] = {
624
624
  "Version", "Usage", "Options", "Command", "Commands", "Format", "Synopsis",
625
625
  "Description", "Arguments", "Parameters", "Returns", "Summary",
626
626
  "Authorization", "Documentation", "License", "Copyright", "Password",
627
+ # v1.2.3: FAQ labels — `Question:`/`Answer:` recur like speakers but aren't
628
+ # (stopgap for the most common recurring-Key:value FP; the real fix is a
629
+ # non-count signal — see scratch/review/v1_2_2_fp_findings.md). Deliberately
630
+ # NOT adding single letters `Q`/`A`: `A:`/`B:` is legitimate anonymized dialogue.
631
+ "Question", "Answer",
627
632
  }
633
+ # v1.2.4: match case-INSENSITIVELY. The label regex matches all-caps, so the
634
+ # case-sensitive set left holes (ALL-CAPS `FROM:`/`SUBJECT:` header dumps still
635
+ # false-positived; only FAQ got dual-cased in v1.2.3). Folding once here closes
636
+ # every all-caps hole AND removes the dual-listing maintenance burden — `User`
637
+ # stays a legitimate speaker because it isn't in the set in any case.
638
+ CHATLOG_SPEAKER_STOP_LIST_CF: frozenset[str] = frozenset(
639
+ w.casefold() for w in CHATLOG_SPEAKER_STOP_LIST
640
+ )
628
641
 
629
642
  # v0.9: Reference tokens vector identity constants.
630
643
  REFERENCE_TOKENS_VECTOR_ID = "reference_tokens"
@@ -2544,7 +2557,7 @@ class Scanner:
2544
2557
  # sweep showed separates transcripts (recurring) from header blocks (one-shot).
2545
2558
  speaker_matches = [
2546
2559
  m.group(1) for m in CHATLOG_SPEAKER_LABEL_RE.finditer(text)
2547
- if m.group(1) not in CHATLOG_SPEAKER_STOP_LIST
2560
+ if m.group(1).casefold() not in CHATLOG_SPEAKER_STOP_LIST_CF
2548
2561
  ]
2549
2562
  speaker_counts = Counter(speaker_matches)
2550
2563
  if (len(speaker_counts) >= 2 and sum(speaker_counts.values()) >= 3
@@ -2571,14 +2584,17 @@ class Scanner:
2571
2584
  @staticmethod
2572
2585
  def _string_has_speaker_dialogue(s: Any) -> bool:
2573
2586
  """Dialogue embedded in a JSON string value (e.g. hh-rlhf's
2574
- '\\n\\nHuman: ...\\n\\nAssistant: ...'): 3+ prose speaker labels."""
2587
+ '\\n\\nHuman: ...\\n\\nAssistant: ...'). v1.2.4: applies the SAME predicate
2588
+ as prose Rule 1 — >=2 distinct speakers, >=3 total, >=1 recurring — so the
2589
+ same text isn't detected inside a JSON string yet rejected as prose."""
2575
2590
  if not isinstance(s, str) or len(s) < 20:
2576
2591
  return False
2577
- labels = [
2592
+ labels = Counter(
2578
2593
  m.group(1) for m in CHATLOG_SPEAKER_LABEL_RE.finditer(s)
2579
- if m.group(1) not in CHATLOG_SPEAKER_STOP_LIST
2580
- ]
2581
- return len(labels) >= 3
2594
+ if m.group(1).casefold() not in CHATLOG_SPEAKER_STOP_LIST_CF
2595
+ )
2596
+ return (len(labels) >= 2 and sum(labels.values()) >= 3
2597
+ and any(c >= 2 for c in labels.values()))
2582
2598
 
2583
2599
  def _detect_conversational_json(self, text: str) -> bool:
2584
2600
  """Generalized conversational JSON/JSONL detection (v1.2; tightened v1.2.1).
@@ -2687,7 +2703,7 @@ class Scanner:
2687
2703
  (e.g. hh-rlhf's '\\n\\nHuman: ...\\n\\nAssistant: ...')."""
2688
2704
  pairs: list[tuple[str, str]] = []
2689
2705
  matches = [m for m in CHATLOG_SPEAKER_LABEL_RE.finditer(s)
2690
- if m.group(1) not in CHATLOG_SPEAKER_STOP_LIST]
2706
+ if m.group(1).casefold() not in CHATLOG_SPEAKER_STOP_LIST_CF]
2691
2707
  for i, m in enumerate(matches):
2692
2708
  end = matches[i + 1].start() if i + 1 < len(matches) else len(s)
2693
2709
  pairs.append((m.group(1), s[m.end():end].strip()))
@@ -2811,7 +2827,7 @@ class Scanner:
2811
2827
  # v0.9.1: filter stop-list tokens from both detection and turn metrics
2812
2828
  raw_label_matches = [
2813
2829
  m for m in CHATLOG_SPEAKER_LABEL_RE.finditer(text)
2814
- if m.group(1) not in CHATLOG_SPEAKER_STOP_LIST
2830
+ if m.group(1).casefold() not in CHATLOG_SPEAKER_STOP_LIST_CF
2815
2831
  ]
2816
2832
  turn_count = len(raw_label_matches)
2817
2833
  label_counts = Counter(m.group(1) for m in raw_label_matches)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: file-observer
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Not a file watcher — a one-shot, read-only scan that emits a deterministic JSON manifest of what's in a directory before you ingest it.
5
5
  Author-email: Russell Pfister <russalo@russalo.com>
6
6
  License: AGPL-3.0-or-later
@@ -20,4 +20,6 @@ tests/test_unit.py
20
20
  tests/test_v1_1.py
21
21
  tests/test_v1_2.py
22
22
  tests/test_v1_2_1.py
23
- tests/test_v1_2_2.py
23
+ tests/test_v1_2_2.py
24
+ tests/test_v1_2_3.py
25
+ tests/test_v1_2_4.py
@@ -17,7 +17,7 @@ def test_canonical_top_level_api():
17
17
 
18
18
  import file_observer
19
19
 
20
- assert file_observer.__version__ == "1.2.2"
20
+ assert file_observer.__version__ == "1.2.4"
21
21
  assert "Scanner" in file_observer.__all__
22
22
 
23
23
 
@@ -49,9 +49,9 @@ def test_canonical_submodule_constants_unchanged():
49
49
  """Constants and the manifest field they feed stay stable across the 1.0.x → 1.1 line (the 1.0.1 import-package rename left them intact)."""
50
50
  from file_observer.scanner import SCANNER_VERSION, LOGIC_VERSION, SCHEMA_VERSION
51
51
 
52
- assert SCANNER_VERSION == "1.2.2"
52
+ assert SCANNER_VERSION == "1.2.4"
53
53
  assert SCHEMA_VERSION == "1.2" # additive minor bump (v1.1 corpus-intelligence fields)
54
- assert LOGIC_VERSION == "1.1.2" # v1.2.2: prose chatlog detection tightened (recurrence + stop-list)
54
+ assert LOGIC_VERSION == "1.1.4" # v1.2.4: case-insensitive stop-list + embedded-dialogue parity
55
55
 
56
56
 
57
57
  def test_legacy_scanner_import_warns():
@@ -811,8 +811,8 @@ class TestScanContext:
811
811
  (tmp_path / "a.txt").write_text("hello")
812
812
  manifest = Scanner(source_dir=tmp_path).scan()
813
813
  ctx = manifest.context
814
- assert ctx.scanner_version == "1.2.2"
815
- assert ctx.logic_version == "1.1.2"
814
+ assert ctx.scanner_version == "1.2.4"
815
+ assert ctx.logic_version == "1.1.4"
816
816
  assert ctx.python_version # non-empty
817
817
  assert ctx.platform # non-empty
818
818
 
@@ -850,7 +850,7 @@ class TestScanContext:
850
850
  manifest = Scanner(source_dir=tmp_path).scan()
851
851
  data = json_mod.loads(manifest_to_json(manifest))
852
852
  assert "context" in data
853
- assert data["context"]["scanner_version"] == "1.2.2"
853
+ assert data["context"]["scanner_version"] == "1.2.4"
854
854
 
855
855
 
856
856
  # ---------------------------------------------------------------------------
@@ -2042,8 +2042,8 @@ class TestSemanticToolNames:
2042
2042
 
2043
2043
  def test_version_is_current(self) -> None:
2044
2044
  from file_observer.scanner import SCANNER_VERSION, LOGIC_VERSION
2045
- assert SCANNER_VERSION == "1.2.2"
2046
- assert LOGIC_VERSION == "1.1.2"
2045
+ assert SCANNER_VERSION == "1.2.4"
2046
+ assert LOGIC_VERSION == "1.1.4"
2047
2047
 
2048
2048
 
2049
2049
  # ---------------------------------------------------------------------------
@@ -3530,4 +3530,4 @@ class TestMarkdownReport:
3530
3530
  from file_observer.scanner import manifest_to_markdown
3531
3531
  (tmp_path / "a.txt").write_text("hello")
3532
3532
  md = manifest_to_markdown(Scanner(source_dir=tmp_path).scan())
3533
- assert "1.2.2" in md
3533
+ assert "1.2.4" in md
@@ -0,0 +1,41 @@
1
+ """v1.2.3 — FAQ false-positive stopgap.
2
+
3
+ Pro + flash both flagged that recurring `Question:`/`Answer:` labels (a FAQ doc)
4
+ satisfy the v1.2.2 prose rule (≥2 distinct, ≥3 total, ≥1 recurring) and
5
+ false-positive as a chatlog. v1.2.3 adds `Question`/`Answer` to the stop-list.
6
+
7
+ This is a STOPGAP for the most common single case — NOT the real fix. The root
8
+ issue (prose `Key:value` is ambiguous with dialogue) needs a non-count signal;
9
+ see scratch/review/v1_2_2_fp_findings.md. We deliberately do NOT stop-list single
10
+ letters like `Q`/`A` — `A:`/`B:` is a legitimate anonymized-dialogue pattern.
11
+
12
+ Falsify-first: the FAQ test FAILS on v1.2.2, passes after the stop-list add.
13
+ """
14
+
15
+ from file_observer.scanner import Scanner, ScannerConfig
16
+
17
+
18
+ def _is_chatlog(tmp_path, name, content):
19
+ (tmp_path / name).write_text(content)
20
+ return Scanner(source_dir=tmp_path, config=ScannerConfig()).scan().files[0].is_chatlog
21
+
22
+
23
+ def test_faq_question_answer_not_flagged(tmp_path):
24
+ c = ("Question: How do I install?\nAnswer: Use pip.\n"
25
+ "Question: Where are the docs?\nAnswer: In docs/.\n")
26
+ assert _is_chatlog(tmp_path, "FAQ.md", c) is False
27
+
28
+
29
+ def test_faq_allcaps_not_flagged(tmp_path):
30
+ # case-sensitive stop-list + the label regex matches all-caps → ALL-CAPS FAQ
31
+ # style needs explicit coverage (PR #30 review: flash/codex/copilot flagged it).
32
+ c = ("QUESTION: How do I install?\nANSWER: Use pip.\n"
33
+ "QUESTION: Where are the docs?\nANSWER: In docs/.\n")
34
+ assert _is_chatlog(tmp_path, "FAQ.md", c) is False
35
+
36
+
37
+ def test_anonymized_ab_dialogue_still_detected(tmp_path):
38
+ # `A:`/`B:` anonymized speakers are a LEGIT conversation — must stay detected
39
+ # (guards against over-stop-listing single letters while fixing FAQ).
40
+ c = "A: hey did you see this\nB: yeah just now\nA: what do you think\n"
41
+ assert _is_chatlog(tmp_path, "dialogue.txt", c) is True
@@ -0,0 +1,56 @@
1
+ """v1.2.4 — two clean wins from the in-house multi-agent recall review (PR #31).
2
+
3
+ The Claude multi-agent /code-review pass found two real issues the cross-model
4
+ (Gemini) reviews missed, both verified live:
5
+
6
+ F9: the stop-list is case-sensitive, but the case-sensitivity fix was applied
7
+ only to FAQ (Question/QUESTION) — ALL-CAPS email/usenet headers (FROM:/
8
+ SUBJECT:) still false-positived. Fix: match the stop-list case-insensitively
9
+ (also kills the dual-casing maintenance burden).
10
+ F4: `_string_has_speaker_dialogue` (the embedded-JSON-string path) was never
11
+ hardened — still `len(labels) >= 3` with no distinct/recurrence — so the same
12
+ dialogue detected inside a JSON string was rejected as prose. Fix: apply the
13
+ prose Rule 1 predicate (>=2 distinct, >=3 total, >=1 recurring).
14
+
15
+ Falsify-first: the two FP/asymmetry tests FAIL on v1.2.3, pass after the fix; the
16
+ guard tests must stay green throughout. See scratch/review/v1_2_2_fp_findings.md.
17
+ """
18
+
19
+ import json
20
+ from file_observer.scanner import Scanner, ScannerConfig
21
+
22
+
23
+ def _is_chatlog(tmp_path, name, content):
24
+ (tmp_path / name).write_text(content)
25
+ return Scanner(source_dir=tmp_path, config=ScannerConfig()).scan().files[0].is_chatlog
26
+
27
+
28
+ class TestF9CaseInsensitiveStopList:
29
+ def test_allcaps_email_headers_not_flagged(self, tmp_path):
30
+ # ALL-CAPS recurring RFC822/usenet header dump — the FP class v1.2.2 targeted,
31
+ # but only title-case From/Subject were stop-listed.
32
+ c = "FROM: alice\nSUBJECT: re hi\nFROM: bob\nSUBJECT: re re hi\n"
33
+ assert _is_chatlog(tmp_path, "thread.txt", c) is False
34
+
35
+ def test_allcaps_faq_still_not_flagged(self, tmp_path):
36
+ c = "QUESTION: how?\nANSWER: like so.\nQUESTION: and then?\nANSWER: done.\n"
37
+ assert _is_chatlog(tmp_path, "FAQ.md", c) is False
38
+
39
+ def test_user_still_a_legit_speaker(self, tmp_path):
40
+ # `User` is intentionally NOT stop-listed; case-insensitive matching must
41
+ # not start suppressing it.
42
+ c = "User: hi there\nBob: hey\nUser: how are you\n"
43
+ assert _is_chatlog(tmp_path, "chat.txt", c) is True
44
+
45
+
46
+ class TestF4EmbeddedDialogueParity:
47
+ def test_embedded_json_dialogue_requires_recurrence(self, tmp_path):
48
+ # 3 distinct one-shot speakers in a JSON string value: rejected as prose
49
+ # (Rule 1), so must also be rejected here — no container-format asymmetry.
50
+ c = json.dumps({"chosen": "Alice: hi\n\nBob: hello\n\nCarol: hey there friend"})
51
+ assert _is_chatlog(tmp_path, "e.jsonl", c) is False
52
+
53
+ def test_hh_rlhf_embedded_still_detected(self, tmp_path):
54
+ # 2 distinct, recurring (Human x2) — a real embedded conversation, must stay.
55
+ c = json.dumps({"chosen": "\n\nHuman: hi\n\nAssistant: hello\n\nHuman: bye"})
56
+ assert _is_chatlog(tmp_path, "hh.jsonl", c) is True
File without changes
File without changes