reflection-analyser 0.1.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.
- reflection_analyser/__init__.py +14 -0
- reflection_analyser/analyser.py +123 -0
- reflection_analyser/api.py +64 -0
- reflection_analyser/cli.py +88 -0
- reflection_analyser/exceptions.py +2 -0
- reflection_analyser/lexicon.py +165 -0
- reflection_analyser/manifest.py +15 -0
- reflection_analyser/schemas.py +50 -0
- reflection_analyser-0.1.0.dist-info/METADATA +143 -0
- reflection_analyser-0.1.0.dist-info/RECORD +13 -0
- reflection_analyser-0.1.0.dist-info/WHEEL +4 -0
- reflection_analyser-0.1.0.dist-info/entry_points.txt +2 -0
- reflection_analyser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""reflection-analyser — reflective-writing depth analysis for the lens family."""
|
|
2
|
+
from .analyser import ReflectionAnalyser
|
|
3
|
+
from .exceptions import ReflectionAnalyserError
|
|
4
|
+
from .schemas import (
|
|
5
|
+
MarkerSignal,
|
|
6
|
+
ReflectionAnalysis,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ReflectionAnalyser",
|
|
11
|
+
"ReflectionAnalyserError",
|
|
12
|
+
"ReflectionAnalysis",
|
|
13
|
+
"MarkerSignal",
|
|
14
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Core reflection analyser — text → markers → composite depth → Moon-style band."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .exceptions import ReflectionAnalyserError
|
|
8
|
+
from .lexicon import lexicons
|
|
9
|
+
from .schemas import MarkerSignal, ReflectionAnalysis
|
|
10
|
+
|
|
11
|
+
# Weighting per marker family in the composite depth score. Tuned so that
|
|
12
|
+
# metacognition + criticality + evidence dominate, with affect and forward-
|
|
13
|
+
# looking acting as supporting indicators. Sums to 1.0.
|
|
14
|
+
_WEIGHTS = {
|
|
15
|
+
"metacognition": 0.30,
|
|
16
|
+
"criticality": 0.25,
|
|
17
|
+
"evidence": 0.20,
|
|
18
|
+
"affect": 0.10,
|
|
19
|
+
"forward": 0.15,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# A marker reaches its weight-cap when its coverage hits this many hits per
|
|
23
|
+
# 100 words. Avoids a wordy passage with 30 'however's saturating the score.
|
|
24
|
+
_COVERAGE_CAP_PER_100W = 2.0
|
|
25
|
+
|
|
26
|
+
# Moon-style band thresholds. Tuneable per rubric.
|
|
27
|
+
_BAND_THRESHOLDS = [
|
|
28
|
+
(0.75, "transformative"),
|
|
29
|
+
(0.50, "critical"),
|
|
30
|
+
(0.25, "dialogic"),
|
|
31
|
+
(0.00, "descriptive"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
|
35
|
+
_WORD_SPLIT = re.compile(r"\b\w+\b")
|
|
36
|
+
|
|
37
|
+
_TEXT_SUFFIXES = {".txt", ".md", ".markdown", ".text", ".rst", ".qmd", ""}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReflectionAnalyser:
|
|
41
|
+
"""Score the reflective depth of a piece of writing."""
|
|
42
|
+
|
|
43
|
+
def analyse_text(self, text: str, *, source_kind: str = "text") -> ReflectionAnalysis:
|
|
44
|
+
if not text or not text.strip():
|
|
45
|
+
raise ReflectionAnalyserError("Empty input — nothing to analyse")
|
|
46
|
+
|
|
47
|
+
words = _WORD_SPLIT.findall(text)
|
|
48
|
+
word_count = len(words)
|
|
49
|
+
sentences = [s for s in _SENTENCE_SPLIT.split(text) if s.strip()]
|
|
50
|
+
|
|
51
|
+
lex = lexicons()
|
|
52
|
+
signals: dict[str, MarkerSignal] = {}
|
|
53
|
+
for name, compiled in lex.items():
|
|
54
|
+
count, samples = compiled.find_hits(text, sentences)
|
|
55
|
+
coverage = (count / word_count * 100) if word_count else 0.0
|
|
56
|
+
signals[name] = MarkerSignal(
|
|
57
|
+
count=count,
|
|
58
|
+
coverage_per_100_words=round(coverage, 4),
|
|
59
|
+
examples=samples,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
composite = _compute_depth(signals)
|
|
63
|
+
band = _band_for(composite)
|
|
64
|
+
|
|
65
|
+
return ReflectionAnalysis(
|
|
66
|
+
word_count=word_count,
|
|
67
|
+
sentence_count=len(sentences),
|
|
68
|
+
metacognition=signals["metacognition"],
|
|
69
|
+
criticality=signals["criticality"],
|
|
70
|
+
evidence=signals["evidence"],
|
|
71
|
+
affect=signals["affect"],
|
|
72
|
+
forward_looking=signals["forward"],
|
|
73
|
+
composite_depth_score=round(composite, 4),
|
|
74
|
+
depth_band=band,
|
|
75
|
+
source_kind=source_kind,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def analyse(self, path: str | Path) -> ReflectionAnalysis:
|
|
79
|
+
"""Read a file (text directly, binary via document-analyser if [documents] extra is installed)."""
|
|
80
|
+
p = Path(path)
|
|
81
|
+
if not p.exists():
|
|
82
|
+
raise ReflectionAnalyserError(f"File not found: {p}")
|
|
83
|
+
|
|
84
|
+
suffix = p.suffix.lower()
|
|
85
|
+
if suffix in _TEXT_SUFFIXES:
|
|
86
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
87
|
+
return self.analyse_text(text, source_kind=f"file:{suffix.lstrip('.') or 'text'}")
|
|
88
|
+
|
|
89
|
+
# Binary path → compose with document-analyser if available.
|
|
90
|
+
try:
|
|
91
|
+
from document_analyser.extraction import extract_text
|
|
92
|
+
except ImportError as e:
|
|
93
|
+
raise ReflectionAnalyserError(
|
|
94
|
+
f"Reading {suffix} requires the [documents] extra "
|
|
95
|
+
f"(pip install 'reflection-analyser[documents]'): {e}"
|
|
96
|
+
) from e
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
text = extract_text(p)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise ReflectionAnalyserError(f"document-analyser could not extract text from {p}: {e}") from e
|
|
102
|
+
|
|
103
|
+
return self.analyse_text(text, source_kind=f"file:{suffix.lstrip('.')}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compute_depth(signals: dict[str, MarkerSignal]) -> float:
|
|
107
|
+
"""Weighted composite of capped per-marker coverages, normalised to 0–1."""
|
|
108
|
+
score = 0.0
|
|
109
|
+
for name, weight in _WEIGHTS.items():
|
|
110
|
+
sig = signals.get(name)
|
|
111
|
+
if sig is None:
|
|
112
|
+
continue
|
|
113
|
+
# Normalise coverage to [0, 1] by capping at _COVERAGE_CAP_PER_100W.
|
|
114
|
+
normalised = min(sig.coverage_per_100_words / _COVERAGE_CAP_PER_100W, 1.0)
|
|
115
|
+
score += weight * normalised
|
|
116
|
+
return max(0.0, min(score, 1.0))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _band_for(composite: float) -> str:
|
|
120
|
+
for threshold, name in _BAND_THRESHOLDS:
|
|
121
|
+
if composite >= threshold:
|
|
122
|
+
return name
|
|
123
|
+
return "descriptive"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""FastAPI service — reflection-analyser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
|
7
|
+
from lens_contract import add_contract_routes, add_cors, upload_tempfile
|
|
8
|
+
|
|
9
|
+
from .analyser import ReflectionAnalyser
|
|
10
|
+
from .exceptions import ReflectionAnalyserError
|
|
11
|
+
from .manifest import MANIFEST
|
|
12
|
+
from .schemas import ReflectionAnalysis
|
|
13
|
+
|
|
14
|
+
_lens = ReflectionAnalyser()
|
|
15
|
+
|
|
16
|
+
app = FastAPI(
|
|
17
|
+
title="reflection-analyser",
|
|
18
|
+
description="Reflective-writing analysis — metacognition, criticality, depth (Moon-style bands)",
|
|
19
|
+
version=MANIFEST["version"],
|
|
20
|
+
docs_url="/docs",
|
|
21
|
+
redoc_url="/redoc",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
add_contract_routes(app, MANIFEST)
|
|
25
|
+
add_cors(app, env_prefix="REFLECTION_ANALYSER")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.get("/")
|
|
29
|
+
async def root() -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"service": "reflection-analyser",
|
|
32
|
+
"version": MANIFEST["version"],
|
|
33
|
+
"status": "running",
|
|
34
|
+
"endpoints": {"health": "/health", "manifest": "/manifest", "analyse": "/analyse"},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.post("/analyse", response_model=ReflectionAnalysis)
|
|
39
|
+
async def analyse(
|
|
40
|
+
file: UploadFile | None = File(None, description="Reflection file (.txt/.md or .pdf/.docx with [documents])"),
|
|
41
|
+
text: str | None = Form(None, description="Raw reflection text — use instead of file upload"),
|
|
42
|
+
) -> ReflectionAnalysis:
|
|
43
|
+
if file is None and not text:
|
|
44
|
+
raise HTTPException(status_code=422, detail="Supply either a 'file' upload or a 'text' form field")
|
|
45
|
+
if file is not None and text:
|
|
46
|
+
raise HTTPException(status_code=422, detail="Supply only one of 'file' or 'text', not both")
|
|
47
|
+
|
|
48
|
+
if text:
|
|
49
|
+
try:
|
|
50
|
+
return _lens.analyse_text(text, source_kind="text")
|
|
51
|
+
except ReflectionAnalyserError as e:
|
|
52
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
53
|
+
|
|
54
|
+
# file branch
|
|
55
|
+
content = await file.read() # type: ignore[union-attr]
|
|
56
|
+
if not content:
|
|
57
|
+
raise HTTPException(status_code=422, detail="Empty file")
|
|
58
|
+
with upload_tempfile(content, file.filename) as tmp_path: # type: ignore[union-attr]
|
|
59
|
+
try:
|
|
60
|
+
return _lens.analyse(tmp_path)
|
|
61
|
+
except ReflectionAnalyserError as e:
|
|
62
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""CLI entry point for reflection-analyser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
from lens_contract import run_contract_subcommands
|
|
12
|
+
|
|
13
|
+
from .manifest import MANIFEST
|
|
14
|
+
|
|
15
|
+
if run_contract_subcommands(
|
|
16
|
+
MANIFEST,
|
|
17
|
+
app_path="reflection_analyser.api:app",
|
|
18
|
+
default_port=8015,
|
|
19
|
+
env_prefix="REFLECTION_ANALYSER",
|
|
20
|
+
):
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="reflection-analyser",
|
|
25
|
+
description="Reflective-writing analysis — metacognition, criticality, depth (Moon-style bands)",
|
|
26
|
+
epilog="subcommands: `serve` (HTTP API on port 8015), `manifest` (capability manifest)",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("file", help="File path; or '-' to read text from stdin")
|
|
29
|
+
parser.add_argument("--json", action="store_true", dest="as_json", help="Output raw JSON")
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
_run(args)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(args) -> None:
|
|
36
|
+
from .analyser import ReflectionAnalyser
|
|
37
|
+
from .exceptions import ReflectionAnalyserError
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if args.file == "-":
|
|
41
|
+
text = sys.stdin.read()
|
|
42
|
+
result = ReflectionAnalyser().analyse_text(text, source_kind="stdin")
|
|
43
|
+
else:
|
|
44
|
+
result = ReflectionAnalyser().analyse(Path(args.file))
|
|
45
|
+
except ReflectionAnalyserError as e:
|
|
46
|
+
if args.as_json:
|
|
47
|
+
print(json.dumps({"error": str(e)}), file=sys.stderr)
|
|
48
|
+
else:
|
|
49
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
if args.as_json:
|
|
53
|
+
print(result.model_dump_json(indent=2))
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
_print_summary(result)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_summary(result) -> None:
|
|
60
|
+
print(f"Words: {result.word_count:,} Sentences: {result.sentence_count}")
|
|
61
|
+
print(f"Depth band: {result.depth_band} (composite score: {result.composite_depth_score:.2f})")
|
|
62
|
+
print()
|
|
63
|
+
print("Markers (count · per-100-words):")
|
|
64
|
+
for name, sig in (
|
|
65
|
+
("metacognition", result.metacognition),
|
|
66
|
+
("criticality", result.criticality),
|
|
67
|
+
("evidence", result.evidence),
|
|
68
|
+
("affect", result.affect),
|
|
69
|
+
("forward-looking", result.forward_looking),
|
|
70
|
+
):
|
|
71
|
+
print(f" {name:<16} {sig.count:>3} · {sig.coverage_per_100_words:.2f}/100w")
|
|
72
|
+
# Show one example sentence for the strongest marker.
|
|
73
|
+
strongest = max(
|
|
74
|
+
[("metacognition", result.metacognition), ("criticality", result.criticality),
|
|
75
|
+
("evidence", result.evidence), ("affect", result.affect),
|
|
76
|
+
("forward-looking", result.forward_looking)],
|
|
77
|
+
key=lambda kv: kv[1].count,
|
|
78
|
+
)
|
|
79
|
+
if strongest[1].examples:
|
|
80
|
+
print()
|
|
81
|
+
print(f"Strongest signal: {strongest[0]}")
|
|
82
|
+
for ex in strongest[1].examples[:2]:
|
|
83
|
+
short = ex[:160] + ("…" if len(ex) > 160 else "")
|
|
84
|
+
print(f" · {short}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Marker lexicons + matcher.
|
|
2
|
+
|
|
3
|
+
Conservative phrase-level lexicons covering the five marker families. Each
|
|
4
|
+
entry is a regex (compiled lazily on first use) so word-boundary handling
|
|
5
|
+
stays consistent. Keep entries phrase-level: matching `realised` as a bare
|
|
6
|
+
word over-fires (`he realised it was wrong` is a narrative phrase, not
|
|
7
|
+
reflection); `I realised` is the reflective form.
|
|
8
|
+
|
|
9
|
+
Adjust the lexicons — not the analyser logic — to tune the signal.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
# Patterns are case-insensitive at compile time. The lexicons below are
|
|
17
|
+
# *fragments*; we wrap them in word boundaries when compiling.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_METACOGNITION_PHRASES = [
|
|
21
|
+
r"I (?:realised|realized|came to (?:see|realise|realize|understand)|recognised|recognized)",
|
|
22
|
+
r"(?:looking|reflecting) back",
|
|
23
|
+
r"on reflection",
|
|
24
|
+
r"I (?:noticed|noted)",
|
|
25
|
+
r"I (?:learnt|learned) (?:that|how)",
|
|
26
|
+
r"I (?:think|thought|believe|believed)",
|
|
27
|
+
r"in hindsight",
|
|
28
|
+
r"I came to (?:think|believe)",
|
|
29
|
+
r"I (?:understand|understood) (?:now|that)",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_CRITICALITY_PHRASES = [
|
|
34
|
+
r"however",
|
|
35
|
+
r"on the other hand",
|
|
36
|
+
r"in contrast",
|
|
37
|
+
r"that said",
|
|
38
|
+
r"although",
|
|
39
|
+
r"despite",
|
|
40
|
+
r"nevertheless",
|
|
41
|
+
r"nonetheless",
|
|
42
|
+
r"alternatively",
|
|
43
|
+
r"by contrast",
|
|
44
|
+
r"this (?:contradicts|conflicts with|challenges)",
|
|
45
|
+
r"one (?:view|perspective) is .{0,80}? another",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Evidence: explicit reference to a specific source / quote / date / number.
|
|
50
|
+
# Note: we deliberately don't include bare 4-digit years — "I started uni in 2018"
|
|
51
|
+
# is a temporal reference, not evidence. The APA pattern below catches the
|
|
52
|
+
# evidence-shaped use ("(Smith, 2020)").
|
|
53
|
+
_EVIDENCE_PHRASES = [
|
|
54
|
+
r"according to \w+",
|
|
55
|
+
r"as (\w+\s){0,3}(?:argues|argued|writes|wrote|notes|notes that|points out)",
|
|
56
|
+
r"\([A-Z]\w+,? \d{4}\)", # in-text APA
|
|
57
|
+
r"\[[0-9]+\]", # numeric citation
|
|
58
|
+
r"\"[^\"]{8,}\"", # a quoted span ≥ 8 chars
|
|
59
|
+
r"\bp\.\s?\d+\b", # page reference
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Affect: feelings + first-person. Wrapped with "I (was|felt) X" to avoid
|
|
64
|
+
# scoring narratives like "the customer was frustrated".
|
|
65
|
+
_AFFECT_PHRASES = [
|
|
66
|
+
r"I (?:was|felt|feel|am|have been) (?:frustrated|surprised|confused|uncertain|confident|nervous|anxious|excited|proud|disappointed|overwhelmed|relieved|grateful)",
|
|
67
|
+
r"I (?:struggled|enjoyed|hated|loved|disliked|appreciated)",
|
|
68
|
+
r"(?:it|this) was (?:frustrating|surprising|exciting|disappointing|overwhelming|rewarding)",
|
|
69
|
+
r"a sense of (?:relief|achievement|frustration|confusion|pride)",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Forward-looking action / intent.
|
|
74
|
+
_FORWARD_PHRASES = [
|
|
75
|
+
r"next time",
|
|
76
|
+
r"going forward",
|
|
77
|
+
r"in (?:the )?future",
|
|
78
|
+
r"I will",
|
|
79
|
+
r"I plan to",
|
|
80
|
+
r"I intend to",
|
|
81
|
+
r"I (?:hope|want) to",
|
|
82
|
+
r"my next step",
|
|
83
|
+
r"from now on",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class CompiledLexicon:
|
|
89
|
+
name: str
|
|
90
|
+
patterns: list[re.Pattern]
|
|
91
|
+
|
|
92
|
+
def find_hits(self, text: str, sentences: list[str], *, sample_cap: int = 5) -> tuple[int, list[str]]:
|
|
93
|
+
"""Count hits across the text; return (count, sample sentences with hits)."""
|
|
94
|
+
count = 0
|
|
95
|
+
sample: list[str] = []
|
|
96
|
+
sample_seen: set[str] = set()
|
|
97
|
+
for p in self.patterns:
|
|
98
|
+
for m in p.finditer(text):
|
|
99
|
+
count += 1
|
|
100
|
+
# Find the sentence containing this hit (linear scan; sentences are typically <300).
|
|
101
|
+
pos = m.start()
|
|
102
|
+
acc = 0
|
|
103
|
+
hit_sentence = ""
|
|
104
|
+
for s in sentences:
|
|
105
|
+
acc += len(s)
|
|
106
|
+
if acc >= pos:
|
|
107
|
+
hit_sentence = s.strip()
|
|
108
|
+
break
|
|
109
|
+
if hit_sentence and hit_sentence not in sample_seen and len(sample) < sample_cap:
|
|
110
|
+
sample.append(hit_sentence)
|
|
111
|
+
sample_seen.add(hit_sentence)
|
|
112
|
+
return count, sample
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _compile(name: str, phrases: list[str]) -> CompiledLexicon:
|
|
116
|
+
"""Compile each phrase, wrapping with word boundaries *only* where the phrase
|
|
117
|
+
starts/ends with a word character. Patterns that already use punctuation
|
|
118
|
+
anchors (`(Author, 2020)`, `"quoted span"`, `[42]`) need no boundary —
|
|
119
|
+
`\\b` next to non-word chars over-restricts (fails the inner-quote case).
|
|
120
|
+
"""
|
|
121
|
+
compiled: list[re.Pattern] = []
|
|
122
|
+
for phrase in phrases:
|
|
123
|
+
prefix = r"\b" if _first_real_char_is_word(phrase) else ""
|
|
124
|
+
suffix = r"\b" if _last_real_char_is_word(phrase) else ""
|
|
125
|
+
compiled.append(re.compile(prefix + phrase + suffix, re.IGNORECASE))
|
|
126
|
+
return CompiledLexicon(name=name, patterns=compiled)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_WORD_RE = re.compile(r"\w")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _first_real_char_is_word(phrase: str) -> bool:
|
|
133
|
+
"""Skip leading regex meta-chars to find the first 'real' character."""
|
|
134
|
+
skip = set(r"(?:\\")
|
|
135
|
+
for c in phrase:
|
|
136
|
+
if c in skip:
|
|
137
|
+
continue
|
|
138
|
+
return bool(_WORD_RE.match(c))
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _last_real_char_is_word(phrase: str) -> bool:
|
|
143
|
+
skip = set(r")?:")
|
|
144
|
+
for c in reversed(phrase):
|
|
145
|
+
if c in skip:
|
|
146
|
+
continue
|
|
147
|
+
return bool(_WORD_RE.match(c))
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Lazily-built singletons — compile on first use.
|
|
152
|
+
_LEXICONS: dict[str, CompiledLexicon] | None = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def lexicons() -> dict[str, CompiledLexicon]:
|
|
156
|
+
global _LEXICONS
|
|
157
|
+
if _LEXICONS is None:
|
|
158
|
+
_LEXICONS = {
|
|
159
|
+
"metacognition": _compile("metacognition", _METACOGNITION_PHRASES),
|
|
160
|
+
"criticality": _compile("criticality", _CRITICALITY_PHRASES),
|
|
161
|
+
"evidence": _compile("evidence", _EVIDENCE_PHRASES),
|
|
162
|
+
"affect": _compile("affect", _AFFECT_PHRASES),
|
|
163
|
+
"forward": _compile("forward", _FORWARD_PHRASES),
|
|
164
|
+
}
|
|
165
|
+
return _LEXICONS
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Capability manifest for the lens family (consumed by auto-analyser)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from lens_contract import make_manifest
|
|
5
|
+
|
|
6
|
+
# Explicit-only — same pattern as conversation-analyser. Prose extensions already
|
|
7
|
+
# auto-route to document-analyser; reflection is a different interpretation of
|
|
8
|
+
# the same words (depth/metacognition rather than readability).
|
|
9
|
+
MANIFEST = make_manifest(
|
|
10
|
+
name="reflection-analyser",
|
|
11
|
+
accepts=["reflection", "journal", "metacognition"],
|
|
12
|
+
extensions=[], # explicit-only — invoke deliberately
|
|
13
|
+
auto_routable=False,
|
|
14
|
+
produces="ReflectionAnalysis",
|
|
15
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pydantic schemas for reflection-analyser output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MarkerSignal(BaseModel):
|
|
8
|
+
"""Counts + sample for one reflection-marker family."""
|
|
9
|
+
|
|
10
|
+
count: int = 0
|
|
11
|
+
coverage_per_100_words: float = Field(
|
|
12
|
+
0.0,
|
|
13
|
+
description="Hits per 100 words — a length-normalised proxy for marker density.",
|
|
14
|
+
)
|
|
15
|
+
examples: list[str] = Field(
|
|
16
|
+
default_factory=list,
|
|
17
|
+
description="First few sentences where the marker fired (capped, for transparency).",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReflectionAnalysis(BaseModel):
|
|
22
|
+
"""Top-level result returned by ReflectionAnalyser.analyse* methods."""
|
|
23
|
+
|
|
24
|
+
word_count: int = 0
|
|
25
|
+
sentence_count: int = 0
|
|
26
|
+
|
|
27
|
+
# Per-marker signals
|
|
28
|
+
metacognition: MarkerSignal
|
|
29
|
+
criticality: MarkerSignal
|
|
30
|
+
evidence: MarkerSignal
|
|
31
|
+
affect: MarkerSignal
|
|
32
|
+
forward_looking: MarkerSignal
|
|
33
|
+
|
|
34
|
+
# Composite scoring
|
|
35
|
+
composite_depth_score: float = Field(
|
|
36
|
+
0.0,
|
|
37
|
+
description="0–1; weighted blend of per-marker coverages (see analyser._compute_depth).",
|
|
38
|
+
ge=0.0,
|
|
39
|
+
le=1.0,
|
|
40
|
+
)
|
|
41
|
+
depth_band: str = Field(
|
|
42
|
+
"descriptive",
|
|
43
|
+
description="descriptive | dialogic | critical | transformative",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Provenance / source-of-input
|
|
47
|
+
source_kind: str = Field(
|
|
48
|
+
"text",
|
|
49
|
+
description="'text' | 'file:txt' | 'file:md' | 'file:pdf' | 'file:docx' | …",
|
|
50
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reflection-analyser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reflective-writing analysis — metacognition, criticality, depth (Moon-style bands)
|
|
5
|
+
Project-URL: Homepage, https://github.com/michael-borck/reflection-analyser
|
|
6
|
+
Project-URL: Repository, https://github.com/michael-borck/reflection-analyser
|
|
7
|
+
Project-URL: Issues, https://github.com/michael-borck/reflection-analyser/issues
|
|
8
|
+
Author-email: Michael Borck <michael.borck@curtin.edu.au>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: assessment,education,lens,metacognition,reflection,udl,writing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: fastapi>=0.109.0
|
|
20
|
+
Requires-Dist: lens-contract>=0.2.0
|
|
21
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
22
|
+
Requires-Dist: rich>=13.7.0
|
|
23
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Provides-Extra: documents
|
|
29
|
+
Requires-Dist: document-analyser>=0.6.0; extra == 'documents'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# reflection-analyser
|
|
33
|
+
|
|
34
|
+
**Reflective-writing analysis** — the [lens-family](https://github.com/michael-borck/lens-analysers)
|
|
35
|
+
member that reads a learning journal / reflection / portfolio entry as **reflection**, not just
|
|
36
|
+
as prose.
|
|
37
|
+
|
|
38
|
+
> `document-analyser` reads readability; this reads *reflective depth*. Different signals from
|
|
39
|
+
> the same words. **Explicit-only** (`auto_routable: false`) — same pattern as
|
|
40
|
+
> `conversation-analyser`: text and prose extensions auto-route to `document-analyser`; invoke
|
|
41
|
+
> `reflection-analyser` deliberately when you want the reflective-depth interpretation.
|
|
42
|
+
|
|
43
|
+
Built around the markers commonly used in reflective-writing rubrics
|
|
44
|
+
([Moon's depth scale](https://www.tandfonline.com/doi/abs/10.1080/0307507990240207),
|
|
45
|
+
Gibbs' reflective cycle, the SOLO taxonomy): metacognition, criticality, evidence linkage,
|
|
46
|
+
affect language, and forward-looking action.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install reflection-analyser
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Optional: read `.pdf` / `.docx` / `.pptx` journals (otherwise plain-text / `.md` only):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install 'reflection-analyser[documents]'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Use
|
|
61
|
+
|
|
62
|
+
**Python:**
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from reflection_analyser import ReflectionAnalyser
|
|
66
|
+
|
|
67
|
+
# From text directly
|
|
68
|
+
result = ReflectionAnalyser().analyse_text("Looking back, I realised that…")
|
|
69
|
+
|
|
70
|
+
# From a file (composes on document-analyser for binary docs when [documents] is installed)
|
|
71
|
+
result = ReflectionAnalyser().analyse("journal.md")
|
|
72
|
+
result = ReflectionAnalyser().analyse("journal.docx") # requires [documents]
|
|
73
|
+
|
|
74
|
+
print(result.depth_band) # "dialogic"
|
|
75
|
+
print(result.composite_depth_score) # 0.62
|
|
76
|
+
print(result.metacognition.count) # 7
|
|
77
|
+
print(result.criticality.count) # 3
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**CLI:**
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
reflection-analyser journal.md
|
|
84
|
+
reflection-analyser journal.txt --json
|
|
85
|
+
reflection-analyser journal.docx # needs [documents] extra
|
|
86
|
+
echo "Looking back…" | reflection-analyser -
|
|
87
|
+
reflection-analyser serve
|
|
88
|
+
reflection-analyser manifest
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**HTTP** (`reflection-analyser serve` on port 8015):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
curl -F file=@journal.md http://localhost:8015/analyse
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Signals
|
|
98
|
+
|
|
99
|
+
For a piece of reflective writing:
|
|
100
|
+
|
|
101
|
+
- **Metacognition** — first-person + cognitive verbs (`I realised`, `I noticed`, `looking back`,
|
|
102
|
+
`on reflection`). Surface depth indicator.
|
|
103
|
+
- **Criticality** — contrast/qualification phrases (`however`, `in contrast`, `that said`,
|
|
104
|
+
`on the other hand`). Marker of dialogic vs descriptive reflection.
|
|
105
|
+
- **Evidence** — references to specific moments, sources, dates, quotes — proper-noun and
|
|
106
|
+
citation density. Concrete vs abstract.
|
|
107
|
+
- **Affect** — emotion words (`frustrated`, `surprised`, `confident`, `uncertain`). Too few =
|
|
108
|
+
clinical; presence indicates engagement.
|
|
109
|
+
- **Action / forward-looking** — `next time`, `going forward`, `I will`, future-tense intent.
|
|
110
|
+
Marker of transformative reflection.
|
|
111
|
+
|
|
112
|
+
**Composite depth score** (0–1) combines per-marker coverages; mapped to a Moon-style band:
|
|
113
|
+
|
|
114
|
+
| Band | Score | Description |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| descriptive | 0.0–0.25 | "What happened" only — events recounted, little interpretation |
|
|
117
|
+
| dialogic | 0.25–0.5 | Some self-questioning + critical thought |
|
|
118
|
+
| critical | 0.5–0.75 | Multiple perspectives, evidence linkage, qualification |
|
|
119
|
+
| transformative | 0.75–1.0 | Forward-looking insight, evidence-linked, change-oriented |
|
|
120
|
+
|
|
121
|
+
The score is a **signal, not a grade** — it's meant to inform human judgement, not replace it.
|
|
122
|
+
|
|
123
|
+
## The family
|
|
124
|
+
|
|
125
|
+
| What you want | Use |
|
|
126
|
+
|---|---|
|
|
127
|
+
| Document text + readability | **document-analyser** |
|
|
128
|
+
| Reflective depth on that text | **reflection-analyser** (this) |
|
|
129
|
+
| Human-AI conversation analysis | **conversation-analyser** |
|
|
130
|
+
| Any file → right engine | **auto-analyser** |
|
|
131
|
+
|
|
132
|
+
## Limits
|
|
133
|
+
|
|
134
|
+
- Lexicon-based v1 — fast, transparent, but catches phrasing not meaning. A reflective sentence
|
|
135
|
+
without our trigger words underscores; a non-reflective sentence with `I realised` overscores.
|
|
136
|
+
- English-only for v1.
|
|
137
|
+
- Calibrated against generic reflective-writing rubrics; tune the band thresholds in
|
|
138
|
+
`_BAND_THRESHOLDS` for your unit's specific rubric if needed.
|
|
139
|
+
- Vision/LLM-augmented depth scoring is a possible follow-on; not in v1.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
reflection_analyser/__init__.py,sha256=B8tvujot2eLbSySUHVOYAxXkydlvaKpOnHrSHOvYIFc,359
|
|
2
|
+
reflection_analyser/analyser.py,sha256=8aMSG-UBMYKL4jenPP8BM3UTeVTS6b3CmHVhm2ElWcE,4478
|
|
3
|
+
reflection_analyser/api.py,sha256=cCQcb_YwUZFcw8yrGBN4iTPhdkT3xGLVPaVWEbl_ZXQ,2341
|
|
4
|
+
reflection_analyser/cli.py,sha256=JrEw8WGdO9Z_-VO5FFjGAihA1iFZTM_3qUetT-q5wqo,2908
|
|
5
|
+
reflection_analyser/exceptions.py,sha256=QXOzgTd0Smk-L82ZC2Po9XTmRhYBt3xnohatihdDLV4,136
|
|
6
|
+
reflection_analyser/lexicon.py,sha256=4hIVzk5aaBYaM4gECs84cjFA1OzKNSOQZnx7OM0DOF8,5798
|
|
7
|
+
reflection_analyser/manifest.py,sha256=r7bR0NNsOdiCoCMsNXeo074_VvZbIMsRVu_q99R12UI,615
|
|
8
|
+
reflection_analyser/schemas.py,sha256=AMAZuapscM0Q2ZbUTS2kOC43so-kp4YBu1D-8779Eh8,1427
|
|
9
|
+
reflection_analyser-0.1.0.dist-info/METADATA,sha256=DwQ6KgOAcyrDVd-DbRwPMlU43dZ_7TaEcF7dgHmCpqc,5510
|
|
10
|
+
reflection_analyser-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
reflection_analyser-0.1.0.dist-info/entry_points.txt,sha256=cEGrd0TrLpaV75stmJEurQUqIWI3wm9qySstOS52pXQ,69
|
|
12
|
+
reflection_analyser-0.1.0.dist-info/licenses/LICENSE,sha256=tP28nznO05HKBQN8uZ9NSvmcpzCWMj4P8e6F7IDj24Q,1070
|
|
13
|
+
reflection_analyser-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Borck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|