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.
@@ -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,2 @@
1
+ class ReflectionAnalyserError(Exception):
2
+ """Domain error from reflection-analyser (empty input, unreadable doc, missing extra)."""
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reflection-analyser = reflection_analyser.cli:main
@@ -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.