file-observer 1.2.0__tar.gz → 1.2.2__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 (25) hide show
  1. {file_observer-1.2.0/src/file_observer.egg-info → file_observer-1.2.2}/PKG-INFO +3 -3
  2. {file_observer-1.2.0 → file_observer-1.2.2}/docs/README.md +2 -2
  3. {file_observer-1.2.0 → file_observer-1.2.2}/pyproject.toml +1 -1
  4. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer/scanner.py +120 -108
  5. {file_observer-1.2.0 → file_observer-1.2.2/src/file_observer.egg-info}/PKG-INFO +3 -3
  6. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/SOURCES.txt +3 -1
  7. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_packaging.py +3 -3
  8. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_unit.py +25 -22
  9. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_v1_2.py +9 -6
  10. file_observer-1.2.2/tests/test_v1_2_1.py +112 -0
  11. file_observer-1.2.2/tests/test_v1_2_2.py +95 -0
  12. {file_observer-1.2.0 → file_observer-1.2.2}/LICENSE +0 -0
  13. {file_observer-1.2.0 → file_observer-1.2.2}/LICENSE-AGPL +0 -0
  14. {file_observer-1.2.0 → file_observer-1.2.2}/setup.cfg +0 -0
  15. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer/__init__.py +0 -0
  16. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/dependency_links.txt +0 -0
  17. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/entry_points.txt +0 -0
  18. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/requires.txt +0 -0
  19. {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/top_level.txt +0 -0
  20. {file_observer-1.2.0 → file_observer-1.2.2}/src/scanner/__init__.py +0 -0
  21. {file_observer-1.2.0 → file_observer-1.2.2}/src/scanner/scanner.py +0 -0
  22. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_edge_cases.py +0 -0
  23. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_golden.py +0 -0
  24. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_integration.py +0 -0
  25. {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_v1_1.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: file-observer
3
- Version: 1.2.0
3
+ Version: 1.2.2
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
@@ -104,11 +104,11 @@ Every derived field carries a `signal_provenance` entry; every vector an `identi
104
104
  |---|---|
105
105
  | **Package** | `file-observer` |
106
106
  | **CLI** | `file-observer` or `fo` (shorthand) |
107
- | **Version** | `1.2.0` |
107
+ | **Version** | `1.2.1` |
108
108
  | **Schema** | `1.2` |
109
109
  | **Python** | `>= 3.12` |
110
110
  | **License** | [AGPL-3.0](https://github.com/russalo/file-observer/blob/main/LICENSE) (commercial license available) |
111
- | **Tests** | 595 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
111
+ | **Tests** | 606 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
112
112
 
113
113
  ---
114
114
 
@@ -59,11 +59,11 @@ Every derived field carries a `signal_provenance` entry; every vector an `identi
59
59
  |---|---|
60
60
  | **Package** | `file-observer` |
61
61
  | **CLI** | `file-observer` or `fo` (shorthand) |
62
- | **Version** | `1.2.0` |
62
+ | **Version** | `1.2.1` |
63
63
  | **Schema** | `1.2` |
64
64
  | **Python** | `>= 3.12` |
65
65
  | **License** | [AGPL-3.0](https://github.com/russalo/file-observer/blob/main/LICENSE) (commercial license available) |
66
- | **Tests** | 595 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
66
+ | **Tests** | 606 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
67
67
 
68
68
  ---
69
69
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "file-observer"
7
- version = "1.2.0"
7
+ version = "1.2.2"
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"
@@ -5,7 +5,7 @@ Observation layer for document pipelines. Recursively discovers files,
5
5
  extracts metadata and signals, emits a deterministic JSON manifest.
6
6
 
7
7
  Package: file_observer
8
- Version: 1.2.0
8
+ Version: 1.2.1
9
9
  Schema: 1.2
10
10
  Python: >= 3.12
11
11
  Spec: docs/v1.2.0_RFC_Specification.md (current)
@@ -71,8 +71,8 @@ except ImportError:
71
71
  _defusedxml_available = False
72
72
 
73
73
 
74
- SCANNER_VERSION = "1.2.0"
75
- LOGIC_VERSION = "1.1.0"
74
+ SCANNER_VERSION = "1.2.2"
75
+ LOGIC_VERSION = "1.1.2"
76
76
  SCHEMA_VERSION = "1.2"
77
77
 
78
78
 
@@ -546,17 +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 = 4 # v1.2: generalized conversational detection + per-speaker structure
549
+ CHATLOG_METHOD_VERSION = 6 # v1.2.2: prose Rule 1 requires a recurring speaker; expanded stop-list (FP fixes)
550
550
  CHATLOG_RULES_DEFINITION = (
551
- "detect:speaker_label_re(3+,stop_list,alternation),h3_header_re(5+),section_divider_re(3+),"
551
+ "detect:speaker_label_re(distinct>=2,total>=3,recurring>=1,stop_list),"
552
+ "h3_header_re(5+)|section_divider_re(3+)[require speaker_cosignal_distinct>=2],"
552
553
  "json_conversation(role_keys{type,role,from,speaker,author}+content_keys{text,value,content,message,body},"
553
- "line/array/tree,embedded_speaker_labels);"
554
+ "line/array/tree,embedded_speaker_labels,require msgs>=3 AND distinct_speakers>=2);"
554
555
  "extract:turn_count,speaker_labels(freq>=3,stop_list),section_markers,"
555
556
  "turn_char_stats,speaker_turn_counts,speaker_turn_chars,alternation,"
556
557
  "reference_tokens(at_mentions,wiki_links,code_fence_blocks,url_count),"
557
558
  "top_capitalized_tokens(freq>=3,top20),vocabulary_size_estimate;"
558
- "stop_list:Allow,CAUTION,Disallow,Error,Example,Examples,FIXME,"
559
- "IMPORTANT,NOTE,Note,Result,TIP,TODO,Warning"
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"
560
564
  )
561
565
  CHATLOG_STATIC_TUNING = {
562
566
  "detection_threshold": 3,
@@ -578,6 +582,16 @@ CHATLOG_ROLE_FIELD_KEYS = ("type", "role", "from", "speaker", "author")
578
582
  CHATLOG_CONTENT_FIELD_KEYS = ("text", "value", "content", "message", "body")
579
583
  CHATLOG_ROLE_FIELD_KEYSET = frozenset(CHATLOG_ROLE_FIELD_KEYS)
580
584
  CHATLOG_CONTENT_FIELD_KEYSET = frozenset(CHATLOG_CONTENT_FIELD_KEYS)
585
+ # v1.2.1: `type` is ambiguous — it names a speaker only in some schemas
586
+ # (Claude: type:user/assistant) and is otherwise a wrapper (type:"message"), a
587
+ # log level (type:"info"/"error"), or a content-block kind (type:"text"). Accept
588
+ # `type` as the speaker ONLY when its value is a conversational role; the other
589
+ # role keys (role/from/speaker/author) accept any value. This kills log /
590
+ # rich-content / envelope false positives without a denylist.
591
+ CHATLOG_CONVERSATIONAL_TYPE_VALUES = frozenset({
592
+ "user", "assistant", "human", "system", "model", "bot", "gpt", "ai",
593
+ "prompter", "tool", "function",
594
+ })
581
595
  # v1.2: regex fallback for truncated/large single-JSON samples (e.g. a multi-MB
582
596
  # ShareGPT file whose bounded sample won't json.loads). Matches a flat object
583
597
  # carrying both a role-field and a content-field key, in either order.
@@ -587,16 +601,29 @@ CHATLOG_JSON_MESSAGE_RE = re.compile(
587
601
  r'|\{[^{}]*"(?:text|value|content|message|body)"\s*:[^{}]*'
588
602
  r'"(?:type|role|from|speaker|author)"\s*:\s*"[^"]{1,40}"'
589
603
  )
590
- # v1.2: date-stamped headers distinguish a journal/vault ('# 2026-04-10') from
591
- # a prose doc ('### Parameters'). Lets rules 2/3 keep detecting journals while
592
- # no longer over-firing on documentation markdown.
593
- CHATLOG_DATE_HEADER_RE = re.compile(r'(?m)^#{1,6}\s.*\b\d{4}-\d{2}-\d{2}\b')
604
+ # v1.2.1: capture the role-field VALUE so detection can require >=2 DISTINCT
605
+ # speakers. A conversation alternates (user/assistant, u0/u1, human/gpt); a
606
+ # structured log is all "type":"info" (1 distinct) and a changelog has none.
607
+ # This single requirement fixes the v1.2.0 false positives on logs, single-role
608
+ # JSONL, and Claude rich-content blocks (all <2 distinct roles).
609
+ CHATLOG_JSON_ROLE_VALUE_RE = re.compile(
610
+ r'"(?:type|role|from|speaker|author)"\s*:\s*"([^"]{1,40})"')
594
611
  # v0.9.1: speaker labels matching these tokens are excluded from detection
595
612
  # and extraction. These are common documentation patterns (Note:, Example:, etc.)
596
613
  # that match the speaker label regex but are not conversation participants.
597
614
  CHATLOG_SPEAKER_STOP_LIST: set[str] = {
615
+ # documentation / admonition labels (v0.9.1)
598
616
  "Note", "NOTE", "Example", "Examples", "Result", "Warning", "Error",
599
617
  "Disallow", "Allow", "TODO", "FIXME", "TIP", "IMPORTANT", "CAUTION",
618
+ # v1.2.2: structural/header labels that recur but are never speakers,
619
+ # surfaced by the corpus sweep — email/usenet headers, man-page sections,
620
+ # legal notices, form fields. `User` is intentionally NOT here (it is a
621
+ # legitimate speaker in many transcripts); `Password` and the rest never are.
622
+ "From", "To", "Cc", "Bcc", "Date", "Subject", "Sender", "References",
623
+ "Message", "Newsgroups", "Organization", "Lines", "Distribution", "Path",
624
+ "Version", "Usage", "Options", "Command", "Commands", "Format", "Synopsis",
625
+ "Description", "Arguments", "Parameters", "Returns", "Summary",
626
+ "Authorization", "Documentation", "License", "Copyright", "Password",
600
627
  }
601
628
 
602
629
  # v0.9: Reference tokens vector identity constants.
@@ -2507,22 +2534,32 @@ class Scanner:
2507
2534
  """
2508
2535
  if not text:
2509
2536
  return False
2510
- # Rule 1: speaker labels, excluding stop-list matches
2537
+ # Rule 1: prose speaker labels (stop-list filtered). A conversation has
2538
+ # speakers who RECUR and alternate; a header/label block (email headers,
2539
+ # man-page sections, UTF-8 demos, language lists) has many one-shot
2540
+ # labels. v1.2.2: require >=2 distinct speakers, >=3 total turns, AND at
2541
+ # least one speaker who takes multiple turns (real back-and-forth). This
2542
+ # ports the v1.2.1 distinct-speaker hardening (added then only to the
2543
+ # JSON path) to prose, and adds recurrence — the discriminator the corpus
2544
+ # sweep showed separates transcripts (recurring) from header blocks (one-shot).
2511
2545
  speaker_matches = [
2512
2546
  m.group(1) for m in CHATLOG_SPEAKER_LABEL_RE.finditer(text)
2513
2547
  if m.group(1) not in CHATLOG_SPEAKER_STOP_LIST
2514
2548
  ]
2515
- if len(speaker_matches) >= 3:
2549
+ speaker_counts = Counter(speaker_matches)
2550
+ if (len(speaker_counts) >= 2 and sum(speaker_counts.values()) >= 3
2551
+ and any(c >= 2 for c in speaker_counts.values())):
2516
2552
  return True
2517
2553
  # Rules 2/3 (v1.2): markdown structure (H3 headers / section dividers)
2518
2554
  # only counts as a chatlog/journal signal when accompanied by a
2519
2555
  # conversational co-signal — 2+ speaker labels OR 2+ date-stamped
2520
2556
  # headers (journal entries). Prose docs have neither, so they no
2521
2557
  # longer false-positive; transcripts and dated journals still do.
2522
- structure_cosignal = (
2523
- len(speaker_matches) >= 2
2524
- or len(CHATLOG_DATE_HEADER_RE.findall(text)) >= 2
2525
- )
2558
+ # v1.2.1: markdown structure (H3 headers / dividers) counts only with a
2559
+ # SPEAKER co-signal (2+ labels). Dropped the v1.2 date-header co-signal —
2560
+ # it merely relocated the false positive to dated docs (changelogs,
2561
+ # release notes, dated journals are structurally indistinguishable).
2562
+ structure_cosignal = len(speaker_counts) >= 2
2526
2563
  if structure_cosignal and len(CHATLOG_H3_HEADER_RE.findall(text)) >= 5:
2527
2564
  return True
2528
2565
  if structure_cosignal and len(CHATLOG_SECTION_DIVIDER_RE.findall(text)) >= 3:
@@ -2531,56 +2568,6 @@ class Scanner:
2531
2568
  # (line-delimited, arrays, nested trees, embedded speaker labels).
2532
2569
  return self._detect_conversational_json(text)
2533
2570
 
2534
- @staticmethod
2535
- def _is_message_like(obj: Any) -> bool:
2536
- """A dict carrying a role-field key (names the speaker) AND a
2537
- content-field key (holds the utterance). Schema-general."""
2538
- if not isinstance(obj, dict):
2539
- return False
2540
- has_role = any(
2541
- k in obj and isinstance(obj[k], (str, int)) and str(obj[k]).strip()
2542
- for k in CHATLOG_ROLE_FIELD_KEYS
2543
- )
2544
- if not has_role:
2545
- return False
2546
- for k in CHATLOG_CONTENT_FIELD_KEYS:
2547
- if k not in obj:
2548
- continue
2549
- v = obj[k]
2550
- if isinstance(v, str) and v.strip():
2551
- return True
2552
- if isinstance(v, dict) and isinstance(v.get("content"), (str, list)):
2553
- return True # e.g. Claude: message.content
2554
- if isinstance(v, list) and v:
2555
- return True # content as a list of parts
2556
- return False
2557
-
2558
- def _count_message_like(self, node: Any, budget: int = 5000) -> int:
2559
- """Count message-like dicts in a parsed JSON structure (arrays of
2560
- messages, nested reply trees). Bounded; early-exits at 3."""
2561
- count = 0
2562
- stack = [node]
2563
- seen = 0
2564
- while stack and seen < budget:
2565
- cur = stack.pop()
2566
- seen += 1
2567
- if isinstance(cur, dict):
2568
- if self._is_message_like(cur):
2569
- count += 1
2570
- if count >= 3:
2571
- return count
2572
- # Don't recurse into this message's own role/content —
2573
- # Claude rich-content blocks would double-count. Continue
2574
- # only into other fields (e.g. nested `replies`).
2575
- stack.extend(v for k, v in cur.items()
2576
- if k not in CHATLOG_ROLE_FIELD_KEYSET
2577
- and k not in CHATLOG_CONTENT_FIELD_KEYSET)
2578
- else:
2579
- stack.extend(cur.values())
2580
- elif isinstance(cur, list):
2581
- stack.extend(cur)
2582
- return count
2583
-
2584
2571
  @staticmethod
2585
2572
  def _string_has_speaker_dialogue(s: Any) -> bool:
2586
2573
  """Dialogue embedded in a JSON string value (e.g. hh-rlhf's
@@ -2594,59 +2581,84 @@ class Scanner:
2594
2581
  return len(labels) >= 3
2595
2582
 
2596
2583
  def _detect_conversational_json(self, text: str) -> bool:
2597
- """Generalized conversational JSON/JSONL detection (v1.2).
2598
-
2599
- Handles: line-delimited messages (ConvoKit speaker/text, Claude
2600
- type+message.content), nested trees (oasst prompt.role+replies),
2601
- message arrays (ShareGPT from/value), dialogue embedded in a string
2602
- field (hh-rlhf), and truncated large single-JSON via a regex fallback.
2584
+ """Generalized conversational JSON/JSONL detection (v1.2; tightened v1.2.1).
2585
+
2586
+ Handles line-delimited messages (ConvoKit speaker/text, Claude
2587
+ type+message.content), nested trees (oasst prompt.role+replies), message
2588
+ arrays (ShareGPT from/value), dialogue embedded in a string field
2589
+ (hh-rlhf), and truncated large single-JSON via a regex fallback.
2590
+
2591
+ A conversation requires **3+ messages with 2+ DISTINCT speakers**. The
2592
+ distinct-speaker rule (v1.2.1) is what separates a real conversation
2593
+ from a structured log (`type:"info"` repeated), a single-role stream, or
2594
+ Claude rich-content blocks — all of which carry <2 distinct roles and
2595
+ previously false-positived.
2603
2596
  """
2604
- msg_count = 0
2605
- any_parsed = False
2606
- # Strategy 1: line-delimited JSON (JSONL)
2607
- for line in text.split("\n"):
2608
- line = line.strip()
2609
- if not line or line[0] not in "{[":
2610
- continue
2611
- try:
2612
- obj = json.loads(line)
2613
- except (json.JSONDecodeError, ValueError):
2614
- continue
2615
- any_parsed = True
2616
- msg_count += self._count_message_like(obj)
2617
- if msg_count >= 3:
2618
- return True
2619
- if isinstance(obj, dict):
2620
- for v in obj.values():
2621
- if self._string_has_speaker_dialogue(v):
2622
- return True
2623
- # Strategy 2: single JSON document/array small enough to fit the sample
2624
- if not any_parsed:
2625
- stripped = text.strip()
2626
- if stripped[:1] in "{[":
2597
+ speakers = [sp for sp, _ in self._extract_json_conversation(text)]
2598
+ if len(speakers) >= 3 and len(set(speakers)) >= 2:
2599
+ return True
2600
+ # Regex fallback ONLY when the parser extracted nothing — a truncated/
2601
+ # large single-JSON it couldn't read (e.g. a multi-MB ShareGPT file).
2602
+ # Gated on `not speakers` so it can't override the parser on readable
2603
+ # input (otherwise inner content blocks would re-inflate the count).
2604
+ # Regex fallback ONLY for a truncated/unparseable single-JSON (the
2605
+ # parser read nothing AND the sample doesn't parse). A parseable-but-
2606
+ # non-conversational doc (e.g. a structured log whose `type` values were
2607
+ # correctly rejected as non-speakers) must NOT be rescued here.
2608
+ if not speakers and not self._sample_parses_as_json(text):
2609
+ matches = CHATLOG_JSON_MESSAGE_RE.findall(text)
2610
+ if len(matches) >= 3:
2611
+ # role values from the message-like matches only, 2+ distinct
2612
+ roles = [m.group(1) for m in
2613
+ (CHATLOG_JSON_ROLE_VALUE_RE.search(s) for s in matches) if m]
2614
+ if len(set(roles)) >= 2:
2615
+ return True
2616
+ return False
2617
+
2618
+ @staticmethod
2619
+ def _sample_parses_as_json(text: str) -> bool:
2620
+ """True if the sample contains at least one parseable JSON value.
2621
+ Gates the regex fallback so it fires only on truly-unparseable
2622
+ (truncated) input, not on readable non-conversational JSON."""
2623
+ for ln in text.split("\n"):
2624
+ ln = ln.strip()
2625
+ if ln and ln[0] in "{[":
2627
2626
  try:
2628
- if self._count_message_like(json.loads(stripped)) >= 3:
2629
- return True
2627
+ json.loads(ln)
2628
+ return True
2630
2629
  except (json.JSONDecodeError, ValueError):
2631
2630
  pass
2632
- # Strategy 3: regex fallback ONLY when nothing parsed (a truncated
2633
- # large single-JSON). When lines parsed, strategy 1's accurate
2634
- # object count governs — otherwise the regex would also match inner
2635
- # content blocks (e.g. Claude's rich-content) and over-count.
2636
- if len(CHATLOG_JSON_MESSAGE_RE.findall(text)) >= 3:
2631
+ s = text.strip()
2632
+ if s[:1] in "{[":
2633
+ try:
2634
+ json.loads(s)
2637
2635
  return True
2636
+ except (json.JSONDecodeError, ValueError):
2637
+ pass
2638
2638
  return False
2639
2639
 
2640
2640
  @staticmethod
2641
2641
  def _message_role_content(obj: dict) -> tuple[str, str] | None:
2642
- """(speaker, content_text) for a message-like dict, else None."""
2642
+ """(speaker, content_text) for a message-like dict, else None.
2643
+
2644
+ v1.2.1: `type` counts as the speaker only when its value is a
2645
+ conversational role (Claude's type:user/assistant); a non-conversational
2646
+ `type` (a wrapper like "message", a log level "info", a content block
2647
+ "text") is skipped so the next role key (role/from/speaker/author) wins.
2648
+ """
2643
2649
  speaker = None
2644
2650
  for k in CHATLOG_ROLE_FIELD_KEYS:
2645
2651
  v = obj.get(k)
2646
2652
  if isinstance(v, str) and v.strip():
2647
- speaker = v.strip(); break
2648
- if isinstance(v, int):
2649
- speaker = str(v); break
2653
+ val = v.strip()
2654
+ elif isinstance(v, int):
2655
+ val = str(v)
2656
+ else:
2657
+ continue
2658
+ if k == "type" and val.lower() not in CHATLOG_CONVERSATIONAL_TYPE_VALUES:
2659
+ continue # `type` is a speaker only for conversational values
2660
+ speaker = val
2661
+ break
2650
2662
  if speaker is None:
2651
2663
  return None
2652
2664
  for k in CHATLOG_CONTENT_FIELD_KEYS:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: file-observer
3
- Version: 1.2.0
3
+ Version: 1.2.2
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
@@ -104,11 +104,11 @@ Every derived field carries a `signal_provenance` entry; every vector an `identi
104
104
  |---|---|
105
105
  | **Package** | `file-observer` |
106
106
  | **CLI** | `file-observer` or `fo` (shorthand) |
107
- | **Version** | `1.2.0` |
107
+ | **Version** | `1.2.1` |
108
108
  | **Schema** | `1.2` |
109
109
  | **Python** | `>= 3.12` |
110
110
  | **License** | [AGPL-3.0](https://github.com/russalo/file-observer/blob/main/LICENSE) (commercial license available) |
111
- | **Tests** | 595 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
111
+ | **Tests** | 606 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
112
112
 
113
113
  ---
114
114
 
@@ -18,4 +18,6 @@ tests/test_integration.py
18
18
  tests/test_packaging.py
19
19
  tests/test_unit.py
20
20
  tests/test_v1_1.py
21
- tests/test_v1_2.py
21
+ tests/test_v1_2.py
22
+ tests/test_v1_2_1.py
23
+ tests/test_v1_2_2.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.0"
20
+ assert file_observer.__version__ == "1.2.2"
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.0"
52
+ assert SCANNER_VERSION == "1.2.2"
53
53
  assert SCHEMA_VERSION == "1.2" # additive minor bump (v1.1 corpus-intelligence fields)
54
- assert LOGIC_VERSION == "1.1.0" # no routing change
54
+ assert LOGIC_VERSION == "1.1.2" # v1.2.2: prose chatlog detection tightened (recurrence + stop-list)
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.0"
815
- assert ctx.logic_version == "1.1.0"
814
+ assert ctx.scanner_version == "1.2.2"
815
+ assert ctx.logic_version == "1.1.2"
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.0"
853
+ assert data["context"]["scanner_version"] == "1.2.2"
854
854
 
855
855
 
856
856
  # ---------------------------------------------------------------------------
@@ -1313,11 +1313,10 @@ class TestDetectChatlogPattern:
1313
1313
  # ---- Rule 2: ### headers ----
1314
1314
 
1315
1315
  def test_five_h3_headers_triggers(self, scanner: Scanner) -> None:
1316
- # v1.2: H3 headers count as a journal signal when accompanied by a
1317
- # co-signal (here, date-stamped entries). Bare prose headers no longer
1318
- # trigger see test_generic_h3_headers_no_cosignal_does_not_trigger.
1319
- text = ("### 2026-01-01\nbody\n### 2026-01-02\nbody\n### 2026-01-03\n"
1320
- "body\n### 2026-01-04\nbody\n### 2026-01-05\nbody\n")
1316
+ # v1.2.1: H3 headers count only with a SPEAKER co-signal (2+ labels).
1317
+ # The v1.2 date-header co-signal was dropped (it FP'd on changelogs).
1318
+ text = ("### One\nAlice: hi\n### Two\nBob: yo\n### Three\nbody\n"
1319
+ "### Four\nbody\n### Five\nbody\n")
1321
1320
  assert scanner._detect_chatlog_pattern(text) is True
1322
1321
 
1323
1322
  def test_generic_h3_headers_no_cosignal_does_not_trigger(self, scanner: Scanner) -> None:
@@ -1342,8 +1341,8 @@ class TestDetectChatlogPattern:
1342
1341
  # ---- Rule 3: section dividers ----
1343
1342
 
1344
1343
  def test_three_dash_dividers_triggers(self, scanner: Scanner) -> None:
1345
- # v1.2: dividers count when accompanied by a co-signal (dated entries).
1346
- text = "## 2026-01-01\n---\n## 2026-01-02\n---\n## 2026-01-03\n---\nfooter\n"
1344
+ # v1.2.1: dividers count only with a SPEAKER co-signal (2+ labels).
1345
+ text = "Alice: hi\n---\nBob: yo\n---\nmore prose\n---\nfooter\n"
1347
1346
  assert scanner._detect_chatlog_pattern(text) is True
1348
1347
 
1349
1348
  def test_generic_dividers_no_cosignal_does_not_trigger(self, scanner: Scanner) -> None:
@@ -1385,6 +1384,10 @@ class TestDetectChatlogPattern:
1385
1384
  assert scanner._detect_chatlog_pattern(text) is True
1386
1385
 
1387
1386
  def test_realistic_journal_with_dividers(self, scanner: Scanner) -> None:
1387
+ # v1.2.1: a dated prose journal with NO speakers is structurally
1388
+ # indistinguishable from a changelog — it must NOT be flagged. (The
1389
+ # journal/vault use case now requires a speaker signal or conversational
1390
+ # JSON.) This guards the changelog/dated-doc false positive.
1388
1391
  text = (
1389
1392
  "# 2026-04-10\n"
1390
1393
  "Worked on the scanner. Patched the OLE2 bug.\n"
@@ -1396,7 +1399,7 @@ class TestDetectChatlogPattern:
1396
1399
  "Schema reshape day.\n"
1397
1400
  "---\n"
1398
1401
  )
1399
- assert scanner._detect_chatlog_pattern(text) is True
1402
+ assert scanner._detect_chatlog_pattern(text) is False
1400
1403
 
1401
1404
  # ---- regression guards for PR #9 review comments ----
1402
1405
 
@@ -1550,9 +1553,9 @@ class TestIsChatlogIntegration:
1550
1553
 
1551
1554
  def test_chatlog_txt_file_detected(self, tmp_path: Path) -> None:
1552
1555
  # The .txt extension is also in the chatlog detection allow list.
1553
- # v1.2: dividers need a co-signal (dated journal entries here).
1556
+ # v1.2.1: dividers need a SPEAKER co-signal.
1554
1557
  content = (
1555
- "## 2026-01-01\n---\n## 2026-01-02\n---\n## 2026-01-03\n---\nfooter\n"
1558
+ "Alice: hi\n---\nBob: yo\n---\nmore prose\n---\nfooter\n"
1556
1559
  )
1557
1560
  (tmp_path / "log.txt").write_text(content)
1558
1561
  manifest = Scanner(source_dir=tmp_path).scan()
@@ -1601,7 +1604,7 @@ class TestIsChatlogIntegration:
1601
1604
  def test_is_chatlog_serializes_to_json(self, tmp_path: Path) -> None:
1602
1605
  from file_observer.scanner import manifest_to_json
1603
1606
  import json as _json
1604
- content = "### 2026-01-01\n### 2026-01-02\n### 2026-01-03\n### 2026-01-04\n### 2026-01-05\n"
1607
+ content = "### One\nAlice: hi\n### Two\nBob: yo\n### Three\n### Four\n### Five\n"
1605
1608
  (tmp_path / "headers.md").write_text(content)
1606
1609
  manifest = Scanner(source_dir=tmp_path).scan()
1607
1610
  data = _json.loads(manifest_to_json(manifest))
@@ -1997,12 +2000,12 @@ class TestChatlogFixtures:
1997
2000
  assert chat["turn_count"] >= 6
1998
2001
 
1999
2002
  def test_journal_fixture_section_dividers(self, tmp_path: Path) -> None:
2003
+ # v1.2.1: chatlog_journal.md is dated prose with dividers but NO
2004
+ # speakers — structurally a changelog. It must NOT be flagged now
2005
+ # (journal/vault detection requires a speaker signal or conversational
2006
+ # JSON). Guards the dated-doc false positive.
2000
2007
  rec = self._scan_one_file("chatlog_journal.md", tmp_path)
2001
- assert rec.is_chatlog is True
2002
- chat = rec.specialist_metadata["chatlog"]
2003
- # Has both --- dividers AND # headers
2004
- assert chat["section_marker_count"] >= 3
2005
- assert "---" in chat["section_marker_styles"]
2008
+ assert rec.is_chatlog is False
2006
2009
 
2007
2010
  def test_headers_fixture_h3_rule(self, tmp_path: Path) -> None:
2008
2011
  # v1.2 FP fix: this fixture is prose-doc-like (bare ### headers, no
@@ -2039,8 +2042,8 @@ class TestSemanticToolNames:
2039
2042
 
2040
2043
  def test_version_is_current(self) -> None:
2041
2044
  from file_observer.scanner import SCANNER_VERSION, LOGIC_VERSION
2042
- assert SCANNER_VERSION == "1.2.0"
2043
- assert LOGIC_VERSION == "1.1.0"
2045
+ assert SCANNER_VERSION == "1.2.2"
2046
+ assert LOGIC_VERSION == "1.1.2"
2044
2047
 
2045
2048
 
2046
2049
  # ---------------------------------------------------------------------------
@@ -3527,4 +3530,4 @@ class TestMarkdownReport:
3527
3530
  from file_observer.scanner import manifest_to_markdown
3528
3531
  (tmp_path / "a.txt").write_text("hello")
3529
3532
  md = manifest_to_markdown(Scanner(source_dir=tmp_path).scan())
3530
- assert "1.2.0" in md
3533
+ assert "1.2.2" in md
@@ -111,13 +111,16 @@ class TestErrorDetail:
111
111
 
112
112
  class TestDeterminismAndCounting:
113
113
  def test_multi_role_key_picks_priority_deterministically(self, tmp_path):
114
- # A message with several role keys must select by fixed priority
115
- # (type > role > from > speaker > author), not hash-randomized set
116
- # order — guards the determinism contract (RFC §2).
117
- c = "\n".join(json.dumps({"type": "USER", "role": "x", "content": f"m{i}"})
118
- for i in range(3))
114
+ # Messages with several role keys must select the speaker by fixed
115
+ # priority (type > role > from > speaker > author), not hash-randomized
116
+ # set order — guards the determinism contract (RFC §2). Uses 2 distinct
117
+ # speakers so it's a real conversation (v1.2.1 needs >=2 distinct).
118
+ c = ('{"type": "USER", "role": "x", "content": "a"}\n'
119
+ '{"type": "ASSISTANT", "role": "y", "content": "b"}\n'
120
+ '{"type": "USER", "role": "z", "content": "c"}\n')
119
121
  chat = _scan_text(tmp_path, "m.jsonl", c).specialist_metadata["chatlog"]
120
- assert chat["speaker_turn_counts"] == {"USER": 3}
122
+ # speaker comes from `type` (priority), not `role`'s x/y/z
123
+ assert chat["speaker_turn_counts"] == {"ASSISTANT": 1, "USER": 2}
121
124
 
122
125
  def test_rich_content_blocks_not_double_counted(self, tmp_path):
123
126
  # One message whose content is a list of text blocks must count as a
@@ -0,0 +1,112 @@
1
+ """v1.2.1 — false-positive corpus + determinism property test.
2
+
3
+ Written FIRST (falsify-before-fix). On v1.2.0 the two FP tests FAIL — they
4
+ reproduce the shipped regression (CHANGELOG.md and structured JSONL logs
5
+ mis-detected as chatlogs). The fix makes them pass. See scratch/critical_review.md
6
+ and [[feedback_falsify_dont_confirm]].
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from file_observer.scanner import Scanner, ScannerConfig, manifest_to_json
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
+ class TestFalsePositiveCorpus:
24
+ """Common-but-not-conversational files MUST NOT be flagged as chatlogs."""
25
+
26
+ def test_changelog_not_flagged(self, tmp_path):
27
+ # Dated headers + dividers, no speakers — a changelog/release-notes.
28
+ c = "\n".join(f"## 2026-0{i}-15\n- fixed bug {i}\n- added feature {i}\n---"
29
+ for i in range(1, 6))
30
+ assert _is_chatlog(tmp_path, "CHANGELOG.md", c) is False
31
+
32
+ def test_dated_journal_without_speakers_not_flagged(self, tmp_path):
33
+ c = ("# 2026-01-01\nWorked on the parser.\n---\n"
34
+ "# 2026-01-02\nFixed the bug.\n---\n# 2026-01-03\nShipped.\n---\n")
35
+ assert _is_chatlog(tmp_path, "journal.md", c) is False
36
+
37
+ def test_structured_jsonl_log_not_flagged(self, tmp_path):
38
+ # type + message per line — the output of most logging libraries.
39
+ c = "\n".join(json.dumps({"timestamp": f"t{i}", "type": "info",
40
+ "message": f"request {i} ok"}) for i in range(6))
41
+ assert _is_chatlog(tmp_path, "app.jsonl", c) is False
42
+
43
+ def test_single_role_jsonl_not_flagged(self, tmp_path):
44
+ # All same role value → not a conversation (no alternation).
45
+ c = "\n".join(json.dumps({"role": "system", "content": f"event {i}"})
46
+ for i in range(6))
47
+ assert _is_chatlog(tmp_path, "events.jsonl", c) is False
48
+
49
+ def test_mixed_level_log_not_flagged(self, tmp_path):
50
+ # type=info/error/warn are LOG LEVELS, not speakers — must not pass the
51
+ # distinct-speaker gate just because the level varies (PR #28 review).
52
+ levels = ["info", "error", "warn", "info", "error", "debug"]
53
+ c = "\n".join(json.dumps({"timestamp": f"t{i}", "type": lv, "message": f"event {i}"})
54
+ for i, lv in enumerate(levels))
55
+ assert _is_chatlog(tmp_path, "app.jsonl", c) is False
56
+
57
+ def test_config_json_not_flagged(self, tmp_path):
58
+ c = json.dumps({"name": "pkg", "version": "1.0",
59
+ "scripts": {"build": "tsc", "test": "pytest"}})
60
+ assert _is_chatlog(tmp_path, "package.json", c) is False
61
+
62
+
63
+ class TestStillDetectsRealConversations:
64
+ """The FP fix must not regress genuine conversational content."""
65
+
66
+ def test_two_speaker_jsonl(self, tmp_path):
67
+ c = ('{"speaker": "u0", "text": "hi"}\n{"speaker": "u1", "text": "yo"}\n'
68
+ '{"speaker": "u0", "text": "bye"}\n')
69
+ assert _is_chatlog(tmp_path, "conv.jsonl", c) is True
70
+
71
+ def test_user_assistant_jsonl(self, tmp_path):
72
+ c = ('{"role": "user", "content": "hi"}\n{"role": "assistant", "content": "hello"}\n'
73
+ '{"role": "user", "content": "bye"}\n')
74
+ assert _is_chatlog(tmp_path, "chat.jsonl", c) is True
75
+
76
+ def test_markdown_transcript_with_speakers(self, tmp_path):
77
+ c = "### Scene 1\nAlice: hi\n### Scene 2\nBob: hello\n### Scene 3\nmore\n### S4\n### S5\n"
78
+ assert _is_chatlog(tmp_path, "t.md", c) is True
79
+
80
+ def test_type_message_envelope_with_role(self, tmp_path):
81
+ # {type:"message", role:user/assistant} — `type` is a constant wrapper;
82
+ # the real speaker is `role`. Must detect (PR #28 review regression).
83
+ c = ('{"type": "message", "role": "user", "content": "hi"}\n'
84
+ '{"type": "message", "role": "assistant", "content": "hello"}\n'
85
+ '{"type": "message", "role": "user", "content": "bye"}\n')
86
+ assert _is_chatlog(tmp_path, "chat.jsonl", c) is True
87
+
88
+
89
+ class TestDeterminism:
90
+ """Flagship property: same bytes -> same manifest, across hash seeds."""
91
+
92
+ def test_manifest_checksum_stable_across_hash_seeds(self, tmp_path):
93
+ d = str(tmp_path)
94
+ # multi-role-key objects — the case the set-iteration bug broke
95
+ Path(d, "c.jsonl").write_text(
96
+ '{"role": "user", "content": "a"}\n'
97
+ '{"role": "assistant", "content": "b"}\n'
98
+ '{"speaker": "u", "text": "c"}\n')
99
+ code = (
100
+ "from file_observer.scanner import Scanner, ScannerConfig, manifest_to_json;"
101
+ "import json; from pathlib import Path;"
102
+ f"m = Scanner(source_dir=Path({d!r}), config=ScannerConfig(enable_specialists=True)).scan();"
103
+ "print(json.loads(manifest_to_json(m))['manifest_checksum'])"
104
+ )
105
+ checksums = set()
106
+ for seed in ("0", "1", "12345"):
107
+ env = dict(os.environ, PYTHONHASHSEED=seed)
108
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True,
109
+ text=True, env=env)
110
+ assert out.returncode == 0, out.stderr
111
+ checksums.add(out.stdout.strip())
112
+ assert len(checksums) == 1 and "" not in checksums
@@ -0,0 +1,95 @@
1
+ """v1.2.2 — prose-mode chatlog false-positive corpus + recurrence rule.
2
+
3
+ Found by the empirical corpus sweep harness (scratch/review/corpus_sweep.py) on
4
+ its first run: the v1.2.1 ">=2 distinct speakers" hardening was added to the JSON
5
+ path but never ported to prose Rule 1, which still fired on >=3 total
6
+ `^Capitalized:` matches with no distinctness/recurrence. Email headers, legal
7
+ notices, UTF-8 demos, man-page labels, and form fields were all flagged.
8
+
9
+ Written FIRST (falsify-before-fix): on the unfixed code the TestProseFalsePositives
10
+ cases FAIL (they reproduce the shipped FPs). The fix — Rule 1 requires a recurring
11
+ speaker (real back-and-forth) + an expanded stop-list — makes them pass without
12
+ regressing the genuine-transcript cases. See scratch/review/v1_2_2_fp_findings.md
13
+ and [[feedback_falsify_dont_confirm]].
14
+ """
15
+
16
+ from file_observer.scanner import Scanner, ScannerConfig
17
+
18
+
19
+ def _is_chatlog(tmp_path, name, content):
20
+ (tmp_path / name).write_text(content)
21
+ return Scanner(source_dir=tmp_path, config=ScannerConfig()).scan().files[0].is_chatlog
22
+
23
+
24
+ class TestProseFalsePositives:
25
+ """Header/label blocks are NOT conversations. Each catalogued from a real
26
+ corpus file the sweep flagged under v1.2.1."""
27
+
28
+ def test_email_headers_not_flagged(self, tmp_path):
29
+ # obs-studio QSV11-License-Clarification-Email.txt shape
30
+ c = ("From: alice@example.com\nTo: bob@example.com\nCc: team@example.com\n"
31
+ "Date: Mon, 1 Jun 2026 10:00:00\nSubject: License clarification\n\n"
32
+ "Please see the attached clarification regarding the license terms.\n")
33
+ assert _is_chatlog(tmp_path, "email.txt", c) is False
34
+
35
+ def test_usenet_headers_not_flagged(self, tmp_path):
36
+ # tika testRFC822 / testMessageNews shape — 7 distinct one-shot headers
37
+ c = ("Path: news.example.com\nFrom: poster@example.com\n"
38
+ "Newsgroups: comp.lang.python\nSubject: Re: question\n"
39
+ "Date: 1 Jun 2026\nOrganization: Example\nLines: 12\n\nbody text here\n")
40
+ assert _is_chatlog(tmp_path, "news.txt", c) is False
41
+
42
+ def test_utf8_demo_labels_not_flagged(self, tmp_path):
43
+ # tmux tools/UTF-8-demo.txt shape — 6 distinct one-shot script labels
44
+ c = ("APL: ⍝ ⌽ ⊖\nBraille: ⠁⠂⠃\nGeorgian: შემოქმედი\n"
45
+ "Russian: Съешь же\nEthiopian: ሰላም\nRunes: ᚠᚢᚦ\n")
46
+ assert _is_chatlog(tmp_path, "UTF-8-demo.txt", c) is False
47
+
48
+ def test_language_label_block_not_flagged(self, tmp_path):
49
+ # tika multi-language.txt shape
50
+ c = ("English: The quick brown fox.\nPortuguese: A rápida raposa.\n"
51
+ "Spanish: El zorro rápido.\n")
52
+ assert _is_chatlog(tmp_path, "multi-language.txt", c) is False
53
+
54
+ def test_manpage_labels_not_flagged(self, tmp_path):
55
+ # autogpt docs/content/classic/usage.md shape — recurring doc labels
56
+ c = ("Usage: tool [opts]\nOptions: --verbose\nCommands: run\n"
57
+ "Usage: tool run [opts]\nOptions: --dry\nCommands: list\n")
58
+ assert _is_chatlog(tmp_path, "usage.md", c) is False
59
+
60
+ def test_doc_section_labels_not_flagged(self, tmp_path):
61
+ # autogpt battleship product spec shape — one-shot section labels
62
+ c = ("Overview: build battleship\nShips: 5 types\n"
63
+ "Setup: place ships\nObjective: sink all\n")
64
+ assert _is_chatlog(tmp_path, "spec.txt", c) is False
65
+
66
+ def test_legal_notice_not_flagged(self, tmp_path):
67
+ # tika NOTICE.txt shape
68
+ c = ("License: Apache 2.0\nLicense: see LICENSE file\n"
69
+ "OpenCSV: bundled under Apache 2.0\n")
70
+ assert _is_chatlog(tmp_path, "NOTICE.txt", c) is False
71
+
72
+ def test_oauth_form_fields_not_flagged(self, tmp_path):
73
+ # fastapi simple-oauth2.md shape — User:/Password: pairs (Password stop-listed)
74
+ c = ("User: johndoe\nPassword: secret\nUser: alice\nPassword: hunter2\n")
75
+ assert _is_chatlog(tmp_path, "oauth.md", c) is False
76
+
77
+
78
+ class TestProseTruePositivesStillDetect:
79
+ """The fix must not regress genuine prose transcripts."""
80
+
81
+ def test_three_turn_two_speaker_recurs(self, tmp_path):
82
+ # 2 speakers, one recurs — minimal real back-and-forth
83
+ c = "Alice: hi there friend\nBob: hello back\nAlice: goodbye now\n"
84
+ assert _is_chatlog(tmp_path, "chat.txt", c) is True
85
+
86
+ def test_human_assistant_transcript(self, tmp_path):
87
+ c = ("Human: what's the weather\nAssistant: sunny today\n"
88
+ "Human: thanks\nAssistant: anytime\n")
89
+ assert _is_chatlog(tmp_path, "transcript.txt", c) is True
90
+
91
+ def test_markdown_transcript_with_h3_structure(self, tmp_path):
92
+ # detects via the structure rule (5+ H3 + speaker co-signal), not Rule 1
93
+ c = ("### Scene 1\nAlice: hi\n### Scene 2\nBob: hello\n### Scene 3\n"
94
+ "more\n### Scene 4\n### Scene 5\n")
95
+ assert _is_chatlog(tmp_path, "play.md", c) is True
File without changes
File without changes