Concrete roadmap for ADR 012: make FeedForward consume the lens analyser family for deterministic signals, map those signals to rubric categories, and let the instructor review/adjust before release. Signals are evidence alongside the LLM, not a replacement.
Decisions already locked (see ADR 012): consume lens (don't build our own analysis); call analysers over HTTP with
auto-analyseras the router; store signal scores as a synthetic model run so they flow through the existing aggregation untouched; reuse the existing instructor-approval fields.
Student submission
│ (existing) extract_file_content → text
▼
FeedbackGenerator.generate_feedback_for_draft(draft_id)
├── LLM runs ─────────────► CategoryScore rows (existing)
└── SignalService.extract(draft) ← NEW
│ HTTP POST /analyse → auto-analyser → document/code/… analyser
│ (explicit: cite-sight, conversation-analyser)
▼
signals table (raw, queryable, survives content deletion)
▼
SignalScorer.score(draft) ← NEW
│ apply signal_rules (signal → 0–100 + confidence)
▼
synthetic ModelRun ──► CategoryScore rows
▼
_aggregate_feedback (UNCHANGED — blends LLM + signal CategoryScores per category)
▼
AggregatedFeedback (status: pending_review)
▼
Instructor review UI: signals + LLM + estimate → adjust → approved → released ← NEW
▼
Student sees released feedback (existing visualization)
The seam was NOT already there (2026-05-23 architecture review): the handler
preprocess() is dead code and the generator hardcodes "run LLM" as the only evidence
producer. The locked design below introduces the seam properly.
Settled in a grilling session on the architecture review (recorded in ADR 012 §6):
-
Handler = slim declaration seam (A3).
AssessmentHandlerrebuilt from in-process constants (no DB at import); declaresvalidate_submission,get_signal_sources(),get_prompt_template,format_feedback, and a thinshape_for_prompt(text)(code → line numbers, math → LaTeX-readable, essay → identity). Inline metric computation deleted (lens computes metrics; extraction stays infile_handlers.py). Theassessment_typestable becomes display metadata. The generator resolves the handler fromassignment.assessment_type_id(defaultessay). -
Evidence-source seam (B + C are one seam).
EvidenceSource.produce(draft, assignment, settings)with two adapters:LLMEvidenceSource,SignalEvidenceSource. The generator holds an injectedsourceslist (default[LLM]; signal added when enabled), gathers them, aggregates the union. Persistence is thin: each source writes its ownModelRun+CategoryScores (as_run_single_modeldoes today) but takes an injectedmodel_provider(litellm) /signal_client(lens HTTP). Pure-result value objects are a later deepening. -
Aggregation refinements (not a zero-change). The signal run must join the
successful_runsset that_aggregate_feedbackiterates (feedback_generator.py:541), and that method must writestatus="pending_review"(line 615), not"approved", to enable the §5 instructor review. -
Testing. No data-access port is injected: tests point
dbat a throwaway file via theDATABASE_PATHenv var inconftest.py(db is built from it at import, user.py:13). Only the network ports (model_provider,signal_client) are faked. Order: introduce the seam, then tests (the thin refactor is mechanical; mock-feedback path is the interim smoke check). -
Row-lookup slice (D). A small
app/utils/db_query.py(by_id,where,first,next_id) that retires the racy_get_next_id; used for the newsignals/signal_rulesreads and the generator's own scans we're already editing. The ~100 route scans are left for later.
Revised ordering: settle the handler seam (A3) + stand up the evidence-source seam +
the db_query slice before S2 scoring. S0/S1 (sidecar + read-only signals) can run in
parallel — they don't depend on the generator refactor.
document-analyser 0.3.1 runs from its own .venv:
/Users/michael/Projects/lens/document-analyser/.venv/bin/document-analyser serve --port 8000.
- ✅
/health,/manifest, and/text(POST{"text": ...}) work./textreturns the full essay signal set — readability (flesch_score, FK grade), writing quality (passive %, sentence variety, academic tone, transitions, hedging), word analysis (vocabulary_richness, n-grams), and NER — in ~0.5s. - ✅ Integration uses
/textwith already-extracted text, NOT the file-upload/analyse./analyse500s in 0.3.1 (referencesflesch_reading_ease/gunning_fog/smog_index/automated_readability_index— none exist onDocumentAnalysis; it hasflesch_score+flesch_kincaid_grade)./textsidesteps this and fits FeedForward, which already extracts text infile_handlers.py. - ✅
/semantic/sentimentmacOS crash FIXED (lens-side, 2026-05-24: mmap'd safetensors weights cloned onto the heap inGranularSentimentAnalyzer.__init__; see document-analyserINTEGRATION-NOTES.md). Re-verified live 2026-06-12: sentiment request returns sentence/paragraph/document-level results and the server stays up; FeedForward end-to-end extraction stores 18 signals includingsentiment_{positive,negative,neutral,compound}, and the "Tone" auto-match consumes them.
One optional lens-side fix remains (separate repo, Michael owns it): the one-spot
/analyse attribute fix. It doesn't block anything — integration uses /text.
| Signal source | Example signals | Maps naturally to |
|---|---|---|
| document-analyser (core) | flesch_score, flesch_kincaid_grade, paragraph_count, avg_words_per_sentence, sentence_variety, vocabulary_richness | Clarity, Structure/Organisation, Vocabulary |
document-analyser [nlp] |
sentiment compound, granular sentiment, NER, overall_coherence_score, sentence-dislocation |
Coherence, Tone, Topic focus — sentiment live via /semantic/sentiment (macOS crash fixed 2026-05-24); NER works via /text |
| document-analyser (WritingQuality) | passive_voice_percentage, transition_words, hedging_language, academic_tone | Academic writing, Style |
| cite-sight | integrity_score, missing_in_text, orphaned_in_text, broken_urls, unresolved_dois | Referencing / Academic integrity |
| code-analyser | cyclomatic complexity, lint violations, docstring/type coverage | Code quality, Documentation, Style |
| conversation-analyser | critical-thinking score, engagement band, pushback ratio | Process / Critical thinking |
Note: not every rubric category has a signal. Categories like "depth of argument" stay LLM-only. That is by design.
-
Seed model — one
ai_modelsrow:Signal Engine (lens),provider="signal",owner_type="system". (No schema change; uses existing table.) -
signals(new) — queryable raw signals; survives content deletion.id, draft_id, source (e.g. "document-analyser"), name (e.g. "flesch_score"), value (float), raw (str/JSON, nullable), created_at -
signal_rules(new) — signal → category transform.id, rubric_category_id, signal_source, signal_name, transform (JSON: {type: "band"|"linear", params: {...}}), weight (float), enabled (bool)Transforms:
band= piecewise thresholds → score;linear= clamp+scale. Seeded with per-assessment-type defaults; instructor-overridable per assignment.
(ModelRun.raw_response already stores the full analyser JSON for the signal run; no
change needed there.)
| Module | Responsibility |
|---|---|
app/utils/analyser_client.py |
HTTP client: base URLs from config/env, health(), analyse(file/text, source), timeout + graceful degradation (analyser down → return empty, log, continue) |
app/services/signal_service.py |
Orchestrate extraction per assessment type → write signals rows. Handler declares its sources via new AssessmentHandler.get_signal_sources() |
app/services/signal_scorer.py |
Load signal_rules, apply transforms → CategoryScore under the synthetic ModelRun |
(edit) app/services/feedback_generator.py |
Kick off SignalService + SignalScorer alongside LLM runs |
| (new UI) instructor review route | Render signals + LLM + estimate; edit + approve via existing AggregatedFeedback status fields |
- Stand up
document-analyser serveon:8000with the[nlp]extra; confirmauto-analyserroutes a.pdf/.docxto it. - Add
analyser_client.pywith a health-check; smoke-test from a throwaway script. - Exit: a script posts a sample essay and prints back signals JSON.
- For an essay draft, call document-analyser, persist to
signals. - Show the raw signals on the instructor submission-review page (no scoring yet).
- Exit: instructor can see real readability/structure/sentiment signals for a real submission, end to end. Validates the whole round-trip with one analyser, one type.
- ✅ Landed 2026-05-24:
app/models/signals.py,app/utils/analyser_client.py(POST/text, graceful degradation),app/services/signal_service.py(extract + flatten + persist, idempotent), background extraction fired at submission (student/submissions.py), and a read-only panel at/instructor/submissions/{id}/signalslinked from the submissions list. Round-trip smoke verified (14 signals; graceful when analyser down). Caveats: signals render on a dedicated route, not the detail view (which has pre-existing staleModelRunfield refs); the panel was compile-checked but not yet rendered in a live browser (the app needs a.envto import —app/utils/email.pyreads it at import time); sentiment deferred (macOS semantic crash).
- Add
signal_rules+ essay defaults; implementsignal_scorer.py+ syntheticModelRun; emitCategoryScorerows. - Show estimated per-category signal scores next to LLM scores; confirm aggregation blends them.
- Exit: a draft shows LLM score, signal score, and blended aggregate per category.
- ✅ Landed 2026-05-24:
signal_rulesmodel +signal_scorer(band/linear transforms, weighted per-category score + confidence); auto-match signals→categories by name (suggest_rules_for_category); read-only estimates on the instructor panel; synthetic Signal Engine run (signal_evidence.produce_signal_run, sentinelmodel_id=-1) blended into live aggregation in both paths (best-effort; auto-release kept). 32 tests green. Deferred: task 13 architectural deepening since landed in three phases (Ddb_queryhelper:app/utils/db_query.py+ ~14 call sites refactored; BEvidenceSourceseam:app/services/evidence.pywithLLMEvidenceSource+SignalEvidenceSource, orchestration iterates sources uniformly with explicit "≥1 LLM run" policy; A3 slim handler:AssessmentHandlercollapsed from 4-abstract-method ABC + 1059-LOC unused concretes to a declaration dataclass with a singleevidence_sourcesfactory, 4 handlers reduced to slim instances, generator delegates source construction to the handler);pending_review+ instructor approval UI (S3, since landed); confirm/override rules UI (since landed:signal_rules_service+/instructor/assignments/{id}/signal-rules); sentiment signals (since landed — document-analyser 0.5.1 sentiment fix + FeedForward extraction & "Tone" auto-match).
- Wire
AggregatedFeedback.status(pending_review → approved → released) andedited_by_instructorinto a review UI. - Instructor adjusts the mark with signals visible, then releases to the student.
- Exit: nothing reaches the student until an instructor approves.
- ✅ Landed 2026-05-24: feedback now generated as
pending_review;app/services/feedback_review.py(release filter +apply_review); rebuilt per-category instructor review UI (/reviewGET +/review/savePOST) showing signals + estimate, editable score/feedback, Approve&release / Save-draft; student view gated to released feedback with an "awaiting review" state, andrender_enhanced_feedbackrewritten for the real per-category schema. Also fixed the root cause that broke all async POST handlers (the auth decorators were sync wrappers — now async-aware inapp/__init__.py), which also repairs student submit. Verified end-to-end in the running app (pending → approve → released). Note: same-path GET+POST doesn't dispatch by method in FastHTML here, so POST handlers need a distinct path (or to be namedpost).
-
code →
code-analyser(:8004); citations →cite-sight(HTTP); route everything throughauto-analyser; per-type rule defaults. -
Exit: code and essay assignments both produce signals via the same pipeline.
-
✅ code half landed 2026-06-12:
analyser_client.analyse_code(multipart to:8004,CODE_ANALYSER_URLoverride);signal_serviceroutes by assessment type (registry.type_code_for_assignment, used by the generator too — it previously hardcodedDEFAULT_HANDLER); code response flattened to 18 signals (incl.syntax_valid/has_main_guardas 0/1); filename for language detection fromsubmission_files.original_filename, content-sniffed fallback for text-only submissions; auto-match suggestions are now per assessment type (a code "Style" category maps to lint warnings, not passive voice) andsignal_rules_servicepersists the rightsignal_source; code extensions +.mdnow extract as plain text infile_handlers(previously rejected, so code uploads couldn't submit at all). 146 tests green (19 new intests/test_code_signals.py); verified live end-to-end against code-analyser 0.6.0. Remaining for S4: cite-sight, auto-analyser routing. -
✅ cite-sight half landed 2026-06-12:
analyser_client.analyse_citations(multipart to:3001/analyse,CITE_SIGHT_URL/CITE_SIGHT_TIMEOUToverrides;CITE_SIGHT_VERIFY=falsedisables the Crossref/OpenAlex + URL network tier, local cross-referencing always on).signal_servicerefactored to multiple sources per assessment type (essay → document-analyser + cite-sight), each idempotent and independently degrading — a re-run backfills a source that was down without duplicating the others. Citation flattening guards verification-dependent signals (aformat_onlyrun emits no verified-counts, so "verification off" can't read as "all citations bogus"). Referencing/citation categories auto-match tocitation_integrity_pct, orphaned-reference and format-issue bands (suggestion tuples can now carry a per-signal source). Verified live: fabricated reference → not_found, uncited bibliography entry → orphan, Referencing estimate 48.3/100. 11 new tests. -
⊘ auto-analyser routing — deliberately NOT adopted (decided 2026-06-13). The plan originally said "route everything through
auto-analyser". On review that adds cost without benefit for FeedForward's architecture:- auto-analyser's value is format auto-detection — routing an unknown
file to the right analyser. FeedForward always knows the assessment type
(the instructor declares it per assignment), so
signal_service._sources_for_typealready routes precisely, by type, with no detection needed.type_code_for_assignmentis more accurate than content-sniffing (an essay that quotes code wouldn't be misrouted). - auto-analyser only does routing. FeedForward still needs its own
per-analyser flatteners (
_flatten_text/code/citation_response) and per-type category mappings — that's the real work, and auto-analyser can't collapse it. So adopting it removes a two-line dict lookup and adds a fourth always-on sidecar (:8010) to keep healthy. - It also can't express "essay → two sources (document + cite-sight)";
that fan-out lives correctly in
_sources_for_type.
Revisit only if FeedForward grows a genuine "mixed/unknown submission" type (e.g. a zip of arbitrary files), where bundle-/auto-analyser would earn their keep. Until then, S4 is complete: code and essay assignments both produce signals via the same pipeline (the S4 exit criterion), routed explicitly.
- auto-analyser's value is format auto-detection — routing an unknown
file to the right analyser. FeedForward always knows the assessment type
(the instructor declares it per assignment), so
- Tests first for all new code (the repo currently has no active suite — start the suite here).
- Fallback behaviour when an analyser is offline; pin lens versions.
- Privacy lifecycle: confirm
signalsare retained while draftcontentis deleted. - Remove the hardcoded auth bypass in
app/routes/auth.py. - Instructor docs for editing
signal_rules.
- Default signal→score bands per assessment type (needs a few real submissions to tune).
Tooling landed 2026-06-13:
/instructor/assignments/{id}/signal-calibration(signal_calibration.calibration_for_assignment) compares signal estimates against instructor-released scores per category and surfaces systematic bias — the evidence loop for retuning rules as real submissions accumulate. - How to weight the signal run vs LLM runs in aggregation (start: equal; expose later).
- Whether to surface signal contributions to students or only the blended score.
- lens maturity: use HTTP/library, not document-analyser's CLI (known bug); pin versions.
- ML deps stay in the sidecar, never in FeedForward's process.
- Missing signals → add them in the relevant lens analyser, not in FeedForward.