Skip to content

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

Next: Milestone 6 — Generated Answer Pipeline