Goal: Add a robust, offline PDF report generator (siw-brain report) that converts
LeadCard JSONL outputs into a human-readable, A4 PDF report. The design must fit
the current project conventions (fail-closed, encoding-safe I/O, stderr logging,
no sensitive outputs), and must be reliable in Windows PowerShell pipelines.
This plan is written in ASCII per repository editing constraints.
Decision:
- Canonical input format: JSONL where each line is a LeadCard object.
- Accepted compatibility format: JSONL where each line is an object containing "card" (LeadCard) and "source_meta" (harvest output).
- Input source: file path via --in, or stdin if --in "-" or omitted with piped input.
Rationale:
- The documentation encourages writing pure LeadCard JSONL (see USAGE_NON_TECH.md).
- The
harvestCLI outputs a wrapper object with "card". - Accepting both avoids user friction and aligns with "last mile" intent.
- Canonical format keeps the report tool independent of harvest details.
Normalization rule:
- If parsed line is a dict with a "card" dict field, use the "card" value.
- Else if parsed line is a dict, treat it as a LeadCard.
- Else treat as invalid line.
New package path:
- src/siw_intent_brain/report/
Files:
- reader.py
- curator.py
- render_pdf.py
- init.py
Primary public API:
- read_candidates_jsonl(path) -> (records, invalid_lines)
- select_top(records, top_n) -> list[LeadCard]
- compute_stats(records) -> dict
- render_report(records, stats, invalid_lines, out_path) -> None
Function: read_candidates_jsonl(path_or_text, from_stdin=False) -> tuple[list[dict], list[dict]]
Behavior:
- If from_stdin: read sys.stdin.read() (respect current stdin encoding).
- Else use read_text_file() from src/siw_intent_brain/io_utils.py to support: utf-8-sig, utf-8, utf-16 (Windows PowerShell default).
- Split into lines with .splitlines() to normalize line endings.
- Skip empty/whitespace-only lines.
- For each non-empty line:
- Try json.loads(line)
- On JSON error: append to invalid_lines and continue
- Normalize to LeadCard (see rules in Section 1)
- Always validate with validate_lead_card() to flag structural issues
Invalid line record schema:
- line_number: int (1-based)
- reason: str (short, stable, no raw tracebacks)
- raw: str (truncated, e.g. 240 chars)
Validation policy:
- If validate_lead_card() returns errors:
- Append invalid line with "schema invalid" + error summary
- Do NOT crash; continue reading
- Do NOT include the record in valid records
Why validation here:
- Ensures PDF rendering does not need to guard every missing key.
- Follows fail-closed principles and keeps PDF layout predictable.
Security:
- Never print API keys or secrets.
- Never log raw full lines to stderr by default; store truncated for appendices.
Tier ranking:
- tier_rank = {"S": 0, "A": 1, "B": 2, "C": 3, "D": 4}
Sort key:
- (tier_rank, -confidence, -commercial_relevance, -solution_seeking, -pain_point_intensity, -urgency)
Score source:
- scores = record["scores"] (LeadCard)
- keys: commercial_relevance, solution_seeking, pain_point_intensity, urgency
- Missing values default to 0.0 for stability.
Functions:
-
select_top(records, top_n)
- Filter records by ok == True (recommended)
- Sort using key above (stable sort)
- Return top_n (if top_n <= 0 return [])
-
compute_stats(records)
- Use only ok == True by default; record a "excluded_ok_false" count
- Outputs:
- counts by lead_tier
- counts by recommended_next_step
- mean confidence
- mean of 4 score dimensions
- top keywords (Counter, top 10)
- top budget_hints (Counter, top 10)
- Deterministic order: sort by (count desc, token asc)
- If no valid records, means default to 0.0 (avoid divide-by-zero)
Stats schema (example): { "total": int, "valid": int, "invalid": int, "excluded_ok_false": int, "tier_counts": {"S": 0, "A": 0, "B": 0, "C": 0, "D": 0}, "next_step_counts": {...}, "means": { "confidence": 0.0, "urgency": 0.0, "pain_point_intensity": 0.0, "commercial_relevance": 0.0, "solution_seeking": 0.0 }, "top_keywords": [("keyword", count), ...], "top_budget_hints": [("hint", count), ...] }
Dependency:
- reportlab (added to pyproject.toml dependencies)
Page:
- A4 portrait, standard margins (e.g., 36-40 pt)
- Use reportlab.platypus (SimpleDocTemplate, Paragraph, Table, Spacer)
Layout Sections:
-
Cover / Summary
- Report title + generation timestamp (local time)
- Input file name (if provided by CLI)
- Counts: total/valid/invalid
- Tier and next_step distributions (simple tables)
- Mean scores table
- Top keywords + top budget hints lists
-
Top Opportunities (cards)
- One card per LeadCard from select_top()
- Each card includes:
- Header row: Tier, Confidence, NextStep
- Score table: urgency, pain_point_intensity, commercial_relevance, solution_seeking
- problem_summary
- rationale
- keywords (list)
- budget_hints (list)
- tooling_stack (list)
- Cards separated with Spacer or light horizontal rule
-
Appendix: Invalid Lines
- List each invalid line entry with line_number and reason
- Include raw excerpt (truncated)
Truncation rules (prevent layout overflow):
- problem_summary: max 180 chars
- rationale: max 320 chars (aligns with 400 max in schema)
- list fields: max 8 items per list
- each list item: max 80 chars
- if more items exist, append "... (+N more)"
Typography:
- Use reportlab default fonts for ASCII-only compatibility.
- If non-ASCII content is detected and a CJK font file is available in src/siw_intent_brain/report/assets/, register and use it.
- If non-ASCII content is detected and no CJK font is available:
- Replace non-ASCII with "?" in rendered text
- Emit a single WARN to stderr (no raw text)
Robustness:
- Any exception during PDF build returns error code 2 (render error).
- Fail-safe: if reportlab import fails, return error code 1 (input/dep error).
New subcommand: siw-brain report --in candidates.jsonl --out report.pdf --top 20 siw-brain report --in - --out report.pdf --top 20 siw-brain report --out report.pdf --top 20 (stdin)
Arguments:
- --in (optional) : input JSONL path; "-" or omitted means stdin
- --out (required): output PDF path
- --top (optional): number of top cards to render (default: 20)
- Optional: --verbose to enable logging (stderr)
Exit codes:
- 0: success
- 1: input/file/encoding/JSON/validation/dependency errors
- 2: render errors (PDF build failures)
Error handling:
- Use try/except to keep stderr messages concise and user-friendly.
- No raw tracebacks in normal operation.
- Never print or leak API keys.
Test cases:
-
Basic PDF generation
- Create a small JSONL with 2-3 valid LeadCards
- Call report pipeline (reader + curator + render_pdf)
- Assert output file exists and size > 0
-
UTF-16 input
- Write JSONL as utf-16 (PowerShell default)
- Ensure reader returns records
- PDF builds successfully
-
Invalid line tolerance
- JSONL with one invalid JSON line
- Ensure invalid_lines contains entry
- PDF still builds successfully
-
Stdin support
- Pipe JSONL via stdin and ensure reader accepts --in "-" or omitted
Optional tests:
- Harvest wrapper line handling ({"card": {...}})
- ok=false records excluded from top list but counted in stats
Testing policy:
- Offline-only (no network)
- No dependency on real API keys
Update pyproject.toml:
- Add "reportlab>=4.x" to [project].dependencies
If keeping optional dependency:
- Use extras and skip tests if reportlab missing
- But for direct integration and "no missing deps", install by default
USAGE.md:
- Add "report" command in CLI section
- Explain accepted JSONL formats (LeadCard and harvest wrapper)
- Provide example: siw-brain report --in candidates.jsonl --out report.pdf --top 20
USAGE_NON_TECH.md:
- Add "generate PDF report" quick steps
- Mention that invalid lines are listed in appendix
README.md:
- Add short mention of report feature and example usage
Step 1: Create package scaffolding
- Add src/siw_intent_brain/report/init.py
- Export primary functions for internal import
Step 2: Implement reader.py
- Implement read_candidates_jsonl() using read_text_file()
- Implement JSONL parsing with per-line error collection
- Normalize "card" wrapper
- Add optional validate_lead_card() checks
Step 3: Implement curator.py
- Implement tier_rank and sort_key
- Implement select_top()
- Implement compute_stats() using Counter
- Ensure deterministic ordering
Step 4: Implement render_pdf.py
- Add reportlab imports and A4 layout
- Define reusable styles (title, section headers, body)
- Implement safe truncation helper functions
- Build summary tables
- Render cards with consistent spacing
- Render invalid line appendix
Step 5: Wire CLI
- Add report subcommand to argparse
- Add _cmd_report(args) with correct exit codes
- Minimal, user-friendly errors to stderr
- Support stdin if --in "-" or omitted with piped input
Step 6: Tests
- Add tests/test_report.py (offline)
- Use tmp_path for output file
- Use ASCII fixtures for compliance
- Include utf-16 test using Path.write_text(encoding="utf-16")
- Add stdin test
Step 7: Docs and examples
- Update USAGE.md and USAGE_NON_TECH.md with report usage
- Add short README mention
Step 8: QA checklist
- Manual run: siw-brain report --in candidates.jsonl --out report.pdf
- Validate that PDF renders on Windows and is readable
- Test invalid JSON line handling
- Confirm stderr only on errors
- Encoding issues: use read_text_file() to handle utf-8-sig/utf-8/utf-16.
- Schema drift: validate LeadCards before rendering.
- Large inputs: only top N cards rendered; stats computed with O(n).
- Layout overflow: truncation and list limits.
- Determinism: stable sort and deterministic Counter ordering.
- "report" command added and documented.
- Accepts both LeadCard JSONL and harvest wrapper JSONL.
- Accepts stdin for pipeline usage (--in "-" or omitted with pipe).
- PDF generated for valid inputs with size > 0.
- Invalid lines do not break rendering and are listed in appendix.
- Tests pass offline.
- stdout is quiet (no JSON); stderr only for errors/warnings.
- No sensitive output to stderr.