Milestone 5 — Consulting Readouts
Status: Complete
Completed: 2026-06-07
Service: readout-service (implemented within the unified Raw to Knowledge service in MVP)
Goal
Generate pre-meeting and post-meeting readouts that surface validated answers by Horn type, scope, and customer context. This is the first milestone where nontechnical stakeholders see visible value from the governed registry.
Stakeholder story: "Consultants walk into meetings with institutional memory."
Delivered Components
Domain Layer
| File | Purpose |
|---|---|
domain/models/readout.py |
ReadoutItem, ReadoutSection, ConflictSummaryItem, PreMeetingReadout, UnansweredQuestion, PostMeetingAssessment |
domain/services/readout_builder.py |
ReadoutBuilder — assembles readout value objects from pre-fetched registry answers; no I/O |
domain/services/readout_exporter.py |
MarkdownExporter, PDFExporter, ReadoutExporterProtocol |
Application Layer
| File | Purpose |
|---|---|
application/readout/readout_service.py |
ReadoutService — orchestrates registry queries and builder delegation |
API Layer
| Route | Method | Description |
|---|---|---|
/v1/readouts/pre-meeting |
GET |
Structured pre-meeting readout (JSON) |
/v1/readouts/pre-meeting/export |
GET |
Pre-meeting readout as Markdown or PDF |
/v1/readouts/post-meeting |
POST |
Structured post-meeting assessment (JSON) |
/v1/readouts/post-meeting/export |
POST |
Post-meeting assessment as Markdown or PDF |
Data Flow
Pre-meeting readout
GET /v1/readouts/pre-meeting?customer_scope=Acme&meeting_date=2026-06-15
→ ReadoutService.generate_pre_meeting()
→ registry.query(scope=Acme, status=APPROVED)
→ registry.query(scope=Acme, status=DISPUTED)
→ ReadoutBuilder.build_pre_meeting()
→ groups answers by HornType
→ attaches expiry warnings (review_due within 30 days of meeting_date)
→ builds ConflictSummaryItems from disputed answers
→ returns PreMeetingReadout with all 9 sections
→ returns JSON
Post-meeting assessment
POST /v1/readouts/post-meeting
body: { customer_scope, session_date, question_texts[] }
→ ReadoutService.generate_post_meeting()
→ registry.query(scope=customer_scope, status=APPROVED)
→ ReadoutBuilder.build_post_meeting()
→ for each question: keyword overlap match against approved answers
→ answered: questions with ≥2-token overlap with an answer
→ unanswered: questions with no match → candidates for Flow B (M6)
→ coverage_rate = answered / total
→ returns JSON
Key Design Decisions
Scope isolation via query filtering
Scope isolation is enforced at the RegistryRepository.query() call by passing customer_scope as a filter parameter. The ReadoutBuilder never touches the registry — it operates only on the answers the application layer passes in. This means the quality gate (Customer A answers never appear in Customer B's readout) is tested both at the builder level (unit test: verify builder only outputs what it receives) and at the service level (integration test: verify repository filter is passed correctly).
Disputed answers surfaced separately
Disputed answers are fetched independently and placed only in conflict_summary, never in sections. This prevents contested content from appearing as authoritative knowledge in the main readout body. The API consumer can inspect conflict_summary and take governance action.
Expiry warnings tied to meeting date, not today
For pre-meeting readouts, expiry warnings use the meeting_date parameter as the reference date rather than today. An answer due in 25 days is expiring-soon for a meeting next week and for a meeting in three weeks. Using the meeting date as the reference ensures the warning is relevant to the actual engagement.
Keyword matching as M6 placeholder
Post-meeting question-to-answer matching uses a simple keyword overlap heuristic (minimum 2 shared tokens). This is an explicit placeholder for the Weaviate semantic search integration in Milestone 6. The _find_best_match method is isolated in ReadoutBuilder so it can be replaced without changing the builder's public API.
PDF export via fpdf2
PDF export uses fpdf2 (pure Python, no system dependencies). The PDFExporter fails fast at instantiation if fpdf2 is not installed — the API returns 501 Not Implemented rather than a runtime crash. The fpdf2 package is declared in pyproject.toml production dependencies.
Domain Models
PreMeetingReadout
class PreMeetingReadout(BaseModel, frozen=True):
readout_id: str # stable UUID per generation
customer_scope: str # customer context
meeting_date: date # upcoming meeting
generated_at: datetime # assembly timestamp
sections: tuple[ReadoutSection, ...] # one per HornType (always 9)
conflict_summary: tuple[ConflictSummaryItem, ...] # disputed answers
expiry_warning_count: int # answers expiring within window
total_approved_in_scope: int # total approved answers queried
PostMeetingAssessment
class PostMeetingAssessment(BaseModel, frozen=True):
assessment_id: str
customer_scope: str
session_date: date
generated_at: datetime
answered: tuple[ReadoutItem, ...] # matched to registry answers
unanswered: tuple[UnansweredQuestion, ...] # no validated match found
coverage_rate: float # 0.0–1.0
Quality Gates
| Gate | Criterion | Automated |
|---|---|---|
| Readout completeness | All 9 Horn types present; empty sections carry a note | Yes — unit + integration |
| Expiry warnings | Answers within 30 days of review_due flagged |
Yes — unit + integration |
| Scope isolation | Customer A answers absent from Customer B readout | Yes — integration |
| Disputed exclusion | Disputed answers in conflict_summary only; not in sections |
Yes — unit + integration |
| Post-meeting coverage | coverage_rate is correct fraction of matched questions |
Yes — unit |
| Markdown export | Export produces valid string with expected content | Yes — unit + integration |
Configuration
One new setting in raw_to_knowledge.config.Settings:
readout_expiry_warning_days: int = 30 # env: READOUT_EXPIRY_WARNING_DAYS
API Reference
GET /v1/readouts/pre-meeting
| Parameter | Type | Required | Description |
|---|---|---|---|
customer_scope |
string | Yes | Customer context to scope the readout |
meeting_date |
date (YYYY-MM-DD) | Yes | Upcoming meeting date |
Returns PreMeetingReadoutOut (JSON).
GET /v1/readouts/pre-meeting/export
Same parameters as above, plus:
| Parameter | Type | Default | Description |
|---|---|---|---|
format |
markdown | pdf |
markdown |
Export format |
Returns text/markdown or application/pdf.
POST /v1/readouts/post-meeting
Request body:
{
"customer_scope": "Acme Corp",
"session_date": "2026-06-07",
"question_texts": [
"Does the platform support localized help sites?",
"What is the DITA 1.3 migration path?"
]
}
Returns PostMeetingAssessmentOut (JSON).
POST /v1/readouts/post-meeting/export
Same body as above, plus "format": "markdown" | "pdf".
Returns text/markdown or application/pdf.
Test Coverage
| File | Tests | Focus |
|---|---|---|
tests/unit/test_readout_builder.py |
25 | Builder correctness, scope isolation, expiry, coverage rate |
tests/unit/test_readout_exporter.py |
21 | Markdown rendering, all sections present, warnings in output |
tests/integration/test_readout_roundtrip.py |
11 | Full pipeline against live Postgres |