From 6f170d8e54f09e1760ff3f756f58df1796f05a62 Mon Sep 17 00:00:00 2001 From: JP Date: Tue, 19 May 2026 11:26:14 -0400 Subject: [PATCH] feat(pebble): polish export markdown + pin format with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens the prospect-research export so partial runs, long claims, and heterogeneous confidence values render cleanly, and pins the document shape with structural tests so it can't regress silently. What changed: - pebble/formatting.py (new): escape_md_table_cell, truncate_for_cell, normalize_confidence — pure helpers, no I/O. - pebble/export.py: empty-state section when claims+summary are blank; confidence rendered as "Medium (0.62)" / "High" / "Unknown" instead of raw values; claim text > 240 chars truncates with a footnote pointing to a "Full claim text" detail section; sources ordered by first claim appearance with "(N claims)" suffix and source_title links when present. - pebble/tests/test_export.py (new): 19 structural assertions on header, empty state, summary, claims table, truncation, confidence, sources. - pebble/tests/test_formatting.py (new): 26 helper unit tests. Signature of render_profile_markdown is unchanged. PDF path is untouched beyond a smoke check. Test counts: pebble 290 -> 335 (+45 new), no regressions. financial_forecasting 827 passed/22 skipped, unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- pebble/export.py | 111 +++++++++---- pebble/formatting.py | 52 ++++++ pebble/tests/test_export.py | 285 ++++++++++++++++++++++++++++++++ pebble/tests/test_formatting.py | 72 ++++++++ 4 files changed, 492 insertions(+), 28 deletions(-) create mode 100644 pebble/formatting.py create mode 100644 pebble/tests/test_export.py create mode 100644 pebble/tests/test_formatting.py diff --git a/pebble/export.py b/pebble/export.py index 084aa7e3..31333df9 100644 --- a/pebble/export.py +++ b/pebble/export.py @@ -2,23 +2,28 @@ from datetime import datetime, timezone +from .formatting import escape_md_table_cell, normalize_confidence, truncate_for_cell + +_CLAIM_TEXT_LIMIT = 240 + def render_profile_markdown(profile: dict, prospect_name: str, prospect_org: str) -> str: """Render a research profile as a Markdown document. Args: - profile: Profile dict with claims, summary, confidence_score. + profile: Profile dict with claims, summary, confidence_score, and + optionally `partial` (bool) and `partial_reason` (str). prospect_name: Display name of the prospect. - prospect_org: Organization name. + prospect_org: Organization name (may be blank). Returns: - Markdown string. + Markdown string. Stable structure across runs — pinned by tests in + `pebble/tests/test_export.py`. """ lines: list[str] = [] generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - confidence = profile.get("confidence_score", "unknown") + confidence = normalize_confidence(profile.get("confidence_score")) - # Header lines.append(f"# Prospect Research: {prospect_name}") if prospect_org: lines.append(f"**Organization:** {prospect_org}") @@ -28,46 +33,99 @@ def render_profile_markdown(profile: dict, prospect_name: str, prospect_org: str lines.append("**Status:** Partial (some agents failed)") lines.append("") - # Summary - summary = profile.get("summary", "") + summary = (profile.get("summary") or "").strip() + claims = profile.get("claims") or [] + + if not summary and not claims: + lines.append("## No information gathered") + lines.append("") + reason = (profile.get("partial_reason") or "").strip() + if reason: + lines.append(f"This research run did not produce any claims. Reason: {reason}") + else: + lines.append( + "This research run did not produce any claims. The agents may have " + "timed out or found no public information for this prospect." + ) + lines.append("") + return "\n".join(lines) + if summary: lines.append("## Summary") lines.append("") lines.append(summary) lines.append("") - # Claims table - claims = profile.get("claims", []) + truncated_claims: list[tuple[int, str]] = [] if claims: lines.append(f"## Claims ({len(claims)})") lines.append("") lines.append("| # | Claim | Source | Confidence | Status |") lines.append("|---|-------|--------|------------|--------|") for i, claim in enumerate(claims, 1): - text = claim.get("text", "").replace("|", "\\|").replace("\n", " ") + raw_text = claim.get("text", "") + short, was_truncated = truncate_for_cell(raw_text, _CLAIM_TEXT_LIMIT) + cell_text = escape_md_table_cell(short) + if was_truncated: + cell_text = f"{cell_text} [^c{i}]" + truncated_claims.append((i, raw_text)) url = claim.get("source_url", "") source_link = f"[Link]({url})" if url else "-" - conf = claim.get("confidence", "medium") - temporal = claim.get("temporal_status", "") - if temporal and temporal != "unknown": - status = temporal - else: - status = "-" - lines.append(f"| {i} | {text} | {source_link} | {conf} | {status} |") + conf_cell = normalize_confidence(claim.get("confidence", "medium")) + temporal = (claim.get("temporal_status") or "").strip() + status = temporal if temporal and temporal != "unknown" else "-" + lines.append(f"| {i} | {cell_text} | {source_link} | {conf_cell} | {status} |") lines.append("") - # Sources list (unique URLs) - urls = sorted({c.get("source_url", "") for c in claims if c.get("source_url")}) - if urls: - lines.append("## Sources") - lines.append("") - for url in urls: - lines.append(f"- {url}") - lines.append("") + if truncated_claims: + lines.append("### Full claim text") + lines.append("") + for idx, full_text in truncated_claims: + normalized = full_text.replace("\r\n", "\n").strip() + lines.append(f"[^c{idx}]: {normalized}") + lines.append("") + + source_lines = _render_sources_section(claims) + if source_lines: + lines.extend(source_lines) return "\n".join(lines) +def _render_sources_section(claims: list[dict]) -> list[str]: + """Build the `## Sources` section ordered by first-appearance in claims. + + Each URL appears once; multi-claim URLs get a `(N claims)` suffix. + Uses `source_title` when present, falling back to the bare URL. + """ + counts: dict[str, int] = {} + titles: dict[str, str] = {} + order: list[str] = [] + for claim in claims: + url = (claim.get("source_url") or "").strip() + if not url: + continue + if url not in counts: + counts[url] = 0 + order.append(url) + counts[url] += 1 + title = (claim.get("source_title") or "").strip() + if title and url not in titles: + titles[url] = title + + if not order: + return [] + + out: list[str] = ["## Sources", ""] + for url in order: + title = titles.get(url) + label = f"[{title}]({url})" if title else url + suffix = f" ({counts[url]} claims)" if counts[url] > 1 else "" + out.append(f"- {label}{suffix}") + out.append("") + return out + + def render_profile_pdf(markdown_text: str) -> bytes: """Best-effort PDF rendering from markdown text. @@ -78,7 +136,6 @@ def render_profile_pdf(markdown_text: str) -> bytes: import markdown as md html = md.markdown(markdown_text, extensions=["tables"]) - # Wrap in minimal HTML for readability full_html = f"""