structured-gist 0.9.0__py3-none-any.whl

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.
@@ -0,0 +1,12 @@
1
+ """Deterministic linter and claim-list renderer for structured-gist outlines.
2
+
3
+ Modules: ``structured_gist.lint_outline`` (``lint_text``) and
4
+ ``structured_gist.claim_list`` (``render_claim_list``). stdlib only.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("structured-gist")
11
+ except PackageNotFoundError:
12
+ __version__ = "0+unknown"
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Claim-list render target for structured-gist outline trees.
4
+
5
+ Implements FR-018, FR-018a, FR-018b, FR-019, FR-020:
6
+ - Emits JSONL (one claim record per line)
7
+ - Each record: claim, modality, polarity, source_span, parent_id
8
+ - Validates against published schema
9
+ - Derived view of the existing outline tree (reuses lint_outline parser)
10
+
11
+ No external dependencies beyond Python 3 stdlib.
12
+ """
13
+
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import List, Tuple, Optional, Dict, Any
19
+ from io import StringIO
20
+
21
+ try:
22
+ # Installed package: lint_outline sits beside this module
23
+ from .lint_outline import parse_line, extract_outline_from_text, lint_text
24
+ except ImportError:
25
+ # Script or flat import: add parent directory to path for lint_outline import
26
+ sys.path.insert(0, str(Path(__file__).parent.parent / 'tests'))
27
+
28
+ from lint_outline import parse_line, extract_outline_from_text, lint_text
29
+
30
+
31
+ # Load and cache the schema
32
+ def _load_schema() -> Dict[str, Any]:
33
+ """Load the claim_list schema from the adjacent JSON file."""
34
+ schema_path = Path(__file__).parent / 'claim_list.schema.json'
35
+ with open(schema_path, 'r', encoding='utf-8') as f:
36
+ return json.load(f)
37
+
38
+
39
+ _SCHEMA = _load_schema()
40
+
41
+
42
+ def _has_verb_predicate(text: str) -> bool:
43
+ """
44
+ Heuristic: determine if text contains a verb/predicate.
45
+
46
+ Returns True if text contains:
47
+ - Common auxiliary/modal verbs (is, are, was, were, has, have, had, do, does, did,
48
+ can, could, will, would, should, must, may, might, shall)
49
+ - Common verbs (be, have, do, go, get, make, take, know, give, come, use, find, tell,
50
+ work, call, try, ask, need, feel, become, leave, put, mean, seem, help, talk, turn,
51
+ start, show, hear, play, run, move, like, live, believe, hold, bring, begin, let,
52
+ provide, contain, validate, check, require, allow, enable, disable, perform,
53
+ support, expect, represent, implement, define, create, generate, produce, process,
54
+ handle, manage, control, maintain, update, remove, add, delete, modify, retrieve,
55
+ store, load, save, open, close, read, write, execute, return, throw, catch, pass,
56
+ fail, succeed, etc.)
57
+ - Words ending in -ed (past tense forms)
58
+ - Words ending in -ing (gerund/progressive forms)
59
+
60
+ Does NOT count bare noun phrases like "Configuration" or "Name".
61
+ """
62
+ if not text or not text.strip():
63
+ return False
64
+
65
+ # Strip surrounding ** if present (from bold markdown)
66
+ cleaned = text.strip()
67
+ if cleaned.startswith('**') and cleaned.endswith('**'):
68
+ cleaned = cleaned[2:-2].strip()
69
+
70
+ if not cleaned:
71
+ return False
72
+
73
+ # Common auxiliary and modal verbs
74
+ auxiliaries = {
75
+ 'is', 'are', 'was', 'were', 'been', 'be',
76
+ 'has', 'have', 'had',
77
+ 'does', 'do', 'did',
78
+ 'can', 'could', 'will', 'would', 'should', 'must', 'may', 'might', 'shall',
79
+ }
80
+
81
+ # Common content verbs (infinitive forms)
82
+ verbs = {
83
+ 'go', 'get', 'make', 'take', 'know', 'give', 'come', 'use', 'find', 'tell',
84
+ 'work', 'call', 'try', 'ask', 'need', 'feel', 'become', 'leave', 'put', 'mean',
85
+ 'seem', 'help', 'talk', 'turn', 'start', 'show', 'hear', 'play', 'run', 'move',
86
+ 'like', 'live', 'believe', 'hold', 'bring', 'begin', 'let', 'provide', 'contain',
87
+ 'validate', 'check', 'require', 'allow', 'enable', 'disable', 'perform', 'support',
88
+ 'expect', 'represent', 'implement', 'define', 'create', 'generate', 'produce',
89
+ 'process', 'handle', 'manage', 'control', 'maintain', 'update', 'remove', 'add',
90
+ 'delete', 'modify', 'retrieve', 'store', 'load', 'save', 'open', 'close',
91
+ 'read', 'write', 'execute', 'return', 'throw', 'catch', 'pass', 'fail', 'succeed',
92
+ 'resolve', 'render', 'emit', 'consume', 'accept', 'reject', 'parse', 'compute',
93
+ 'apply', 'enforce', 'collect', 'gather', 'extract', 'combine', 'merge', 'split',
94
+ 'build', 'develop', 'ship', 'test', 'verify', 'validate', 'inspect', 'audit',
95
+ 'cover', 'fix', 'resolve', 'discover', 'investigate', 'determine', 'calculate',
96
+ 'measure', 'record', 'track', 'flag', 'warn', 'report', 'document', 'explain',
97
+ 'describe', 'analyze', 'examine', 'review', 'check', 'assess', 'evaluate',
98
+ }
99
+
100
+ # Split text into words
101
+ words = cleaned.split()
102
+
103
+ for word in words:
104
+ # Remove punctuation for comparison
105
+ word_stripped = word.rstrip('.,;:!?\'")').lower()
106
+
107
+ # Check for auxiliary verbs
108
+ if word_stripped in auxiliaries:
109
+ return True
110
+
111
+ # Check for common verbs (3rd person singular: adds, makes, validates, etc.)
112
+ if word_stripped[:-1] in verbs and word_stripped.endswith('s'):
113
+ return True
114
+
115
+ # Check for past tense (-ed)
116
+ if word_stripped.endswith('ed') and len(word_stripped) > 3:
117
+ return True
118
+
119
+ # Check for progressive (-ing)
120
+ if word_stripped.endswith('ing') and len(word_stripped) > 4:
121
+ return True
122
+
123
+ # Check infinitive forms
124
+ if word_stripped in verbs:
125
+ return True
126
+
127
+ return False
128
+
129
+
130
+ def _is_summary(attr_text: str, child_text: str) -> bool:
131
+ """
132
+ Determine if a child's text is a near-verbatim restatement of the attribute.
133
+ A child is "summary" (excluded from emission) if it merely restates the parent
134
+ almost verbatim — use word overlap heuristic.
135
+
136
+ Returns True if >80% of words in child are present in attribute.
137
+ """
138
+ if not attr_text or not child_text:
139
+ return False
140
+
141
+ # Normalize both texts
142
+ attr_words = set(w.lower().rstrip('.,;:!?') for w in attr_text.split())
143
+ child_words = [w.lower().rstrip('.,;:!?') for w in child_text.split()]
144
+
145
+ if not child_words:
146
+ return False
147
+
148
+ # Count how many child words appear in attribute
149
+ overlap = sum(1 for w in child_words if w in attr_words)
150
+ overlap_ratio = overlap / len(child_words)
151
+
152
+ return overlap_ratio >= 0.8
153
+
154
+
155
+ def _validate_record(record: Dict[str, Any]) -> List[str]:
156
+ """
157
+ Validate a claim record against the schema.
158
+ Returns list of validation errors (empty if valid).
159
+ """
160
+ errors = []
161
+
162
+ # Check required keys
163
+ required_keys = {'claim', 'modality', 'polarity', 'source_span', 'parent_id'}
164
+ missing = required_keys - set(record.keys())
165
+ if missing:
166
+ errors.append(f"Missing required keys: {missing}")
167
+
168
+ # Check no extra keys
169
+ extra = set(record.keys()) - required_keys
170
+ if extra:
171
+ errors.append(f"Unexpected extra keys: {extra}")
172
+
173
+ # Check types and values
174
+ if 'claim' in record:
175
+ if not isinstance(record['claim'], str):
176
+ errors.append(f"'claim' must be string, got {type(record['claim'])}")
177
+
178
+ if 'modality' in record:
179
+ if not isinstance(record['modality'], str):
180
+ errors.append(f"'modality' must be string, got {type(record['modality'])}")
181
+ elif record['modality'] not in ['assertion', 'epistemic_hedge', 'deontic_hedge']:
182
+ errors.append(f"'modality' must be one of ['assertion', 'epistemic_hedge', 'deontic_hedge'], got {record['modality']}")
183
+
184
+ if 'polarity' in record:
185
+ if not isinstance(record['polarity'], str):
186
+ errors.append(f"'polarity' must be string, got {type(record['polarity'])}")
187
+ elif record['polarity'] not in ['affirmative', 'negative']:
188
+ errors.append(f"'polarity' must be one of ['affirmative', 'negative'], got {record['polarity']}")
189
+
190
+ if 'source_span' in record:
191
+ if not isinstance(record['source_span'], dict):
192
+ errors.append(f"'source_span' must be dict, got {type(record['source_span'])}")
193
+ else:
194
+ ss = record['source_span']
195
+ if 'start' not in ss:
196
+ errors.append("'source_span' missing 'start' key")
197
+ elif not isinstance(ss.get('start'), int):
198
+ errors.append(f"'source_span.start' must be int, got {type(ss.get('start'))}")
199
+
200
+ if 'end' not in ss:
201
+ errors.append("'source_span' missing 'end' key")
202
+ elif not isinstance(ss.get('end'), int):
203
+ errors.append(f"'source_span.end' must be int, got {type(ss.get('end'))}")
204
+
205
+ if 'text' not in ss:
206
+ errors.append("'source_span' missing 'text' key")
207
+ elif not isinstance(ss.get('text'), str):
208
+ errors.append(f"'source_span.text' must be string, got {type(ss.get('text'))}")
209
+
210
+ if 'parent_id' in record:
211
+ if not isinstance(record['parent_id'], str):
212
+ errors.append(f"'parent_id' must be string, got {type(record['parent_id'])}")
213
+
214
+ return errors
215
+
216
+
217
+ def _build_parent_id(depth: int, line_index: int) -> str:
218
+ """
219
+ Generate a stable parent_id for a node.
220
+ Uses depth and line index to create a stable identifier.
221
+ """
222
+ return f"node_{depth}_{line_index}"
223
+
224
+
225
+ def render_claim_list(outline_text: str) -> str:
226
+ """
227
+ Render an outline tree as a claim-list JSONL.
228
+
229
+ Args:
230
+ outline_text: The outline text (any render mode: block, inline, or responsive)
231
+
232
+ Returns:
233
+ JSONL output (one JSON object per line, one line per claim)
234
+
235
+ Raises:
236
+ ValueError: If any emitted record fails schema validation
237
+ """
238
+ # Extract and parse the outline
239
+ outline_lines, is_block_mode = extract_outline_from_text(outline_text)
240
+ if not outline_lines:
241
+ return ""
242
+
243
+ # Parse each line into (depth, family, text)
244
+ parsed = []
245
+ for i, line in enumerate(outline_lines):
246
+ depth, family, text = parse_line(line, unit=2)
247
+ parsed.append((i, depth, family, text, line))
248
+
249
+ # Build parent index: map each line to its parent's index
250
+ parent_index = {}
251
+ for i, (line_idx, depth, family, text, line) in enumerate(parsed):
252
+ if depth == 0:
253
+ parent_index[i] = None
254
+ else:
255
+ # Find nearest ancestor with depth < current depth
256
+ parent = None
257
+ for j in range(i - 1, -1, -1):
258
+ _, p_depth, _, _, _ = parsed[j]
259
+ if p_depth < depth:
260
+ parent = j
261
+ break
262
+ parent_index[i] = parent
263
+
264
+ # Emit claims
265
+ records = []
266
+ output = StringIO()
267
+
268
+ for i, (line_idx, depth, family, text, original_line) in enumerate(parsed):
269
+ # Calculate character offset in original text
270
+ # Note: this is a simplified approximation; exact offsets would need more tracking
271
+ char_offset = outline_text.find(text)
272
+ if char_offset < 0:
273
+ char_offset = 0
274
+
275
+ # Determine parent_id
276
+ parent_i = parent_index[i]
277
+ if parent_i is not None:
278
+ parent_depth = parsed[parent_i][1]
279
+ parent_family = parsed[parent_i][2]
280
+ parent_id = _build_parent_id(parent_depth, parent_i)
281
+ else:
282
+ parent_id = "root"
283
+
284
+ # Decide whether to emit and what to emit
285
+ should_emit = False
286
+ claim_text = None
287
+
288
+ if family == 'dash':
289
+ # Concept nodes NEVER emit
290
+ should_emit = False
291
+ elif family == 'arrow':
292
+ # Explanation nodes ALWAYS emit
293
+ should_emit = True
294
+ claim_text = text
295
+ elif family in ('uroman', 'ualpha', 'lroman', 'lalpha'):
296
+ # Enumerator nodes ALWAYS emit
297
+ should_emit = True
298
+ claim_text = text
299
+ elif family == 'attr':
300
+ # Attribute nodes emit only if:
301
+ # 1. Own text contains a verb/predicate, OR
302
+ # 2. Has a non-summary ↪ child
303
+
304
+ # Check for verb in own text
305
+ if _has_verb_predicate(text):
306
+ should_emit = True
307
+ claim_text = text
308
+ else:
309
+ # Check for non-summary arrow child
310
+ # A child is the next line at greater depth whose parent is this line
311
+ child_text = None
312
+ for j in range(i + 1, len(parsed)):
313
+ child_line_idx, child_depth, child_family, child_text_raw, _ = parsed[j]
314
+ if child_depth <= depth:
315
+ # No more children
316
+ break
317
+ if child_depth == depth + 1 and child_family == 'arrow':
318
+ # This is a direct child
319
+ # Check if it's non-summary
320
+ if not _is_summary(text, child_text_raw):
321
+ should_emit = True
322
+ claim_text = child_text_raw
323
+ break
324
+
325
+ # Build and validate record
326
+ if should_emit and claim_text:
327
+ record = {
328
+ 'claim': claim_text,
329
+ 'modality': 'assertion', # Default; could be enhanced with hedge detection
330
+ 'polarity': 'affirmative', # Default; could be enhanced with negation detection
331
+ 'source_span': {
332
+ 'start': char_offset,
333
+ 'end': char_offset + len(claim_text),
334
+ 'text': claim_text
335
+ },
336
+ 'parent_id': parent_id
337
+ }
338
+
339
+ # Validate record against schema
340
+ validation_errors = _validate_record(record)
341
+ if validation_errors:
342
+ raise ValueError(f"Schema validation failed for record: {validation_errors}\nRecord: {record}")
343
+
344
+ # Emit JSONL line
345
+ output.write(json.dumps(record) + '\n')
346
+ records.append(record)
347
+
348
+ return output.getvalue()
349
+
350
+
351
+ def main():
352
+ """CLI entry point."""
353
+ if len(sys.argv) > 1:
354
+ # Read from file
355
+ with open(sys.argv[1], 'r', encoding='utf-8') as f:
356
+ text = f.read()
357
+ else:
358
+ # Read from stdin
359
+ text = sys.stdin.read()
360
+
361
+ try:
362
+ jsonl = render_claim_list(text)
363
+ print(jsonl, end='')
364
+ sys.exit(0)
365
+ except Exception as e:
366
+ print(f"Error: {e}", file=sys.stderr)
367
+ sys.exit(1)
368
+
369
+
370
+ if __name__ == '__main__':
371
+ main()
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Claim List Record",
4
+ "description": "A single claim extracted from an outline tree via the claim-list render target (FR-018). One record per emitted claim.",
5
+ "type": "object",
6
+ "required": ["claim", "modality", "polarity", "source_span", "parent_id"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "claim": {
10
+ "type": "string",
11
+ "description": "The claim text extracted from the source outline node."
12
+ },
13
+ "modality": {
14
+ "type": "string",
15
+ "enum": ["assertion", "epistemic_hedge", "deontic_hedge"],
16
+ "description": "The epistemic/deontic status of the claim. 'assertion' is unqualified; 'epistemic_hedge' marks likelihood/certainty qualification; 'deontic_hedge' marks obligation/permission (MUST/SHOULD/MAY)."
17
+ },
18
+ "polarity": {
19
+ "type": "string",
20
+ "enum": ["affirmative", "negative"],
21
+ "description": "Whether the claim is affirmative or negative. Mirrors the 'negation' gold-fact category."
22
+ },
23
+ "source_span": {
24
+ "type": "object",
25
+ "description": "Character offset and quoted text identifying the source of this claim in the original document.",
26
+ "required": ["start", "end", "text"],
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "start": {
30
+ "type": "integer",
31
+ "minimum": 0,
32
+ "description": "Character offset (0-indexed) of the start of the claim in the source."
33
+ },
34
+ "end": {
35
+ "type": "integer",
36
+ "minimum": 0,
37
+ "description": "Character offset (exclusive) of the end of the claim in the source."
38
+ },
39
+ "text": {
40
+ "type": "string",
41
+ "description": "The quoted text at source_span[start:end], for verification."
42
+ }
43
+ }
44
+ },
45
+ "parent_id": {
46
+ "type": "string",
47
+ "description": "Stable identifier of the emitting node's parent in the outline tree. Must be identical across re-renders of an unchanged outline."
48
+ }
49
+ }
50
+ }