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.
- {file_observer-1.2.0/src/file_observer.egg-info → file_observer-1.2.2}/PKG-INFO +3 -3
- {file_observer-1.2.0 → file_observer-1.2.2}/docs/README.md +2 -2
- {file_observer-1.2.0 → file_observer-1.2.2}/pyproject.toml +1 -1
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer/scanner.py +120 -108
- {file_observer-1.2.0 → file_observer-1.2.2/src/file_observer.egg-info}/PKG-INFO +3 -3
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/SOURCES.txt +3 -1
- {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_packaging.py +3 -3
- {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_unit.py +25 -22
- {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_v1_2.py +9 -6
- file_observer-1.2.2/tests/test_v1_2_1.py +112 -0
- file_observer-1.2.2/tests/test_v1_2_2.py +95 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/LICENSE +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/LICENSE-AGPL +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/setup.cfg +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer/__init__.py +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/dependency_links.txt +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/entry_points.txt +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/requires.txt +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/file_observer.egg-info/top_level.txt +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/scanner/__init__.py +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/src/scanner/scanner.py +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_edge_cases.py +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_golden.py +0 -0
- {file_observer-1.2.0 → file_observer-1.2.2}/tests/test_integration.py +0 -0
- {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.
|
|
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.
|
|
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** |
|
|
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.
|
|
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** |
|
|
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.
|
|
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.
|
|
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.
|
|
75
|
-
LOGIC_VERSION = "1.1.
|
|
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 =
|
|
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
|
|
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,
|
|
559
|
-
"
|
|
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:
|
|
591
|
-
#
|
|
592
|
-
#
|
|
593
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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
|
|
2600
|
-
type+message.content), nested trees (oasst prompt.role+replies),
|
|
2601
|
-
|
|
2602
|
-
|
|
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
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
if
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
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
|
-
|
|
2629
|
-
|
|
2627
|
+
json.loads(ln)
|
|
2628
|
+
return True
|
|
2630
2629
|
except (json.JSONDecodeError, ValueError):
|
|
2631
2630
|
pass
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
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
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
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.
|
|
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.
|
|
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** |
|
|
111
|
+
| **Tests** | 606 passed; ran clean (zero fatal errors) across 12 corpora / 28,756 files |
|
|
112
112
|
|
|
113
113
|
---
|
|
114
114
|
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
815
|
-
assert ctx.logic_version == "1.1.
|
|
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.
|
|
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
|
|
1317
|
-
#
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
1346
|
-
text = "
|
|
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
|
|
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
|
|
1556
|
+
# v1.2.1: dividers need a SPEAKER co-signal.
|
|
1554
1557
|
content = (
|
|
1555
|
-
"
|
|
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 = "###
|
|
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
|
|
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.
|
|
2043
|
-
assert LOGIC_VERSION == "1.1.
|
|
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.
|
|
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
|
-
#
|
|
115
|
-
# (type > role > from > speaker > author), not hash-randomized
|
|
116
|
-
# order — guards the determinism contract (RFC §2).
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|