diff --git a/.gitignore b/.gitignore index 6e3ef92..3f9b8bf 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ v1/htmlcov/ .specify/ specs/ CLAUDE.md + +# ── framework spike (LangGraph evaluation; NOT product code) ────── +# Recorded the plain-code-vs-LangGraph decision in specs/003; the spike files exercised the +# comparison and are kept local only — they are not part of the shipped engine. +v1/spike/ diff --git a/v1/README.md b/v1/README.md index b7025e8..8b23981 100644 --- a/v1/README.md +++ b/v1/README.md @@ -64,13 +64,21 @@ uv run python scripts/doctor.py # verify provider connectivity ```bash uv run python run.py --domain o2c # live run (generates everything live) uv run python run.py --domain o2c --auto-resolve # non-interactive -uv run python run.py --domain o2c --use-fixture # use the pre-built O2C fixture (demo-safe) +uv run python run.py --domain o2c --use-fixture # pre-built O2C fixture — the full reference-depth suite uv run python run.py --domain o2c --golden # replay a saved run offline uv run python run.py --domain o2c --refresh # diff against the previous run (new/resolved/changed) uv run python run.py --domain o2c --no-verify # skip the adversarial verification pass uv run python run.py --domain p2p --auto-resolve # any other domain — generated live ``` +> **Reference-depth O2C suite.** `--use-fixture` renders the hand-grounded O2C fixture — the full +> reference-grade suite (per-report cover + own TOC, the channel-mix / lead-time / credit-band / +> collections / EDI-connection / top-account tables, the five pain-point detail tables, the evidence +> register, success-metrics, risk register and traceability matrix). Every figure traces to the raw +> CSVs / source documents and passes the grounding gate. The live path emits the core suite today; +> mining the corpus into these structured sections from the live agent is a planned follow-up, so +> richer depth flows automatically for any domain. + Findings are variable in number and ranked by impact; each is adversarially **verified** (a challenged finding is flagged for review, not dropped); the agent can **conformance-check** a documented rule against the data; every report number **links to its source**; and `--refresh` diff --git a/v1/discovery/agent_loop.py b/v1/discovery/agent_loop.py index 042c28d..6bec1ac 100644 --- a/v1/discovery/agent_loop.py +++ b/v1/discovery/agent_loop.py @@ -1,7 +1,9 @@ """The agent tool-use loop — the agent genuinely discovers the findings. -The agent is given generic data/text tools and asked to investigate an O2C document set and -emit exactly 3 findings. It is NOT told what the findings are. It calls describe/group_by/ +The agent is given generic data/text tools and asked to investigate a document set and emit the +material findings the evidence supports (typically 3-5; the report DEPTH comes from the fact-store + +per-report fan-out downstream, not from the finding count). It is NOT told what the findings are. It +calls describe/group_by/ join_diff/filter_count/aggregate/find_mentions, reasons over the results, and terminates by calling emit_findings. Every quantitative claim is grounded against a real tool result. @@ -163,8 +165,10 @@ def run_discovery(llm: LLMClient, csv_ids: list[str], doc_ids: list[str], system = build_system_prompt(csv_ids, doc_ids, narrative_text, domain_label) schemas = tools.schemas() + [EMIT_TOOL] messages: list[dict] = [{"role": "user", "content": - f"Investigate this {domain_label} landscape and emit exactly 3 " - "findings. Begin by orienting on each data file per the protocol."}] + f"Investigate this {domain_label} landscape and emit the material, " + "evidence-backed findings (typically 3-5), each a distinct cross-source " + "issue ranked by business impact. Begin by orienting on each data file " + "per the protocol, then emit_findings once."}] seen, findings = set(), None for _ in range(MAX_TURNS): turn = llm.messages_with_tools(system=system, messages=messages, tools=schemas, model=model) @@ -173,7 +177,10 @@ def run_discovery(llm: LLMClient, csv_ids: list[str], doc_ids: list[str], if on_activity: for tu in tool_uses: on_activity(narrate(tu)) - if turn.stop_reason != "tool_use" or not tool_uses: + # The API requires a tool_result for EVERY tool_use, regardless of stop_reason — a turn that + # ends with tool_use blocks (even on a non-"tool_use" stop, e.g. max_tokens mid-call) must + # still be answered with results, or the next request 400s on an unpaired tool_use. + if not tool_uses: messages.append({"role": "user", "content": "You must finish by calling emit_findings exactly once. Do that now."}) continue diff --git a/v1/discovery/factstore.py b/v1/discovery/factstore.py new file mode 100644 index 0000000..a7337aa --- /dev/null +++ b/v1/discovery/factstore.py @@ -0,0 +1,231 @@ +"""Build a grounded FactStore (the KG-lite) from a discovery run + the registered sources. + +This is the Block-1+2 output the per-report synthesis fan-out expands from — it replaces the flat +"~3 findings" waist with a structured, sourced collection of measured numbers, verbatim document +quotes, typed entities (accounts / systems / connections with field-level attributes), and +relations. Everything carries its source(s) + confidence tier. + +GENERIC by construction: facts are derived from whatever findings + CSV columns + narrative text a +domain provides. No domain constants — a thinner domain simply yields fewer facts. Deterministic: +ordering is stable and row harvesting is capped + sorted, so a golden replay is byte-stable. +""" +from __future__ import annotations + +import re + +from . import docnames, tools +from .models import DocQuote, EntityFact, FactStore, QuantFact, Relation, StrategyProfile + +# Raw tool-output field names / engine jargon a model may copy verbatim into a quote. These are +# internal to the tool layer and must never reach a client-facing brief or report. We strip the +# token (keeping the human numbers/words around it); a quote that is *only* such jargon is dropped. +_TOOL_JARGON = re.compile( + r"\b(?:n_mismatch|sum_delta|from_tool|group_by|join_diff|filter_count|find_mentions)\b" + r"\s*[:=]?\s*", + re.I, +) +# residue left after a key is stripped: braces/quotes, then orphaned separators (": :" / ", :" / +# leading-or-trailing ";"). Applied repeatedly so chained residue ("{ : : 267") fully collapses. +_QUOTE_BRACES = re.compile(r"[{}\"]") +_QUOTE_RESIDUE = re.compile(r"\s*[:,]\s*(?=[:,])|^\s*[:,;]\s*|\s*[:,;]\s*$") + +# how many entity rows to harvest per CSV (deterministic cap — the reports surface the top accounts, +# not every row; full data lives in the source pages / provenance). +_ENTITY_CAP = 12 +# relation keywords a finding/handoff may carry (generic, not domain-specific) +_REL_KINDS = ("handoff", "conflict", "owns", "runs on", "triggers", "depends") + + +def build_fact_store(raw_payload: dict, reg: dict) -> FactStore: + """Assemble the grounded fact-store from the run's findings + the registered sources.""" + fs = FactStore() + _harvest_quants_and_quotes(raw_payload, fs) + _harvest_entities(reg, fs) + _harvest_relations(raw_payload, fs) + return fs + + +# ── measured numbers + verbatim quotes (from the findings the tools already grounded) ─────────── +def _harvest_quants_and_quotes(raw_payload: dict, fs: FactStore) -> None: + seen_q: set[tuple] = set() + seen_quote: set[tuple] = set() + for f in raw_payload.get("findings", []): + tier = _tier(f) + srcs = sorted({docnames.stem(s.get("doc_id", "")) for s in f.get("sources", []) + if s.get("doc_id")}) + for cv in f.get("computed_values", []): + label, val = str(cv.get("label", "")).strip(), cv.get("value") + num = _num(val) + if num is None or not label: + continue + key = (label.lower(), round(num, 4)) + if key in seen_q: + continue + seen_q.add(key) + fs.quant.append(QuantFact(label=label, value=num, unit=_unit_of(label), + sources=srcs, tier=tier)) + for nv in f.get("narrative_values", []): + quote = _clean_quote(str(nv.get("quote", ""))) + doc = docnames.stem(nv.get("doc_id", "")) + if not quote or not doc: + continue + key = (doc, quote[:60].lower()) + if key in seen_quote: + continue + seen_quote.add(key) + fs.quotes.append(DocQuote(text=quote, doc_id=doc, + locator=str(nv.get("label", "")), tier=tier)) + for s in f.get("sources", []): + quote = _clean_quote(str(s.get("quote", ""))) + doc = docnames.stem(s.get("doc_id", "")) + if not quote or not doc: + continue + key = (doc, quote[:60].lower()) + if key in seen_quote: + continue + seen_quote.add(key) + fs.quotes.append(DocQuote(text=quote, doc_id=doc, + locator=str(s.get("locator", "")), tier=tier)) + + +# ── typed entities (one per CSV row, generic name + attributes derived from the columns) ───────── +def _harvest_entities(reg: dict, fs: FactStore) -> None: + for csv_id in sorted(reg.get("csv_ids", [])): + path = tools.FILE_REGISTRY.get(csv_id) + if path is None: + continue + try: + cols, rows = tools._read_rows(path) + except (OSError, ValueError): + continue + if not cols or not rows: + continue + kind = _entity_kind(csv_id) + name_col = _name_column(cols) + # keep a deterministic, capped slice (rows are already in file order) + for r in rows[:_ENTITY_CAP]: + name = str(r.get(name_col, "")).strip() if name_col else "" + if not name: + continue + attrs = {c: str(r.get(c, "")).strip() for c in cols + if c != name_col and str(r.get(c, "")).strip()} + fs.entities.append(EntityFact(kind=kind, name=name, attributes=attrs, + sources=[csv_id], tier="verified")) + + +# ── relations (from handoff/conflict-flavoured findings) ───────────────────────────────────────── +def _harvest_relations(raw_payload: dict, fs: FactStore) -> None: + seen: set[tuple] = set() + for f in raw_payload.get("findings", []): + blob = (str(f.get("title", "")) + " " + str(f.get("description", ""))).lower() + kind = next((k.replace(" ", "_") for k in _REL_KINDS if k in blob), "") + if not kind: + continue + srcs = sorted({docnames.stem(s.get("doc_id", "")) for s in f.get("sources", []) + if s.get("doc_id")}) + rel = Relation(src=str(f.get("id", "")), kind=kind, dst=str(f.get("title", ""))[:60], + sources=srcs) + key = (rel.src, rel.kind) + if key in seen: + continue + seen.add(key) + fs.relations.append(rel) + + +# ── StrategyProfile from the domain manifest (neutral default when none declared) ──────────────── +def strategy_from_manifest(manifest: dict | None) -> StrategyProfile: + s = (manifest or {}).get("strategy_profile") or {} + return StrategyProfile( + direction_type=str(s.get("direction_type", "")), + horizon=str(s.get("horizon", "")), + strategic_constraints=str(s.get("strategic_constraints", "")), + stakeholder_priorities=list(s.get("stakeholder_priorities", []) or []), + out_of_scope=str(s.get("out_of_scope", "")), + success_definition=str(s.get("success_definition", ""))) + + +# ── helpers ────────────────────────────────────────────────────────────────────────────────────── +def _clean_quote(quote: str) -> str: + """Strip raw tool-output field names / engine jargon a model may have copied into a quote, so + internal tokens never reach a client-facing brief. Returns "" when, after stripping, nothing of + substance remains (the quote was essentially a raw tool dump) — caller then drops it. + + Deterministic and domain-agnostic: it removes only the fixed internal token set, leaving the + human numbers and words intact (e.g. "n_mismatch 267; sum_delta 30675000" → "267; 30675000", + which carries no meaning on its own and so is dropped; a real prose quote is left untouched).""" + q = quote.strip() + if not q or not _TOOL_JARGON.search(q): + return q + cleaned = _QUOTE_BRACES.sub("", _TOOL_JARGON.sub("", q)) + prev = None + while prev != cleaned: # collapse orphaned separators until stable + prev = cleaned + cleaned = _QUOTE_RESIDUE.sub("", cleaned) + cleaned = cleaned.strip(" ;,:") + # If what remains is only digits / punctuation / separators, the quote carried no prose meaning + # of its own — it was a raw tool-output echo. Drop it rather than surface a bare number string. + if not re.search(r"[A-Za-z]", cleaned): + return "" + return cleaned + + +def _num(v) -> float | None: + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _tier(f: dict) -> str: + c = str(f.get("confidence", "")).lower() + if c in ("verified", "amber", "gap"): + return c + # an adversarially-challenged finding is at most amber + if f.get("verification", {}).get("supported") is False: + return "amber" + return "verified" + + +def _unit_of(label: str) -> str: + lo = label.lower() + # a COUNT-of-things label wins over the field name it counts ("Accounts with mismatched + # credit_limit_eur" is a count of accounts, not a EUR amount). + if lo.startswith("account") or "accounts with" in lo or "number of account" in lo: + return "accounts" + if "pct" in lo or "percent" in lo or "%" in label or " rate" in lo or "share" in lo: + return "percent" + if "escalation" in lo or "incident" in lo or "case" in lo: + return "escalations" + if "eur" in lo or "€" in label or "overstatement" in lo or "limit" in lo or "value" in lo: + return "eur" + if "account" in lo: + return "accounts" + return "count" + + +def _entity_kind(csv_id: str) -> str: + """Derive a generic entity kind from the filename (no domain constants). Check the more specific + signals (escalation/incident log, connection register) before the broad 'customer/master' one, + so a 'customer-service-escalation-log' is an incident, not an account.""" + lo = csv_id.lower() + if "escalation" in lo or "incident" in lo or "ticket" in lo or "log" in lo: + return "incident" + if "connection" in lo or "integration" in lo or "edi" in lo: + return "connection" + if "order" in lo or "flow" in lo or "transaction" in lo: + return "transaction" + if "customer" in lo or "account" in lo or "master" in lo or "crm" in lo: + return "account" + return "record" + + +def _name_column(cols: list[str]) -> str: + """Pick the most name-like column generically: prefer a 'name', else an 'id', else the first.""" + lowered = [(c, c.lower()) for c in cols] + for c, lo in lowered: + if lo.endswith("name") or lo == "name" or "customer_name" in lo: + return c + for c, lo in lowered: + if lo.endswith("_id") or lo == "id": + return c + return cols[0] if cols else "" diff --git a/v1/discovery/fanout.py b/v1/discovery/fanout.py new file mode 100644 index 0000000..5cf61e8 --- /dev/null +++ b/v1/discovery/fanout.py @@ -0,0 +1,280 @@ +"""Per-report / per-opportunity synthesis fan-out (feature 003). + +Replaces the single 16K `emit_synthesis` (which had to produce all six reports at once and so thinned +every field) with MANY bounded generations — one per report, plus one per opportunity for the +centrepiece portfolio. Each call: + - is fed only the RELEVANT grounded slice of the fact-store (numbers + verbatim quotes + entities) + plus the StrategyProfile brief for the strategic reports; + - has its own token budget (no shared 16K ceiling); + - is routed through the EXISTING LLMClient, so the on-disk cache / golden replay / determinism are + unchanged (each sub-call is cache-keyed on its own inputs); + - is passed through a per-section grounding gate (`validate_section`) that rejects ungrounded + MEASURED numbers (retry once), keeps Report-01 factual, and treats sourced factual tables as + document facts — identical rules to the monolithic gate, applied per section; + - emits forward-looking PLANNING content into a separate, clearly-labelled channel + (PlanningAssumption) — never as a measured fact. + +Plain-code (no framework — see specs/003-deep-live-pipeline/decision.md). One failed section omits +rather than aborting the suite. +""" +from __future__ import annotations + +import re + +from .agent_loop import GroundingError, _close +from .models import FactStore, PlanningAssumption, StrategyProfile +from .synthesis import _STRUCTURAL, _SOURCED_TABLE_KEYS, assert_factual + +# the seven report keys, in suite order (matches reportsuite.render.REPORTS) +REPORT_KEYS = ["00-executive-summary", "01-current-state", "02-pain-points", "03-recommendation", + "04-opportunity-portfolio", "05-roadmap", "06-supporting-artefacts"] +# the strategic reports the StrategyProfile shapes (tactical 04/06 stay direction-agnostic) +_STRATEGIC = {"03-recommendation", "05-roadmap"} +# report keys whose prose is held to the factual lint (no diagnostic language) +_FACTUAL = {"01-current-state"} + +PLANNING_KINDS = {"date", "owner", "sla", "threshold", "cadence", "cost", "sequence"} + + +# ── per-section grounding gate (same rules as the monolith, applied to one section) ───────────── +def validate_section(section: dict, allow: set[float], doc_keys: set[str], *, + factual: bool = False) -> dict: + """Raise GroundingError if this report section violates a grounding/factual invariant. Sourced + factual tables (baseline_stats/data_tables/…) restate cited document facts and are exempt from + the findings-allow-list; every other measured number must trace to `allow`.""" + def prose_ok(s: str): + for tok in re.findall(r"\d[\d,]*\.?\d*", s or ""): + v = float(tok.replace(",", "")) + if round(v, 4) in _STRUCTURAL: + continue + if not _close(v, allow): + raise GroundingError(f"section has untraceable number {tok!r}") + + def walk(o, sourced=False): + if isinstance(o, dict): + if {"value", "unit", "text"} <= set(o): + if not (round(float(o["value"]), 4) in _STRUCTURAL or _close(o["value"], allow)): + raise GroundingError(f"section number {o['value']} not traceable") + if "doc_key" in o and o["doc_key"] not in doc_keys: + raise GroundingError(f"unknown doc_key {o['doc_key']!r}") + for k, v in o.items(): + # planning assumptions are explicitly NON-facts → not number-gated + walk(v, sourced or k in _SOURCED_TABLE_KEYS or k == "planning_assumptions") + elif isinstance(o, list): + for x in o: + walk(x, sourced) + elif isinstance(o, str): + if not sourced: + prose_ok(o) + + walk(section) + if factual: + _lint_factual(section) + return section + + +def _lint_factual(o) -> None: + if isinstance(o, dict): + for v in o.values(): + _lint_factual(v) + elif isinstance(o, list): + for x in o: + _lint_factual(x) + elif isinstance(o, str): + assert_factual(o) + + +# ── one bounded generation (a report section or an opportunity) ───────────────────────────────── +def _emit_tool(name: str, schema: dict) -> dict: + return {"name": name, + "description": ("Emit this report section as structured grounded content. Call EXACTLY " + "once. Every measured number must equal a VERIFIED FACT value; put any " + "forward-looking planning content (dates, owners, SLAs, thresholds, " + "cadence, cost, sequence) in `planning_assumptions`, never as a fact."), + "input_schema": schema} + + +SECTION_SYSTEM = ( + "You write ONE section of a grounded, client-ready discovery report. BUSINESS LANGUAGE ONLY. " + "Every measured figure must equal one of the VERIFIED FACTS you are given — never invent, sum, " + "or round a number. Forward-looking planning content (a date, a future owner, an SLA, a target " + "threshold, a cadence, a cost, a sequence decision) is NOT a measured fact: put it in " + "`planning_assumptions` with its kind and the grounded basis it rests on. Emit by calling the " + "tool exactly once.") + + +def synth_section(llm, *, tool_name: str, schema: dict, fact_store: FactStore, + strategy: StrategyProfile | None, instruction: str, doc_keys: set[str], + factual: bool = False, max_tokens: int = 6000, model=None, + attempts: int = 2, allow: set[float] | None = None) -> dict | None: + """Generate one report section via a bounded, cache-keyed LLM call, then gate it. Returns the + section dict, or None if it cannot be grounded after `attempts` (the suite omits it rather than + aborting). `fact_store` is the SLICE shown in the prompt; `allow` (when given) is the grounding + allow-list to gate against — pass the FULL run's allow-list here so a focused prompt slice never + starves the gate of a legitimately-grounded number. Determinism inherited from + llm.messages_with_tools.""" + if allow is None: + allow = fact_store.numbers_allow() + brief = strategy.brief() if strategy else "" + user = (f"VERIFIED FACTS (use ONLY these numbers):\n{_facts_brief(fact_store)}\n\n" + + (f"STRATEGY (shape this section to this direction):\n{brief}\n\n" if brief else "") + + f"DOCUMENT KEYS you may cite: {sorted(doc_keys)}\n\n" + + f"TASK:\n{instruction}\nCall {tool_name} exactly once.") + messages: list[dict] = [{"role": "user", "content": user}] + tool = _emit_tool(tool_name, schema) + last_err = None + for _ in range(attempts): + turn = llm.messages_with_tools(system=SECTION_SYSTEM, messages=messages, tools=[tool], + model=model, max_tokens=max_tokens) + emits = [b for b in turn.tool_uses if b["name"] == tool_name] + if not emits: + messages.append({"role": "assistant", "content": turn.content}) + messages.append({"role": "user", "content": f"Call {tool_name} exactly once now."}) + continue + try: + return validate_section(emits[0]["input"], allow, doc_keys, factual=factual) + except GroundingError as e: + last_err = e + messages.append({"role": "assistant", "content": turn.content}) + messages.append({"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": emits[0]["id"], + "content": f"REJECTED: {e}\n\nUse only the verified facts for measured numbers; move " + f"any planning content into planning_assumptions. Re-emit {tool_name}."}]}) + return None # could not ground this section — omit it (one section never aborts the suite) + + +def collect_planning(section: dict | None) -> list[PlanningAssumption]: + """Pull a section's labelled planning assumptions into typed objects (kept out of the measured + grounding gate). Defensive: the model occasionally emits an item as a bare STRING instead of a + {statement, kind, basis} object — treat that as the statement. Unknown kinds default to + 'sequence'.""" + out = [] + for pa in (section or {}).get("planning_assumptions", []) or []: + if isinstance(pa, str): + pa = {"statement": pa} + elif not isinstance(pa, dict): + continue + stmt = str(pa.get("statement", "")).strip() + if not stmt: + continue + kind = str(pa.get("kind", "sequence")).strip().lower() + out.append(PlanningAssumption(statement=stmt, + kind=kind if kind in PLANNING_KINDS else "sequence", + basis=str(pa.get("basis", "")).strip())) + return out + + +def _facts_brief(fs: FactStore) -> str: + lines = [] + for q in fs.quant: + lines.append(f" [num] {q.label} = {q.value} {q.unit} ({q.tier}; {', '.join(q.sources)})") + for e in fs.entities[:24]: + attrs = "; ".join(f"{k}={v}" for k, v in list(e.attributes.items())[:6]) + lines.append(f" [{e.kind}] {e.name} — {attrs} ({', '.join(e.sources)})") + for d in fs.quotes[:16]: + lines.append(f" [quote] \"{d.text}\" — {d.doc_id}") + for r in fs.relations: + lines.append(f" [rel] {r.src} {r.kind} {r.dst}") + return "\n".join(lines) + + +# ── orchestrator: build the fact-store, fan out per report (+ per opportunity), assemble ──────── +# Each report owns a slice of SynthesisContent fields; the orchestrator merges the per-report emits. +# (Phase 1 wires the control flow + gate + planning channel; Phase 2 fills the per-report schemas to +# reference depth.) The seed names a small number of opportunities to expand individually for r04. +def run_synthesis_fanout(llm, fact_store: FactStore, strategy: StrategyProfile, doc_keys, *, + report_specs=None, opp_seeds=None, model=None, allow=None): + """Run the fan-out and return (merged_payload: dict, planning: list[PlanningAssumption]). + + `report_specs`: {report_key: {"tool", "schema", "instruction", "slice"(terms)}} — what each + report generates. `opp_seeds`: [{"id","title","topic"}] — opportunities expanded individually for + report 04. `allow`: the authoritative grounding allow-list to gate every section against — pass + the RUN'S full allow-list (synthesis.allowed_numbers over the raw payload: tool numbers + finding + values + derived ratios) so legitimately-grounded figures aren't rejected; falls back to the + fact-store's own numbers when not given (tests / no raw payload).""" + report_specs = report_specs or {} + opp_seeds = opp_seeds or [] + merged: dict = {} + planning: list[PlanningAssumption] = [] + # numbers are grounded RUN-WIDE; the per-section slice only shapes the prompt. Gate against the + # full run allow-list (not the narrow fact-store slice) so a focused prompt never starves the gate. + if allow is None: + allow = fact_store.numbers_allow() + + for key in REPORT_KEYS: + spec = report_specs.get(key) + if not spec: + continue + fs = fact_store.slice_for(*spec.get("slice", [])) if spec.get("slice") else fact_store + # a malformed/oddly-shaped emit from ONE report must omit just that report, never abort the + # suite (the live model can return an unexpected shape; resilience over all-or-nothing). + try: + section = synth_section( + llm, tool_name=spec["tool"], schema=spec["schema"], fact_store=fs, allow=allow, + strategy=strategy if key in _STRATEGIC else None, + instruction=spec["instruction"], doc_keys=doc_keys, + factual=key in _FACTUAL, max_tokens=spec.get("max_tokens", 6000), model=model) + planning += collect_planning(section) + _merge(merged, section) + except (AttributeError, TypeError, KeyError, ValueError): + continue + + # per-opportunity deep generation for the centrepiece portfolio (report 04) + opps = [] + for seed in opp_seeds: + spec = report_specs.get("04-opportunity-portfolio", {}) + try: + opp = synth_section( + llm, tool_name="emit_opportunity", schema=spec.get("opp_schema", _MIN_OPP_SCHEMA), + fact_store=fact_store.slice_for(*([seed.get("topic")] if seed.get("topic") else [])), + allow=allow, strategy=None, instruction=_opp_instruction(seed), doc_keys=doc_keys, + max_tokens=spec.get("opp_max_tokens", 6000), model=model) + except (AttributeError, TypeError, KeyError, ValueError): + opp = None + if opp is not None: + planning += collect_planning(opp) + opps.append(opp) + if opps: + merged.setdefault("opportunities", []) + merged["opportunities"] = opps + merged.get("opportunities", []) + return merged, planning + + +def _merge(into: dict, section: dict | None) -> None: + """Merge one report's emitted fields into the suite payload. List fields concatenate; scalar/ + object fields are set if absent (earlier reports own the shared fields). `planning_assumptions` + is consumed by collect_planning, not merged into the payload.""" + if not section: + return + for k, v in section.items(): + if k == "planning_assumptions": + continue + if isinstance(v, list): + into.setdefault(k, []) + into[k].extend(v) + else: + into.setdefault(k, v) + + +def _opp_instruction(seed: dict) -> str: + return (f"Write the FULL working documentation for opportunity {seed.get('id','')} — " + f"\"{seed.get('title','')}\": overview, before/after process, quantified business impact " + f"(verified numbers only, with derivation), implementation approach, success metrics, " + f"dependencies and risks. EVERY before/after process step MUST have a one-line " + f"`description` (never leave a step with only a name). Write all prose in normal " + f"sentence case (not ALL CAPS). Put any dates/owners/SLAs/thresholds in " + f"planning_assumptions. Emit emit_opportunity once.") + + +# a minimal opportunity schema so Phase 1 is runnable; Phase 2 supplies the full reference schema. +_MIN_OPP_SCHEMA = {"type": "object", "properties": { + "id": {"type": "string"}, "title": {"type": "string"}, "overview": {"type": "string"}, + "business_impact": {"type": "object", "properties": { + "narrative": {"type": "string"}, + "quantified": {"type": "array", "items": {"type": "object", "properties": { + "value": {"type": "number"}, "unit": {"type": "string"}, "text": {"type": "string"}}, + "required": ["value", "unit", "text"]}}}}, + "planning_assumptions": {"type": "array", "items": {"type": "object", "properties": { + "statement": {"type": "string"}, "kind": {"type": "string"}, "basis": {"type": "string"}}, + "required": ["statement"]}}}, + "required": ["id", "title", "overview"]} diff --git a/v1/discovery/fanout_specs.py b/v1/discovery/fanout_specs.py new file mode 100644 index 0000000..c591f6a --- /dev/null +++ b/v1/discovery/fanout_specs.py @@ -0,0 +1,291 @@ +"""Per-report emit schemas + prompts for the synthesis fan-out (feature 003, Phase 2). + +Each report owns a slice of SynthesisContent (see reportsuite.render field map). This module defines, +per report, the bounded emit-tool schema it produces and the instruction that drives it from the +grounded fact-store slice. The orchestrator (`run_report_fanout`) runs them, merges the slices into +one payload, and reconstructs a reference-depth SynthesisContent via build._from_payload. + +Schemas are scoped (a report emits only its fields) so each call stays within its own token budget — +this is what removes the single-16K ceiling. Reusable JSON-schema fragments mirror the report +dataclasses; planning content rides a `planning_assumptions` array (never a measured fact). +""" +from __future__ import annotations + +from . import factstore +from .fanout import run_synthesis_fanout +from .models import StrategyProfile + +# ── reusable JSON-schema fragments ────────────────────────────────────────────────────────────── +_STR = {"type": "string"} +_STRS = {"type": "array", "items": _STR} + + +def _source(doc_keys): + return {"type": "object", "properties": {"doc_key": {"type": "string", "enum": sorted(doc_keys)}}, + "required": ["doc_key"]} + + +_NUMBER_REF = {"type": "object", "properties": { + "value": {"type": "number"}, + "unit": {"type": "string", + "enum": ["count", "eur", "percent", "ratio", "accounts", "orders", "escalations"]}, + "label": _STR, "text": _STR}, "required": ["value", "unit", "text"]} + +_PLANNING = {"type": "array", "description": + "forward-looking content the data cannot compute (a date, future owner, SLA, target " + "threshold, cadence, cost, or sequence decision) — NEVER a measured fact", + "items": {"type": "object", "properties": { + "statement": _STR, + "kind": {"type": "string", + "enum": ["date", "owner", "sla", "threshold", "cadence", "cost", + "sequence"]}, + "basis": {"type": "string", + "description": "the grounded fact this assumption is anchored to"}}, + "required": ["statement"]}} + + +def _data_table(doc_keys): + return {"type": "object", "properties": { + "title": _STR, + "columns": _STRS, + "rows": {"type": "array", "items": {"type": "array", "items": _STR}}, + "caption": _STR, "note": _STR, + "sources": {"type": "array", "items": _source(doc_keys)}}, + "required": ["title", "columns", "rows"]} + + +def _key_stat(): + return {"type": "object", "properties": {"value": _STR, "label": _STR, "sublabel": _STR}, + "required": ["value", "label"]} + + +def _process_detail(doc_keys): + return {"type": "object", "properties": { + "title": _STR, "body": _STR, "actor": _STR, "system": _STR, + "sources": {"type": "array", "items": _source(doc_keys)}}, + "required": ["title", "body"]} + + +def _step(doc_keys): + return {"type": "object", "properties": { + "seq": {"type": "integer", "minimum": 1}, "name": _STR, "actor": _STR, "system": _STR, + "description": _STR, "failure_points": _STRS, + "sources": {"type": "array", "items": _source(doc_keys)}}, + "required": ["seq", "name", "description"]} + + +# ── per-report emit schemas (each scoped to the fields that report owns) ──────────────────────── +def _r01_schema(doc_keys): + return {"type": "object", "properties": {"current_state": {"type": "object", "properties": { + "domain_overview": _STR, "process_summary": _STR, + "process_flow": {"type": "array", "minItems": 3, "items": _step(doc_keys)}, + "baseline_stats": {"type": "array", "items": _key_stat()}, + "data_tables": {"type": "array", "items": _data_table(doc_keys), + "description": "the grounded factual tables (channel mix, lead times, credit " + "bands, collections ladder, connection inventory, top " + "accounts, systems) — restate values VERBATIM from sources"}, + "process_detail": {"type": "array", "items": _process_detail(doc_keys), + "description": "one entry per process stage — how it runs today"}, + "process_inventory": {"type": "array", "items": {"type": "object", "properties": { + "name": _STR, "purpose": _STR}, "required": ["name"]}}, + "ownership_map": {"type": "array", "items": {"type": "object", "properties": { + "activity": _STR, "responsible": _STR, "accountable": _STR}, "required": ["activity"]}}, + "system_inventory": {"type": "array", "items": {"type": "object", "properties": { + "name": _STR, "role": _STR, "system_of_record_for": _STR}, "required": ["name"]}}, + "system_profiles": {"type": "array", "items": {"type": "object", "properties": { + "name": _STR, "role": _STR, "how_used": _STR, "owners": _STR, "limitations": _STR}, + "required": ["name"]}}, + "format_taxonomy": {"type": "array", "items": {"type": "object", "properties": { + "label": _STR, "description": _STR, "examples": _STR}, "required": ["label"]}}, + "handoff_catalogue": {"type": "array", "items": {"type": "object", "properties": { + "from_step": _STR, "to_step": _STR, "mechanism": _STR}, + "required": ["from_step", "to_step"]}}}, + "required": ["domain_overview", "process_summary", "process_flow"]}, + "planning_assumptions": _PLANNING}} + + +def _r02_schema(doc_keys): + src = _source(doc_keys) + pp = {"type": "object", "properties": { + "id": {"type": "string", "pattern": r"^PP\d+$"}, "title": _STR, + "impact_rank": {"type": "integer", "minimum": 1, "maximum": 8}, + "from_finding": _STR, "description": _STR, "root_cause": _STR, "failure_pattern": _STR, + "business_consequence": _STR, "category": _STR, + "severity": {"type": "string", "enum": ["high", "medium", "lower"]}, + "quantified": {"type": "array", "items": _NUMBER_REF}, + "detail_table": _data_table(doc_keys), + "sources": {"type": "array", "minItems": 1, "items": src}}, + "required": ["id", "title", "impact_rank", "description", "root_cause"]} + return {"type": "object", "properties": { + "pain_points": {"type": "array", "minItems": 1, "maxItems": 8, "items": pp}, + "cross_process_patterns": {"type": "array", "items": {"type": "object", "properties": { + "pattern": _STR, "description": _STR}, "required": ["pattern", "description"]}}, + "evidence_register": {"type": "array", "items": {"type": "object", "properties": { + "finding": _STR, "source": _STR, "evidence_type": _STR, "data_point": _STR, + "confidence": {"type": "string", "enum": ["Verified", "Amber", "Gap"]}}, + "required": ["finding", "source"]}}, + "planning_assumptions": _PLANNING}, "required": ["pain_points"]} + + +def _r03_schema(doc_keys): + return {"type": "object", "properties": { + "transformation": {"type": "object", "properties": { + "sequencing_rationale": _STR, "strategic_readiness": _STR, "dependency_notes": _STR}, + "required": ["sequencing_rationale", "strategic_readiness"]}, + "target_state": _STR, + "metrics_framework": {"type": "array", "minItems": 3, "items": {"type": "object", + "properties": {"name": _STR, "definition": _STR, "target": _STR}, + "required": ["name", "definition", "target"]}}, + "risk_register": {"type": "array", "items": {"type": "object", "properties": { + "risk": _STR, "likelihood": {"type": "string", "enum": ["High", "Medium", "Low"]}, + "impact": {"type": "string", "enum": ["High", "Medium", "Low"]}, + "mitigation": _STR, "owner": _STR}, "required": ["risk"]}}, + "traceability": {"type": "array", "items": {"type": "object", "properties": { + "pain_point": _STR, "summary": _STR, "severity": _STR, "recommendation": _STR, + "opportunity": _STR, "expected_outcome": _STR, "horizon": _STR}, + "required": ["pain_point"]}}, + "planning_assumptions": _PLANNING}, + "required": ["transformation", "metrics_framework"]} + + +def _r05_schema(doc_keys): + roaditem = {"type": "object", "properties": { + "title": _STR, "rationale": _STR, "opportunity_id": _STR, "depends_on": _STRS}, + "required": ["title", "rationale"]} + return {"type": "object", "properties": { + "roadmap": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "object", + "properties": { + "horizon": {"type": "string", "enum": ["H1", "H2", "H3"]}, + "window": _STR, "theme": _STR, + "items": {"type": "array", "minItems": 1, "items": roaditem}}, + "required": ["horizon", "window", "theme", "items"]}}, + "strategy_profile": {"type": "object", "properties": {"posture": _STR, "notes": _STR}}, + "planning_assumptions": _PLANNING}, + "required": ["roadmap"]} + + +def _r00_schema(doc_keys): + return {"type": "object", "properties": { + "executive_summary": {"type": "object", "properties": { + "headline": _STR, "situation": _STR, "opportunity": _STR}, + "required": ["headline", "situation", "opportunity"]}, + "planning_assumptions": _PLANNING}, + "required": ["executive_summary"]} + + +def _opp_schema(doc_keys): + src = _source(doc_keys) + return {"type": "object", "properties": { + "id": {"type": "string", "pattern": r"^OPP\d+$"}, "title": _STR, + "pattern": {"type": "string", "enum": ["hitl_workflow", "automation", "ai_agent", + "modernisation"]}, + "overview": _STR, + "before_process": {"type": "array", "minItems": 2, "items": _step(doc_keys)}, + "after_process": {"type": "array", "minItems": 2, "items": _step(doc_keys)}, + "business_impact": {"type": "object", "properties": { + "narrative": _STR, "quantified": {"type": "array", "items": _NUMBER_REF}, + "derivation": _STR}, "required": ["narrative"]}, + "implementation_approach": _STR, "personas": _STRS, "expected_behaviour": _STR, + "escalation": _STR, "knowledge_sources": _STRS, "document_formats": _STRS, + "data_readiness": _STR, "technical_complexity": _STR, "operational_readiness": _STR, + "required_integrations": _STRS, "success_metrics": _STRS, + "dependencies": {"type": "array", "items": {"type": "string", "pattern": r"^OPP\d+$"}}, + "risks": _STRS, + "value_rating": {"type": "string", "enum": ["high", "medium", "low"]}, + "feasibility_rating": {"type": "string", "enum": ["high", "medium", "low"]}, + "value_score": {"type": "integer", "minimum": 1, "maximum": 5}, + "feasibility_score": {"type": "integer", "minimum": 1, "maximum": 5}, + "matrix_quadrant": {"type": "string", + "enum": ["do_first", "plan_for", "consider", "deprioritise"]}, + "sources": {"type": "array", "minItems": 1, "items": src}, + "planning_assumptions": _PLANNING}, + "required": ["id", "title", "pattern", "overview", "before_process", "after_process", + "business_impact"]} + + +# ── the per-report spec registry the orchestrator consumes ────────────────────────────────────── +def report_specs(doc_keys) -> dict: + dk = list(doc_keys) + return { + "00-executive-summary": { + "tool": "emit_exec", "schema": _r00_schema(dk), + "instruction": "Write the executive summary: a headline (the single most important " + "finding), the situation in a nutshell, and where the value is / what to " + "do first. Business language; only verified numbers."}, + "01-current-state": { + "tool": "emit_current_state", "schema": _r01_schema(dk), "max_tokens": 12000, + "instruction": "Document the FACTUAL current state at reference depth: domain overview + " + "process summary; the end-to-end process_flow (≥3 steps, actor+system " + "each); baseline_stats tiles; the grounded data_tables you can build from " + "the facts (channel mix, lead times, credit bands, collections ladder, " + "connection inventory, top accounts, systems — restate values VERBATIM " + "from the sources, cite each); process_detail per stage; ownership_map " + "(RACI); system_inventory; system_profiles; format_taxonomy; " + "handoff_catalogue. STATE FACTS ONLY — no diagnostic words (risk, gap, " + "breach, conflict, critical). Omit a table/section the facts cannot fill."}, + "02-pain-points": { + "tool": "emit_pain_points", "schema": _r02_schema(dk), "max_tokens": 10000, + "instruction": "Document the pain points found, ranked by impact: each with id (PP1…), " + "title, severity (high|medium|lower), category, description, root_cause, " + "failure_pattern, business_consequence, quantified figures (verified " + "numbers only), and a grounded detail_table where the facts support one " + "(e.g. a discrepancy register). Add cross_process_patterns and an " + "evidence_register (finding → source → data point/quote → confidence)."}, + "03-recommendation": { + "tool": "emit_recommendation", "schema": _r03_schema(dk), "max_tokens": 9000, + "instruction": "Write the transformation recommendation shaped by the STRATEGY: " + "sequencing_rationale, strategic_readiness, dependency_notes; a " + "target_state narrative; a metrics_framework (name/definition/directional " + "target — no invented numbers); a risk_register (risk, likelihood, " + "impact, mitigation, owner-by-ROLE — ratings/owners are planning " + "assumptions); and a traceability matrix (pain point → recommendation → " + "opportunity → outcome → horizon)."}, + "04-opportunity-portfolio": { + "tool": "emit_portfolio", "opp_schema": _opp_schema(dk), "opp_max_tokens": 8000, + "schema": {"type": "object", "properties": {}}, # opportunities come per-seed + "instruction": ""}, + "05-roadmap": { + "tool": "emit_roadmap", "schema": _r05_schema(dk), "max_tokens": 7000, + "instruction": "Sequence the opportunities across three horizons (H1 0-6 / H2 6-18 / " + "H3 18+), shaped by the STRATEGY direction and horizon. Each horizon: " + "window, theme, items (title, rationale, opportunity_id where it maps a " + "portfolio item, depends_on). Specific dates/durations are planning " + "assumptions. Set strategy_profile.posture."}, + } + + +def opp_seeds_from_pain_points(payload: dict) -> list[dict]: + """One opportunity to expand per pain point (the portfolio addresses each PP). Derives a topic + from the PP title so each opportunity is fed its relevant fact slice.""" + seeds = [] + for i, pp in enumerate(payload.get("pain_points", []), start=1): + title = pp.get("title", "") + topic = " ".join(w for w in title.split() if len(w) > 4)[:40] + seeds.append({"id": f"OPP{i}", "title": f"Address: {title}", "topic": topic}) + return seeds + + +def run_report_fanout(llm, raw_payload: dict, reg: dict, strategy: StrategyProfile | None = None, + doc_keys=None, model=None): + """Top-level live deep synthesis: build the grounded fact-store, fan out per report, expand one + opportunity per pain point, and return (merged_payload, planning, fact_store, strategy). The + caller maps merged_payload via build._from_payload and attaches fact_store/strategy/planning.""" + from .synthesis import allowed_numbers + fs = factstore.build_fact_store(raw_payload, reg) + strat = strategy or factstore.strategy_from_manifest(reg.get("manifest")) + dk = set(doc_keys or (reg.get("csv_ids", []) + reg.get("doc_ids", []))) + specs = report_specs(dk) + # the AUTHORITATIVE grounding allow-list for this run (tool numbers + finding values + derived + # ratios) — same source the monolith gate uses; the fact-store slice only shapes the prompt. + allow = allowed_numbers(raw_payload) + # first pass for the pain points (report 02) so we can seed one opportunity per pain point + pp_only, _ = run_synthesis_fanout(llm, fs, strat, dk, allow=allow, + report_specs={"02-pain-points": specs["02-pain-points"]}) + seeds = opp_seeds_from_pain_points(pp_only) + merged, planning = run_synthesis_fanout(llm, fs, strat, dk, allow=allow, report_specs=specs, + opp_seeds=seeds) + # fold the first-pass pain points in (report_specs ran them again in the full pass too; merge + # keeps the first, so they are consistent) + for k, v in pp_only.items(): + merged.setdefault(k, v) + return merged, planning, fs, strat diff --git a/v1/discovery/models.py b/v1/discovery/models.py index 76528c9..5713e0b 100644 --- a/v1/discovery/models.py +++ b/v1/discovery/models.py @@ -46,6 +46,155 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) +# ── grounded fact-store (the KG-lite that feeds the per-report synthesis fan-out) ─────────────── +@dataclass +class QuantFact: + """A measured number with provenance — the only carrier of figures into the fact-store. Gated + against the run's allow-list exactly like a NumberRef.""" + label: str + value: float + unit: str = "count" + sources: list[str] = field(default_factory=list) # doc ids + tier: str = "verified" # verified | amber | gap + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class DocQuote: + """A verbatim snippet from a source document — powers pattern-evidence and quote boxes. Must + appear verbatim in the document (same rule as a finding's narrative_values).""" + text: str + doc_id: str + locator: str = "" + tier: str = "verified" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class EntityFact: + """A grounded entity (system / account / connection / actor / process) with typed attributes — + e.g. an account with {erp_limit, crm_limit, migration_source}. Attribute VALUES are strings + restated from the source; numeric attributes still trace to the allow-list when rendered.""" + kind: str # generic, derived from the data (not o2c-specific) + name: str + attributes: dict[str, str] = field(default_factory=dict) + sources: list[str] = field(default_factory=list) + tier: str = "verified" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class Relation: + """A grounded relationship between two entities/steps (handoff_to / conflicts_with / owned_by / + runs_on / triggers).""" + src: str + kind: str + dst: str + sources: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class FactStore: + """The grounded data plane the synthesis fan-out reads from — a lightweight knowledge graph of + measured numbers, verbatim quotes, typed entities, and relations. Every member carries its + source(s) + confidence tier. Domain-agnostic: a domain simply has fewer facts where its data is + thinner. Replaces the flat ~3-finding waist as the thing synthesis expands from.""" + quant: list[QuantFact] = field(default_factory=list) + quotes: list[DocQuote] = field(default_factory=list) + entities: list[EntityFact] = field(default_factory=list) + relations: list[Relation] = field(default_factory=list) + + def numbers_allow(self) -> set[float]: + """The measured numbers this store grounds (for the per-section grounding gate).""" + out: set[float] = set() + for q in self.quant: + try: + out.add(round(float(q.value), 4)) + except (TypeError, ValueError): + continue + return out + + def slice_for(self, *terms: str) -> "FactStore": + """The relevant subset of the store for one report/opportunity generation — members whose + label/name/text/attributes mention any of the given (case-insensitive) terms. Empty terms → + the whole store. Deterministic (preserves order).""" + if not terms: + return self + needles = [t.lower() for t in terms if t] + + def hit(*texts: str) -> bool: + blob = " ".join(t.lower() for t in texts if t) + return any(n in blob for n in needles) + return FactStore( + quant=[q for q in self.quant if hit(q.label, q.unit)], + quotes=[d for d in self.quotes if hit(d.text, d.doc_id, d.locator)], + entities=[e for e in self.entities + if hit(e.kind, e.name, " ".join(f"{k} {v}" for k, v in e.attributes.items()))], + relations=[r for r in self.relations if hit(r.src, r.kind, r.dst)]) + + def to_dict(self) -> dict[str, Any]: + return {"quant": [q.to_dict() for q in self.quant], + "quotes": [d.to_dict() for d in self.quotes], + "entities": [e.to_dict() for e in self.entities], + "relations": [r.to_dict() for r in self.relations]} + + +@dataclass +class StrategyProfile: + """The locked per-engagement strategic direction (read from the domain manifest). Shapes the + STRATEGIC reports (03 recommendation, 05 roadmap); the tactical portfolio (04) stays + direction-agnostic. A neutral default applies when a manifest declares none.""" + direction_type: str = "" # consolidate|modernize|stabilize|divest|… + horizon: str = "" # e.g. "0-6 months" + strategic_constraints: str = "" + stakeholder_priorities: list[str] = field(default_factory=list) + out_of_scope: str = "" + success_definition: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def brief(self) -> str: + """A short prompt brief for the strategic-report synthesis calls. '' when neutral.""" + bits = [] + if self.direction_type: + bits.append(f"Direction: {self.direction_type}") + if self.horizon: + bits.append(f"Horizon: {self.horizon}") + if self.strategic_constraints: + bits.append(f"Constraints: {self.strategic_constraints}") + if self.stakeholder_priorities: + bits.append("Priorities: " + ", ".join(self.stakeholder_priorities)) + if self.out_of_scope: + bits.append(f"Out of scope: {self.out_of_scope}") + if self.success_definition: + bits.append(f"Success: {self.success_definition}") + return " · ".join(bits) + + +@dataclass +class PlanningAssumption: + """A forward-looking statement the data cannot COMPUTE (a date, owner-by-role, SLA, threshold, + cadence, cost, or sequence). Generated as a clearly-labelled assumption — never presented as a + discovered fact — with the grounded `basis` it is anchored to (if any). The renderer marks it + visibly so a reader never mistakes it for measured data.""" + statement: str + kind: str = "sequence" # date|owner|sla|threshold|cadence|cost|sequence + basis: str = "" # the grounded fact it is anchored to + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @dataclass class Document: doc_id: str @@ -244,6 +393,48 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) +@dataclass +class KeyStat: # a single big-number stat tile (Report 01 baseline) + value: str # pre-formatted grounded figure, e.g. "8,420" or "67.3%" + label: str = "" + sublabel: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class DataTable: # a grounded factual table restated from source documents + """A factual reference table (channel mix, lead-times, credit bands, EDI connections, top + accounts, DC network, …). Cells are pre-formatted strings restated verbatim from the cited + source(s). Carried on the FACTUAL current-state report; the renderer draws it as a table and the + grounding gate treats its source-restated figures as sourced facts (not synthesized claims).""" + title: str + columns: list[str] = field(default_factory=list) + rows: list[list[str]] = field(default_factory=list) + caption: str = "" + note: str = "" # optional footnote (e.g. a sourcing caveat) + sources: list[SourceRef] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return {"title": self.title, "columns": self.columns, "rows": self.rows, + "caption": self.caption, "note": self.note, + "sources": [s.to_dict() for s in self.sources]} + + +@dataclass +class ProcessDetail: # one numbered process-inventory subsection (Report 01 §3.x) + title: str + body: str = "" # factual prose describing how the step runs + actor: str = "" + system: str = "" + sources: list[SourceRef] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return {"title": self.title, "body": self.body, "actor": self.actor, + "system": self.system, "sources": [s.to_dict() for s in self.sources]} + + @dataclass class CurrentState: # Report 01 — NO severity/confidence anywhere domain_overview: str = "" @@ -255,6 +446,10 @@ class CurrentState: # Report 01 — NO severity/confidence ownership_map: list[RaciRow] = field(default_factory=list) system_inventory: list[InventoryItem] = field(default_factory=list) handoff_catalogue: list[Handoff] = field(default_factory=list) + # deeper grounded baseline (all optional — a domain without them simply omits the section) + baseline_stats: list[KeyStat] = field(default_factory=list) # §1 volume baseline tiles + data_tables: list[DataTable] = field(default_factory=list) # channel mix, EDI, DCs, … + process_detail: list[ProcessDetail] = field(default_factory=list) # §3 process inventory detail def to_dict(self) -> dict[str, Any]: return {"domain_overview": self.domain_overview, @@ -265,7 +460,10 @@ def to_dict(self) -> dict[str, Any]: "process_inventory": [i.to_dict() for i in self.process_inventory], "ownership_map": [r.to_dict() for r in self.ownership_map], "system_inventory": [i.to_dict() for i in self.system_inventory], - "handoff_catalogue": [h.to_dict() for h in self.handoff_catalogue]} + "handoff_catalogue": [h.to_dict() for h in self.handoff_catalogue], + "baseline_stats": [k.to_dict() for k in self.baseline_stats], + "data_tables": [t.to_dict() for t in self.data_tables], + "process_detail": [p.to_dict() for p in self.process_detail]} @dataclass @@ -277,17 +475,60 @@ class PainPoint: # Report 02 description: str = "" root_cause: str = "" failure_pattern: str = "" + business_consequence: str = "" # the "so what" — impact in business terms + category: str = "" # grounded category label (e.g. "Data Governance") + severity: str = "" # "high" | "medium" | "lower"; falls back to impact_rank opportunity_signal: str = "" # OPP id, derived in code quantified: list[NumberRef] = field(default_factory=list) + detail_table: "DataTable | None" = None # optional per-PP evidence table (grounded) sources: list[SourceRef] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: d = asdict(self) d["quantified"] = [n.to_dict() for n in self.quantified] d["sources"] = [s.to_dict() for s in self.sources] + d["detail_table"] = self.detail_table.to_dict() if self.detail_table else None return d +@dataclass +class EvidenceRow: # one row of the Report 02 evidence register (appendix) + finding: str # the PP / finding id this evidence supports + source: str = "" # business-friendly document name(s) + evidence_type: str = "" # e.g. "Structured data", "Policy document", "Working notes" + data_point: str = "" # the key figure or quote + confidence: str = "" # "Verified" | "Amber" | "Gap" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class RiskItem: # one row of the Report 03 risk register + risk: str + likelihood: str = "" # "High" | "Medium" | "Low" + impact: str = "" # "High" | "Medium" | "Low" + mitigation: str = "" + owner: str = "" # a grounded ROLE (never an invented person) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class TraceRow: # one row of the Report 03 traceability matrix (appendix) + pain_point: str = "" + summary: str = "" + severity: str = "" + recommendation: str = "" + opportunity: str = "" + expected_outcome: str = "" + horizon: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @dataclass class BusinessImpact: # Report 04 narrative: str = "" @@ -427,9 +668,19 @@ class SynthesisContent: # everything reports 00-06 render source_index: list[SourceDoc] = field(default_factory=list) executive_summary: ExecutiveSummary = field(default_factory=ExecutiveSummary) # Report 00 target_state: str = "" # forward-looking "where this should converge" narrative + # deeper grounded appendices (all optional — omit cleanly when empty) + evidence_register: list[EvidenceRow] = field(default_factory=list) # Report 02 appendix + risk_register: list[RiskItem] = field(default_factory=list) # Report 03 risk register + traceability: list[TraceRow] = field(default_factory=list) # Report 03 appendix # code-owned chart series, derived from grounded numbers in build (never model-set). Each entry: # {"key","title","unit","segments":[{"label","value"}]}. Renderer draws these as donut/bar. charts: list[dict[str, Any]] = field(default_factory=list) + # deep-live-pipeline (feature 003): the grounded fact-store the fan-out expanded from, the locked + # strategy profile, and the clearly-labelled planning assumptions. All optional — the fixture and + # the legacy single-emit path leave them empty and render unchanged. + fact_store: "FactStore | None" = None + strategy: "StrategyProfile | None" = None + planning_assumptions: list[PlanningAssumption] = field(default_factory=list) def to_dict(self) -> dict[str, Any]: return {"current_state": self.current_state.to_dict(), @@ -445,7 +696,13 @@ def to_dict(self) -> dict[str, Any]: "source_index": [s.to_dict() for s in self.source_index], "executive_summary": asdict(self.executive_summary), "target_state": self.target_state, - "charts": self.charts} + "evidence_register": [e.to_dict() for e in self.evidence_register], + "risk_register": [r.to_dict() for r in self.risk_register], + "traceability": [t.to_dict() for t in self.traceability], + "charts": self.charts, + "fact_store": self.fact_store.to_dict() if self.fact_store else None, + "strategy": self.strategy.to_dict() if self.strategy else None, + "planning_assumptions": [p.to_dict() for p in self.planning_assumptions]} @dataclass diff --git a/v1/discovery/reportsuite/assets.py b/v1/discovery/reportsuite/assets.py index aa789ea..6b26337 100644 --- a/v1/discovery/reportsuite/assets.py +++ b/v1/discovery/reportsuite/assets.py @@ -1,197 +1,357 @@ """CSS/JS for the client-facing report suite. -Visual identity — "Deep Teal & Graphite", editorial-consulting (see -specs/001-report-visual-system/spec.md). Deep teal is the single brand accent (anchored to the -cover); one warm bronze note is used sparingly for highest-impact emphasis only. Display type is a -system serif (offline-safe, no web-font fetch) to read as an authored document, not a tool dump. -All pure CSS/inline-SVG — no external fonts, libraries, or network.""" +Visual identity — formal navy/blue corporate consulting (see specs/002-formal-report-suite/spec.md). +Matches the reference deliverables: navy `#1a2f50` structure, blue `#2563eb` accent, system +sans-serif (Helvetica Neue family, offline-safe — no web-font fetch). Each report is a standalone +document: its own cover, its own table of contents, hierarchically numbered sections. All pure +CSS/inline-SVG — no external fonts, libraries, or network. + +Status colours (red/amber/green) are used ONLY for severity/priority/readiness signalling, never as +decoration; the rest of the system stays in the navy/blue family. +""" CSS = """ -:root{ --ink:#1a2230; --muted:#5b6776; --line:#e3e8ee; --bg:#f6f8f9; - --accent:#0f7c8c; --accent-soft:#e6f1f3; --accent-deep:#0b5e6b; - --warm:#c8772e; --warm-soft:#f7ece0; - --panel:#ffffff; - /* chart series ramp — cohesive teal family */ - --c1:#0f7c8c; --c2:#2a93a3; --c3:#5fb0bc; --c4:#9fccd3; --c5:#cfe6ea; - /* system serif display stack (offline-safe) + sans body */ - --display:"Iowan Old Style","Charter",Georgia,"Times New Roman",serif; - --sans:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; - /* spacing scale (8px base) */ - --s1:.5rem; --s2:1rem; --s3:1.5rem; --s4:2rem; } +:root{ + --navy:#1a2f50; --blue:#2563eb; --blue-mid:#3665a8; --blue-deep:#1d4ed8; + --ink:#111827; --muted:#6b7280; --line:#d1d5db; --line-soft:#eaecf0; + --bg:#f3f5f8; --bg-light:#f9fafb; --bg-alt:#f2f4f7; --panel:#ffffff; --note-bg:#eff6ff; + /* status (signalling only) */ + --red:#dc2626; --red-bg:#fef2f2; --red-bd:#fca5a5; + --amber:#d97706; --amber-bg:#fffbeb; --amber-bd:#fcd34d; + --green:#059669; --green-bg:#ecfdf5; --green-bd:#6ee7b7; + --purple:#6d28d9; + /* chart series ramp — navy/blue family */ + --c1:#1a2f50; --c2:#2563eb; --c3:#3665a8; --c4:#60a5fa; --c5:#a9c7f0; + /* system sans stack (offline-safe) — matches the reference */ + --sans:'Helvetica Neue',Arial,'Liberation Sans','Segoe UI',Roboto,sans-serif; + --s1:.5rem; --s2:1rem; --s3:1.5rem; --s4:2rem; +} *{ box-sizing:border-box; } -body{ margin:0; font-family:var(--sans); - color:var(--ink); background:var(--bg); line-height:1.55; } -.layout{ display:flex; min-height:100vh; } -.sidebar{ width:270px; flex:0 0 270px; background:#10222a; color:#cdd7e4; padding:1.5rem 1rem; - position:sticky; top:0; height:100vh; overflow:auto; } -.sidebar h1{ font-size:1rem; color:#fff; margin:0 0 .25rem; } -.sidebar .sub{ font-size:.78rem; color:#8da2bd; margin-bottom:1.5rem; } -.sidebar a{ display:block; color:#cdd7e4; text-decoration:none; padding:.55rem .7rem; - border-radius:7px; font-size:.9rem; margin-bottom:.15rem; } -.sidebar a:hover{ background:#1b2c44; } -.sidebar a.active{ background:var(--accent); color:#fff; } -.sidebar .num{ color:#6f86a6; font-variant-numeric:tabular-nums; margin-right:.5rem; } -.content{ flex:1; padding:2.5rem 3rem; max-width:920px; } -.content h1{ font-family:var(--display); font-size:2rem; font-weight:700; letter-spacing:-.01em; - margin:0 0 .3rem; } -.content h2{ font-family:var(--display); font-size:1.35rem; font-weight:700; - margin:2.4rem 0 .7rem; padding-bottom:.35rem; border-bottom:2px solid var(--accent); - display:flex; align-items:baseline; gap:.6rem; } -.content h3{ font-family:var(--display); font-size:1.1rem; font-weight:700; margin:1.6rem 0 .4rem; } -/* section-number chip on numbered headings (set by the renderer) */ -.secnum{ font-family:var(--sans); font-size:.7rem; font-weight:700; color:#fff; - background:var(--accent); border-radius:5px; padding:.12rem .42rem; letter-spacing:.02em; - position:relative; top:-.12rem; } -.lede{ color:var(--muted); margin:0 0 1.5rem; } -table{ border-collapse:collapse; width:100%; margin:1rem 0; background:var(--panel); font-size:.9rem; } -th,td{ border:1px solid var(--line); padding:.55rem .7rem; text-align:left; vertical-align:top; } -th{ background:#eef2f3; font-weight:600; color:var(--accent-deep); } +body{ margin:0; font-family:var(--sans); color:var(--ink); background:var(--bg); + line-height:1.6; -webkit-font-smoothing:antialiased; } + +/* ── screen layout: slim top nav-bar, then a centred standalone document (like the reference) ── */ +.topnav{ position:sticky; top:0; z-index:10; background:var(--navy); display:flex; align-items:center; + gap:1.2rem; padding:.55rem 1.5rem; flex-wrap:wrap; } +.topnav .brandmark{ color:#fff; font-size:.95rem; } +.tn-links{ display:flex; gap:.1rem; flex-wrap:wrap; } +.topnav a{ color:#cdd7e4; text-decoration:none; padding:.3rem .6rem; border-radius:5px; + font-size:.8rem; } +.topnav a:hover{ background:#243b63; } +.topnav a.active{ background:var(--blue); color:#fff; } +.topnav .num{ color:#7f93b6; font-variant-numeric:tabular-nums; margin-right:.35rem; } +.content{ max-width:940px; margin:0 auto; padding:1.6rem 3rem 3rem; } + +/* ── typography (reference h1/h2/h3 rhythm) ── */ +.content h1{ font-size:1.7rem; font-weight:800; color:var(--navy); letter-spacing:-.01em; + margin:.2rem 0 .3rem; } +.content h2{ font-size:1.22rem; font-weight:800; color:var(--navy); letter-spacing:-.01em; + margin:2.2rem 0 .8rem; padding-bottom:.35rem; border-bottom:2px solid var(--navy); + display:flex; align-items:baseline; gap:.55rem; } +.content h3{ font-size:1rem; font-weight:700; color:var(--blue-mid); margin:1.5rem 0 .45rem; } +.content h4{ font-size:.92rem; font-weight:700; color:var(--navy); margin:.1rem 0 .4rem; } +.eyebrow{ font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.12em; + color:var(--muted); margin:0 0 .15rem; } +/* section-number chip on numbered headings */ +.secnum{ font-size:.7rem; font-weight:700; color:#fff; background:var(--blue); border-radius:5px; + padding:.12rem .42rem; letter-spacing:.02em; position:relative; top:-.1rem; + flex:0 0 auto; } +p{ margin:0 0 .7rem; } +.lede{ color:var(--muted); font-size:1rem; margin:0 0 1.4rem; } .prov{ color:var(--muted); font-size:.82rem; font-style:italic; } -.metric{ display:inline-block; background:var(--accent-soft); color:var(--accent-deep); font-weight:600; - padding:.05rem .4rem; border-radius:5px; } -.card{ background:var(--panel); border:1px solid var(--line); border-radius:10px; - padding:1.2rem 1.4rem; margin:1.2rem 0; } -.pattern{ display:inline-block; font-size:.72rem; letter-spacing:.04em; text-transform:uppercase; - color:var(--accent); border:1px solid var(--accent); border-radius:20px; - padding:.1rem .6rem; margin-left:.5rem; vertical-align:middle; } +strong{ font-weight:700; } em{ font-style:italic; } +ul{ margin:.4rem 0 .9rem; padding-left:1.1rem; } li{ margin:.22rem 0; } +.muted{ color:var(--muted); } + +/* ── document-grade tables (navy header, zebra body, section rows) ── */ +table{ border-collapse:collapse; width:100%; margin:1rem 0; background:var(--panel); + font-size:.86rem; } +thead th{ background:var(--navy); color:#fff; padding:.5rem .65rem; text-align:left; + font-size:.74rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; } +tbody td{ padding:.5rem .65rem; border-bottom:1px solid var(--line-soft); vertical-align:top; + line-height:1.5; color:var(--ink); overflow-wrap:break-word; } +tbody tr:nth-child(even) td{ background:var(--bg-alt); } +tbody tr:last-child td{ border-bottom:none; } +tr.sr td{ background:var(--navy); color:#fff; font-weight:700; font-size:.74rem; + text-transform:uppercase; letter-spacing:.04em; } +table.usecase td{ font-size:.8rem; } +/* wide multi-column prose tables (readiness rationale, traceability matrix) can carry long cells — + fix the layout so columns share width and WRAP at word boundaries rather than overflowing the page + edge or shredding a narrow column character-by-character. */ +table.rationale, table.trace{ table-layout:fixed; } +table.rationale td, table.trace td{ font-size:.82rem; word-break:normal; overflow-wrap:break-word; } +table.rationale .rate{ white-space:normal; } /* let the badge wrap with its reason if needed */ +/* the traceability matrix is 7 narrow columns on A4 — shrink the header type and assign explicit + widths so the header words ("Recommendation"/"Opportunity"/"Expected outcome") wrap cleanly + instead of colliding. */ +table.trace th{ font-size:.62rem; padding:.4rem .45rem; letter-spacing:.02em; } +table.trace td{ font-size:.78rem; padding:.45rem .45rem; } +table.trace th:nth-child(1), table.trace td:nth-child(1){ width:18%; } /* pain point */ +table.trace th:nth-child(2), table.trace td:nth-child(2){ width:20%; } /* summary */ +table.trace th:nth-child(3), table.trace td:nth-child(3){ width:8%; } /* severity */ +table.trace th:nth-child(4), table.trace td:nth-child(4){ width:18%; } /* recommendation */ +table.trace th:nth-child(5), table.trace td:nth-child(5){ width:10%; } /* opportunity */ +table.trace th:nth-child(6), table.trace td:nth-child(6){ width:18%; } /* expected outcome */ +table.trace th:nth-child(7), table.trace td:nth-child(7){ width:8%; } /* horizon */ +.dt{ margin:1.2rem 0; } +.dt h4{ margin:0 0 .2rem; } +.dt-cap{ font-size:.82rem; color:var(--muted); margin-bottom:.2rem; } +.who{ color:var(--muted); font-size:.82rem; margin-bottom:.3rem; } + +/* ── badge system ── */ +.badge{ display:inline-block; font-size:.68rem; font-weight:700; border-radius:3px; + padding:.12rem .5rem; text-transform:uppercase; letter-spacing:.04em; white-space:nowrap; } +.b-high,.b-crit{ background:var(--red-bg); color:var(--red); border:1px solid var(--red-bd); } +.b-med{ background:var(--amber-bg); color:#92400e; border:1px solid var(--amber-bd); } +.b-low,.b-info{ background:var(--green-bg); color:#065f46; border:1px solid var(--green-bd); } +.b-h1{ background:rgba(37,99,235,.12); color:var(--blue-deep); } +.b-h2{ background:rgba(5,150,105,.12); color:#065f46; } +.b-h3{ background:rgba(109,40,217,.12); color:var(--purple); } +.b-ai{ background:rgba(37,99,235,.12); color:var(--blue-deep); } +.b-qw{ background:rgba(13,148,136,.12); color:#0f766e; } +.b-pat{ background:#eef2f7; color:var(--blue-mid); border:1px solid var(--line); } +.b-cat{ background:#f3f0ff; color:var(--purple); border:1px solid #c4b5fd; } +/* planning-assumption marker — deliberately NOT a fact colour (dashed amber) so a reader never + mistakes forward-looking planning content for measured data */ +.b-plan{ background:#fffbeb; color:#92400e; border:1px dashed var(--amber); } +.trace{ font-size:.74rem; color:var(--muted); } + +/* ── stat tiles (4-up big numbers) ── */ +.stat-row{ display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:.7rem; + margin:1.3rem 0 1.5rem; } +@media print{ .stat-row{ grid-template-columns:repeat(4,1fr); } } +.stat-box{ background:var(--panel); border:1px solid var(--line); border-radius:7px; + padding:.9rem 1rem; border-top:3px solid var(--blue); position:relative; } +.sv{ font-size:1.7rem; font-weight:800; color:var(--navy); line-height:1.05; + font-variant-numeric:tabular-nums; } +.sv.red{ color:var(--red); } .sv.amber{ color:var(--amber); } +.sv.blue{ color:var(--blue); } .sv.green{ color:var(--green); } +.sl{ font-size:.76rem; color:var(--muted); margin-top:.25rem; line-height:1.35; } +.stat-ico{ position:absolute; top:.7rem; right:.8rem; width:16px; height:16px; opacity:.3; } +.stat-ico svg{ width:16px; height:16px; fill:none; stroke:var(--blue); stroke-width:1.6; } + +/* mini-stat row (within a card/section) */ +.mini-row{ display:flex; flex-wrap:wrap; gap:.55rem; margin:.9rem 0; } +.mini{ flex:1 1 120px; background:var(--bg-light); border:1px solid var(--line); border-radius:5px; + padding:.55rem .8rem; text-align:center; } +.mval{ font-size:1.15rem; font-weight:800; color:var(--navy); font-variant-numeric:tabular-nums; } +.mval.red{ color:var(--red); } .mval.amber{ color:var(--amber); } .mval.blue{ color:var(--blue); } +.mlbl{ font-size:.68rem; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; + margin-top:.15rem; line-height:1.3; } + +/* ── callout boxes (info / high / medium) + evidence quote ── */ +.note-box,.high-box,.med-box{ border-radius:0 5px 5px 0; padding:.7rem 1rem; margin:.9rem 0; } +.note-box{ background:var(--note-bg); border-left:3px solid var(--blue); } +.high-box{ background:var(--red-bg); border-left:3px solid var(--red); } +.med-box{ background:var(--amber-bg); border-left:3px solid var(--amber); } +.nb-title,.hb-title,.mb-title{ font-size:.74rem; font-weight:700; text-transform:uppercase; + letter-spacing:.06em; margin-bottom:.35rem; } +.nb-title{ color:var(--blue-deep); } .hb-title{ color:var(--red); } .mb-title{ color:#b45309; } +.nb-text,.hb-text,.mb-text{ font-size:.86rem; color:#374151; line-height:1.55; } +.note-box p:last-child,.high-box p:last-child,.med-box p:last-child{ margin-bottom:0; } +.ev-quote{ background:var(--bg-light); border:1px solid var(--line); border-left:3px solid var(--muted); + border-radius:0 5px 5px 0; padding:.65rem 1rem; margin:.9rem 0; } +.eq-text{ font-size:.86rem; color:#374151; font-style:italic; line-height:1.55; } +.eq-attr{ font-size:.78rem; color:var(--muted); font-weight:700; margin-top:.4rem; } + +/* ── pain-point card ── */ +.pp-hdr{ display:flex; align-items:flex-start; gap:.8rem; padding-bottom:.7rem; + border-bottom:1px solid var(--line); margin-bottom:.8rem; } +.pp-id{ background:var(--navy); color:#fff; font-size:.78rem; font-weight:800; padding:.4rem .6rem; + border-radius:4px; text-align:center; line-height:1.3; flex:0 0 auto; } +.pp-name{ font-size:1.05rem; font-weight:800; color:var(--navy); margin-bottom:.35rem; } +.pp-badges{ display:flex; gap:.4rem; flex-wrap:wrap; } + +/* ── recommendation card ── */ +.rec-card{ border:1px solid var(--line); border-radius:7px; overflow:hidden; margin:1.2rem 0; } +.rec-hdr{ background:var(--bg-light); border-bottom:1px solid var(--line); padding:.75rem 1rem; + display:flex; gap:.8rem; align-items:flex-start; } +.rec-id{ background:var(--navy); color:#fff; font-size:.78rem; font-weight:800; padding:.4rem .6rem; + border-radius:4px; text-align:center; line-height:1.3; flex:0 0 auto; white-space:nowrap; } +.rec-name{ font-size:1.02rem; font-weight:800; color:var(--navy); margin-bottom:.4rem; } +.rec-badges{ display:flex; gap:.4rem; flex-wrap:wrap; align-items:center; } +.rec-body{ padding:.9rem 1rem; } +.action-list{ list-style:none; margin:.5rem 0 .8rem; padding:0; } +.action-list li{ display:flex; gap:.55rem; margin:.45rem 0; font-size:.88rem; line-height:1.55; } +.al-horizon{ font-size:.66rem; font-weight:700; border-radius:3px; padding:.12rem .5rem; + flex:0 0 auto; height:fit-content; margin-top:.1rem; text-transform:uppercase; + letter-spacing:.03em; white-space:nowrap; } +.al-h1{ background:rgba(37,99,235,.12); color:var(--blue-deep); } +.al-h2{ background:rgba(5,150,105,.12); color:#065f46; } +.al-h3{ background:rgba(109,40,217,.12); color:var(--purple); } +.kpi-row{ display:flex; gap:.5rem; flex-wrap:wrap; margin-top:.6rem; } +.kpi-pill{ font-size:.76rem; padding:.2rem .6rem; border-radius:11px; border:1px solid var(--green-bd); + background:var(--green-bg); color:#065f46; } +.strat-box{ background:#fff7ed; border-left:3px solid #f97316; border-radius:0 5px 5px 0; + padding:.65rem 1rem; margin:.8rem 0; } +.sb-title{ font-size:.74rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; + color:#c2410c; margin-bottom:.35rem; } +.sb-text{ font-size:.86rem; color:#374151; line-height:1.55; } + +/* ── principle cards ── */ +.principles{ display:grid; grid-template-columns:1fr 1fr; gap:.7rem; margin:1rem 0 1.3rem; } +.prin-card{ background:var(--bg-light); border:1px solid var(--line); border-radius:6px; + padding:.8rem 1rem; } +.prin-num{ font-size:.72rem; font-weight:800; color:var(--blue); margin-bottom:.3rem; + letter-spacing:.04em; } +.prin-title{ font-size:.92rem; font-weight:700; color:var(--navy); margin-bottom:.3rem; } +.prin-text{ font-size:.82rem; color:var(--muted); line-height:1.5; } + +/* ── opportunity cards (exec summary) ── */ +.opp-cards{ display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:1rem; + margin:1.1rem 0; } +.opp-card{ display:block; text-decoration:none; color:inherit; background:var(--panel); + border:1px solid var(--line); border-radius:7px; padding:1rem 1.1rem; + border-top:3px solid var(--blue); transition:box-shadow .15s, transform .15s; } +.opp-card:hover{ box-shadow:0 4px 16px rgba(26,47,80,.12); transform:translateY(-1px); } +.opp-card h4{ margin:.45rem 0 .35rem; } +.opp-card p{ font-size:.84rem; color:var(--muted); margin:0; } +.opp-card .kfig{ margin-top:.55rem; } +.pattern{ display:inline-block; font-size:.68rem; letter-spacing:.04em; text-transform:uppercase; + color:var(--blue-mid); background:#eef2f7; border:1px solid var(--line); + border-radius:20px; padding:.1rem .55rem; } +.metric{ display:inline-block; background:rgba(37,99,235,.1); color:var(--blue-deep); + font-weight:700; padding:.05rem .45rem; border-radius:4px; font-size:.84rem; } + +/* ── two-col panels (exec summary) ── */ +.two-col{ display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin:1.3rem 0; } +.panel{ background:var(--panel); border:1px solid var(--line); border-radius:7px; padding:1rem 1.2rem; } +.panel h3{ margin:.1rem 0 .4rem; } +.panel.target{ border-left:3px solid var(--blue); background:var(--note-bg); } + +/* ── before/after process visual ── */ .ba-grid{ display:grid; grid-template-columns:1fr 36px 1fr; align-items:stretch; gap:.7rem; - margin:1.1rem 0; } -.ba-grid .col{ border:1px solid var(--line); border-radius:10px; padding:.8rem .9rem; } -.ba-grid .before{ background:#f7f8fa; } -.ba-grid .after{ background:var(--accent-soft); border-color:#bfdde2; } -.ba-tag{ display:inline-block; font-size:.68rem; font-weight:700; text-transform:uppercase; + margin:1rem 0; } +.ba-grid .col{ border:1px solid var(--line); border-radius:7px; padding:.8rem .9rem; } +.ba-grid .before{ background:var(--bg-light); } +.ba-grid .after{ background:var(--note-bg); border-color:#bfd4f7; } +.ba-tag{ display:inline-block; font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; padding:.12rem .5rem; border-radius:20px; margin-bottom:.5rem; - background:#e3e8ee; color:var(--muted); } -.ba-tag.after{ background:var(--accent); color:#fff; } + background:var(--bg-alt); color:var(--muted); } +.ba-tag.after{ background:var(--blue); color:#fff; } .ba-arrow{ display:flex; align-items:center; justify-content:center; } .ba-arrow svg{ width:28px; height:28px; } -@media print{ .ba-arrow svg{ width:22px; height:22px; } } .step{ margin:.5rem 0; } -.step .who{ color:var(--muted); font-size:.82rem; } -.failpoint{ color:#8a5a00; background:#fdf4e3; border-radius:5px; padding:.05rem .35rem; - font-size:.82rem; display:inline-block; margin:.15rem .15rem 0 0; } -.matrix{ display:grid; grid-template-columns:1fr 1fr; grid-auto-rows:150px; gap:.5rem; margin:1.2rem 0; } -.quad{ border:1px solid var(--line); border-radius:9px; padding:.7rem .8rem; background:var(--panel); } -.quad h4{ margin:0 0 .4rem; font-size:.82rem; color:var(--muted); text-transform:uppercase; - letter-spacing:.03em; } -.quad.do_first{ background:var(--accent-soft); } -.chip{ display:inline-block; background:#fff; border:1px solid var(--accent); color:var(--accent-deep); - border-radius:6px; padding:.15rem .5rem; margin:.2rem .2rem 0 0; font-size:.84rem; } -.horizon{ border-left:3px solid var(--accent); padding:.2rem 0 .2rem 1rem; margin:1rem 0; } -.horizon .win{ color:var(--muted); font-size:.85rem; } -.badge-note{ font-size:.82rem; color:var(--muted); } -.flow-wrap{ background:linear-gradient(180deg,#fbfdff,#f5f8fc); border:1px solid var(--line); - border-radius:12px; padding:1.1rem 1.2rem .9rem; margin:1.2rem 0; overflow-x:auto; } -.flow-cap{ font-size:.8rem; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; - margin-bottom:.7rem; font-weight:600; } -.flow{ display:block; max-width:820px; } -.srcdoc{ white-space:pre-wrap; word-break:break-word; background:var(--panel); - border:1px solid var(--line); border-radius:8px; padding:1rem; font-size:.82rem; - line-height:1.5; max-height:none; } -ul{ margin:.4rem 0 .8rem; } li{ margin:.2rem 0; } -.card h3{ margin:.1rem 0 .6rem; } - -/* ── executive summary: KPI tiles, panels, opportunity cards ─────────── */ -.kpis{ display:grid; grid-template-columns:repeat(auto-fit,minmax(115px,1fr)); gap:.7rem; - margin:1.4rem 0 1.8rem; } -@media print{ .kpis{ grid-template-columns:repeat(5,1fr); } } -.kpi{ background:var(--panel); border:1px solid var(--line); border-radius:10px; - padding:1rem 1.1rem; border-top:3px solid var(--accent); position:relative; } -.kpi-v{ font-family:var(--display); font-size:1.7rem; font-weight:700; color:var(--ink); - letter-spacing:-.01em; font-variant-numeric:tabular-nums; line-height:1.1; } -.kpi-l{ font-size:.78rem; color:var(--muted); margin-top:.2rem; } -.kpi-ico{ position:absolute; top:.7rem; right:.8rem; width:16px; height:16px; opacity:.35; } -.kpi-ico svg{ width:16px; height:16px; fill:none; stroke:var(--accent); stroke-width:1.6; } -.two-col{ display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin:1.4rem 0; } -.panel{ background:var(--panel); border:1px solid var(--line); border-radius:10px; - padding:1rem 1.2rem; } -.panel h3{ margin:.1rem 0 .4rem; font-size:1rem; } -.panel.target{ border-left:3px solid var(--accent); background:var(--accent-soft); } -.opp-cards{ display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:1rem; - margin:1.2rem 0; } -.opp-card{ display:block; text-decoration:none; color:inherit; background:var(--panel); - border:1px solid var(--line); border-radius:10px; padding:1.1rem 1.2rem; - transition:box-shadow .15s, transform .15s; } -.opp-card:hover{ box-shadow:0 4px 16px rgba(26,34,48,.10); transform:translateY(-1px); } -.opp-card h4{ margin:.45rem 0 .35rem; font-size:1rem; color:var(--ink); } -.opp-card p{ font-size:.85rem; color:var(--muted); margin:0; } -.opp-card .kfig{ margin-top:.6rem; } - -/* ── charts (inline SVG) ─────────────────────────────────────────────── */ -.chart-wrap{ background:linear-gradient(180deg,#fbfdff,#f5f8fc); border:1px solid var(--line); - border-radius:12px; padding:1.1rem 1.2rem .9rem; margin:1.2rem 0; overflow-x:auto; } -.chart-cap{ font-size:.8rem; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; - margin-bottom:.7rem; font-weight:600; } -.chart{ display:block; max-width:760px; } -.chart.donut{ max-width:380px; } - -/* ── use-case summary table ──────────────────────────────────────────── */ -table.usecase td{ font-size:.84rem; vertical-align:top; } -table.usecase th{ font-size:.82rem; } -.opmodel{ background:var(--accent-soft); border-radius:8px; padding:.6rem .9rem; margin:.9rem 0; } -.opmodel p{ margin:.35rem 0; } -.rationale td{ font-size:.86rem; } -.rate{ display:inline-block; font-weight:700; font-size:.72rem; letter-spacing:.03em; - text-transform:uppercase; border-radius:5px; padding:.08rem .45rem; margin-right:.4rem; +.step .who{ color:var(--muted); font-size:.8rem; } +.failpoint{ color:#8a5a00; background:var(--amber-bg); border:1px solid var(--amber-bd); + border-radius:4px; padding:.05rem .4rem; font-size:.78rem; display:inline-block; + margin:.15rem .15rem 0 0; } +.opmodel{ background:var(--note-bg); border-radius:6px; padding:.6rem .9rem; margin:.9rem 0; } +.opmodel p{ margin:.35rem 0; font-size:.86rem; } + +/* ── value/feasibility matrix (chip board) ── */ +.matrix{ display:grid; grid-template-columns:1fr 1fr; grid-auto-rows:130px; gap:.5rem; margin:1.2rem 0; } +.quad{ border:1px solid var(--line); border-radius:7px; padding:.7rem .8rem; background:var(--panel); } +.quad h4{ margin:0 0 .4rem; font-size:.72rem; color:var(--muted); text-transform:uppercase; + letter-spacing:.04em; } +.quad.do_first{ background:var(--note-bg); } +.chip{ display:inline-block; background:#fff; border:1px solid var(--blue); color:var(--blue-deep); + border-radius:5px; padding:.12rem .5rem; margin:.2rem .2rem 0 0; font-size:.8rem; } + +/* ── roadmap horizon detail ── */ +.horizon{ border-left:3px solid var(--blue); padding:.2rem 0 .2rem 1rem; margin:1rem 0; } +.horizon .win{ color:var(--muted); font-size:.84rem; } + +/* ── readiness rating badges ── */ +.rate{ display:inline-block; font-weight:700; font-size:.7rem; letter-spacing:.03em; + text-transform:uppercase; border-radius:4px; padding:.08rem .45rem; margin-right:.4rem; white-space:nowrap; } -.rate-high{ background:#e3f5ea; color:#1a7a44; } -.rate-medium{ background:#fdf4e3; color:#8a5a00; } -.rate-low{ background:#fbe7e7; color:#a32424; } +.rate-high{ background:var(--green-bg); color:#1a7a44; } +.rate-medium{ background:var(--amber-bg); color:#8a5a00; } +.rate-low{ background:var(--red-bg); color:#a32424; } .rate-na{ background:#eef1f5; color:var(--muted); } -/* ── cover page + running header/footer (screen: header hidden, cover compact) ─── */ -.rep-header{ display:none; } /* shown only in print */ -.rep-footer{ display:none; } /* shown only in print */ -.cover{ display:none; } /* shown only on the index / first print page */ -.cover-toc{ display:none; } /* the TOC page — print only */ -.toc{ margin:1.5rem 0 2rem; } -.toc a{ display:flex; align-items:baseline; text-decoration:none; color:var(--ink); - padding:.4rem 0; border-bottom:1px dotted var(--line); } -.toc .toc-num{ color:var(--accent); font-weight:700; width:1.8rem; flex:0 0 1.8rem; } -.toc .toc-t{ flex:1; } -.toc .toc-pg{ color:var(--muted); font-variant-numeric:tabular-nums; } -.brandmark{ display:inline-flex; align-items:center; gap:.5rem; font-weight:700; } +.badge-note{ font-size:.82rem; color:var(--muted); } + +/* ── infographics (inline SVG) ── */ +.fig{ background:var(--bg-light); border:1px solid var(--line); border-radius:8px; + padding:1rem 1.1rem .8rem; margin:1.2rem 0; overflow-x:auto; } +.fig-cap{ font-size:.76rem; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; + margin-bottom:.6rem; font-weight:700; } +.fig-foot{ font-size:.76rem; color:var(--muted); font-style:italic; text-align:center; + margin-top:.5rem; } +.chart{ display:block; max-width:760px; margin:0 auto; } +.chart.donut{ max-width:420px; } +.svg-full{ display:block; width:100%; } + +/* ── source-document pages ── */ +.srcdoc{ white-space:pre-wrap; word-break:break-word; background:var(--panel); + border:1px solid var(--line); border-radius:7px; padding:1rem; font-size:.82rem; + line-height:1.5; } + +/* ── per-report cover + own TOC (VISIBLE on screen too — each report is a standalone + scrolling document like the reference, not a print-only artefact) ── */ +.brandmark{ display:inline-flex; align-items:center; gap:.5rem; font-weight:800; } .brandmark svg{ width:22px; height:22px; } +.cover{ display:flex; margin:0 auto 1.5rem; box-shadow:0 2px 14px rgba(26,47,80,.16); + max-width:940px; } +.report-toc{ display:block; max-width:760px; margin:0 auto 2rem; padding:0 .5rem; } +.report-toc h1{ font-size:1.7rem; font-weight:800; color:var(--navy); margin:0 0 1rem; } +.toc{ margin:1.3rem 0 1.5rem; } +.toc a{ display:flex; align-items:baseline; text-decoration:none; color:var(--ink); + padding:.35rem 0; } +.toc .ti-num{ color:var(--navy); font-weight:700; width:2.4rem; flex:0 0 2.4rem; + font-variant-numeric:tabular-nums; } +.toc .ti-title{ color:var(--ink); } +.toc .ti-sub .ti-num{ font-weight:400; color:var(--muted); padding-left:1.1rem; + width:3.5rem; flex:0 0 3.5rem; } +.toc .ti-sub .ti-title{ color:var(--muted); font-size:.92rem; } +.toc .ti-dots{ flex:1; border-bottom:1px dotted var(--line); margin:0 .5rem .25rem; } +.toc .ti-page{ color:var(--muted); font-variant-numeric:tabular-nums; width:1.4rem; + text-align:right; flex:0 0 1.4rem; } +/* ── print: each report paginates standalone (own cover → own TOC → numbered body) ── */ @media print{ - /* content pages: A4 with a running brand (top-right) + confidentiality/page note (bottom). - CSS @page margin boxes render reliably in headless Chrome and never collide with headings. */ @page{ size:A4; margin:16mm 14mm 15mm 14mm; @top-right{ content:"AuroPro · Autonomous Discovery"; font-size:8pt; color:#9aa7b6; } @bottom-left{ content:"Confidential"; font-size:8pt; color:#9aa7b6; } @bottom-right{ content:"Page " counter(page); font-size:8pt; color:#9aa7b6; } } - /* the cover is a full-bleed first page with NO margin boxes */ @page cover{ margin:0; @top-right{ content:none; } @bottom-left{ content:none; } @bottom-right{ content:none; } } body{ background:#fff; } - .sidebar{ display:none; } - .layout{ display:block; } - .content{ max-width:none; padding:0; } - .rep-header,.rep-footer{ display:none; } - /* page-break hygiene: keep SMALL visual blocks whole (charts, KPI tiles, panels, chart-wraps, - table rows). Big prose CARDS may split across a page boundary rather than leaving a tall - half-empty page before them — readability is fine and pages fill naturally. */ - .kpi,.panel,.opp-card,.horizon,.opmodel,tr,.chart-wrap{ break-inside:avoid; } + .topnav{ display:none; } + .content{ max-width:none; margin:0; padding:0; } + .cover{ display:flex; page:cover; break-after:page; margin:0; max-width:none; + box-shadow:none; } + .report-toc{ display:block; break-after:page; max-width:none; } + .stat-box,.mini,.note-box,.high-box,.med-box,.ev-quote,.pp-hdr,.opp-card,.horizon,.opmodel, + .prin-card,.fig,.kpi-row,.rec-hdr,tr{ break-inside:avoid; } h1,h2,h3,h4{ break-after:avoid; } - /* the cover owns the @page cover (full bleed); the TOC and body follow on fresh pages */ - .cover{ display:flex; page:cover; break-after:page; } - .cover-toc{ display:block; break-after:page; } - a[href]{ color:var(--ink); text-decoration:none; } /* links print as plain text */ + p,li{ orphans:2; widows:2; } /* never strand a single line across a page break */ + a[href]{ color:var(--ink); text-decoration:none; } } -/* ── branded cover (print) — full-bleed A4 ──────────────────────────────── */ -.cover{ flex-direction:column; justify-content:center; width:210mm; min-height:297mm; - background:linear-gradient(128deg,#0f7c8c 0 56%,#243043 56% 100%); color:#fff; - padding:48mm 24mm; box-sizing:border-box; } -.cover .ctitle{ font-family:var(--display); font-size:34pt; font-weight:700; line-height:1.1; - max-width:66%; letter-spacing:-.01em; } -.cover .csub{ font-size:13pt; margin-top:1.1rem; opacity:.9; } -.cover .cbrand{ margin-top:auto; font-size:12pt; display:flex; align-items:center; gap:.55rem; } -.cover .cbrand svg{ width:30px; height:30px; } - -@media (max-width:760px){ .layout{ flex-direction:column; } .sidebar{ width:100%; height:auto; - position:static; } .matrix,.two-col,.opp-cards{ grid-template-columns:1fr; } +/* ── branded cover (print) — full-bleed A4, navy/blue corporate ── */ +.cover{ flex-direction:column; width:210mm; min-height:297mm; background:#fff; + box-sizing:border-box; } +.cv-top{ height:8mm; background:var(--navy); } +.cv-brand{ height:18mm; background:var(--navy); display:flex; align-items:center; padding:0 24mm; + gap:14px; } +.cv-brand .brandmark{ color:#fff; font-size:13pt; letter-spacing:.12em; text-transform:uppercase; } +.cv-brand .cv-sub{ color:rgba(255,255,255,.55); font-size:8.5pt; padding-left:14px; + border-left:1px solid rgba(255,255,255,.3); } +.cv-accent{ height:4px; background:var(--blue); } +.cv-body{ flex:1; padding:34mm 24mm 24mm; } +.cv-tag{ display:inline-block; font-size:10pt; font-weight:700; text-transform:uppercase; + letter-spacing:.1em; color:var(--blue); border:1.5px solid var(--blue); border-radius:3px; + padding:.25rem .8rem; } +.cv-title{ font-size:30pt; font-weight:800; color:var(--navy); line-height:1.12; + letter-spacing:-.02em; margin:1.4rem 0 .5rem; } +.cv-domain{ font-size:13pt; color:var(--blue-mid); } +.cv-meta{ display:grid; grid-template-columns:46mm 1fr; margin-top:14mm; } +.cv-meta .cml{ font-size:8.5pt; font-weight:700; text-transform:uppercase; color:var(--muted); + padding:.5rem 0; border-bottom:1px solid var(--line-soft); letter-spacing:.04em; } +.cv-meta .cmv{ font-size:9pt; color:var(--ink); font-weight:600; padding:.5rem 0; + border-bottom:1px solid var(--line-soft); } +.cv-bottom{ height:22mm; background:var(--navy); margin-top:auto; display:flex; align-items:center; + justify-content:space-between; padding:0 24mm; } +.cv-bot-txt{ font-size:8.5pt; color:rgba(255,255,255,.55); text-transform:uppercase; + letter-spacing:.04em; } +.cv-bot-badge{ font-size:8.5pt; font-weight:700; color:rgba(255,255,255,.85); + border:1px solid rgba(255,255,255,.3); border-radius:3px; padding:.2rem .7rem; } + +@media (max-width:760px){ .matrix,.two-col,.opp-cards,.principles{ grid-template-columns:1fr; } .ba-grid{ grid-template-columns:1fr; } .ba-arrow{ transform:rotate(90deg); } - .content{ padding:1.5rem; } } + .content{ padding:1.4rem; } .cover{ width:100%; min-height:0; } } """ JS = "" # no JS needed for standalone pages diff --git a/v1/discovery/reportsuite/build.py b/v1/discovery/reportsuite/build.py index 5003936..930c624 100644 --- a/v1/discovery/reportsuite/build.py +++ b/v1/discovery/reportsuite/build.py @@ -12,9 +12,10 @@ from .. import docnames from ..models import ( - BusinessImpact, CurrentState, ExecutiveSummary, FormatPattern, Handoff, InventoryItem, - MatrixQuadrant, MetricItem, NumberRef, Opportunity, OppPattern, PainPoint, ProcessStep, RaciRow, - RoadmapHorizon, RoadmapItem, SourceDoc, SourceRef, SynthesisContent, SystemProfile, + BusinessImpact, CurrentState, DataTable, EvidenceRow, ExecutiveSummary, FormatPattern, Handoff, + InventoryItem, KeyStat, MatrixQuadrant, MetricItem, NumberRef, Opportunity, OppPattern, PainPoint, + ProcessDetail, ProcessStep, RaciRow, RiskItem, RoadmapHorizon, RoadmapItem, SourceDoc, SourceRef, + SynthesisContent, SystemProfile, TraceRow, ) from ..synthesis import OPP_TO_PP, PP_TO_OPP, allowed_numbers, validate_synthesis @@ -33,8 +34,24 @@ class NoFixtureForDomain(Exception): def build_synthesis(raw_payload: dict, *, domain: str = "o2c", live=False, llm=None, - doc_keys=None, model=None, suppress_names=None) -> SynthesisContent: - if live: + doc_keys=None, model=None, suppress_names=None, reg=None, + fanout=True) -> SynthesisContent: + """Build the report content. Live runs use the DEEP per-report fan-out by default (fact-store → + per-report/per-opportunity generation → reference-depth SynthesisContent); pass fanout=False for + the legacy single-emit path. `reg` (the domain registry) is required by the fan-out to build the + grounded fact-store; it falls back to the legacy path when absent.""" + if live and fanout and reg is not None: + from .. import fanout_specs + merged, planning, fs, strat = fanout_specs.run_report_fanout( + llm, raw_payload, reg, doc_keys=doc_keys, model=model) + content = _from_payload(merged) + content.fact_store = fs + content.strategy = strat + content.planning_assumptions = planning + # surface only the NON-EMPTY strategy fields alongside r05's posture (don't blank anything) + content.strategy_profile = {**content.strategy_profile, + **{k: v for k, v in strat.to_dict().items() if v}} + elif live: from .. import synthesis payload = synthesis.run_synthesis(llm, raw_payload, doc_keys or [], model=model, suppress_names=suppress_names) @@ -246,48 +263,248 @@ def fixture_o2c() -> SynthesisContent: "notes — the lived reality of how exceptions are actually handled.", examples="Customer-service escalation log, EDI dispute-resolution working notes."), ], + baseline_stats=[ + KeyStat(value="8,420", label="Total orders processed", sublabel="calendar year 2025"), + KeyStat(value="67.3%", label="Orders received via EDI", sublabel="5,667 of 8,420"), + KeyStat(value="14", label="Active EDI connections", sublabel="8 owned + 6 under TSA"), + KeyStat(value="340", label="Active customer accounts", sublabel="in the ERP master"), + ], + data_tables=[ + DataTable( + title="Order channel mix — 2025", + columns=["Channel", "Orders", "Share", "Entry method", "Not fulfilled"], + rows=[ + ["EDI", "5,667", "67.3%", "Automated into order management", "1,196"], + ["Telephone / manual", "1,802", "21.4%", "Keyed into the ERP by an agent", "320"], + ["Email", "767", "9.1%", "Keyed in within one business day", "111"], + ["Fax", "184", "2.2%", "Keyed in; by exception agreement only", "40"], + ["Total", "8,420", "100%", "—", "1,667"], + ], + caption="Volumes and shares are counted directly from the order-flow export.", + note="Fax appears in the order data although the Order Management SOP lists only " + "telephone and email as standard channels.", + sources=[SourceRef(doc_id=FLOW)]), + DataTable( + title="Standard fulfilment lead times by market", + columns=["Market", "Lead time", "Primary distribution centre"], + rows=[ + ["France", "2 business days", "Chartres, FR"], + ["United Kingdom", "3 business days", "Swindon, UK"], + ["Germany", "2 business days", "Frankfurt, DE"], + ["Austria / Switzerland", "3 business days", "Frankfurt, DE"], + ["Spain", "3 business days", "Barcelona, ES"], + ["Portugal", "4 business days", "Barcelona, ES"], + ["Benelux", "2 business days", "Antwerp, BE"], + ["Italy", "3 business days", "Milan, IT (third-party operated)"], + ], + caption="Documented standard lead times and the distribution centre serving each " + "market.", + sources=[SourceRef(doc_id=SOP)]), + DataTable( + title="Credit limit approval authority", + columns=["Credit limit band", "New account", "Limit increase"], + rows=[ + ["Up to €250,000", "Credit Controller", "Credit Controller"], + ["€250,001 – €500,000", "Finance Director", "Finance Director"], + ["€500,001 – €1,000,000", "Finance Director + VP Commercial", + "Finance Director"], + ["Above €1,000,000", "Finance Director + VP Commercial + CEO", + "Finance Director + VP Commercial"], + ], + caption="Approval authority for setting and changing credit limits, by band.", + sources=[SourceRef(doc_id=POLICY)]), + DataTable( + title="Collections escalation ladder", + columns=["Overdue", "Action", "Owner"], + rows=[ + ["1–30 days", "Automated payment reminder on day 5", "System"], + ["31–60 days", "Formal payment demand; account flagged", "Credit Controller"], + ["61–90 days", "Second demand; credit-hold proposed", "Credit Controller"], + ["91+ days", "Account on hold; new orders suspended; agency referral considered", + "Finance Director"], + ], + caption="The documented overdue-collections escalation ladder.", + sources=[SourceRef(doc_id=POLICY)]), + DataTable( + title="EDI connection inventory", + columns=["#", "Trading partner", "Country", "Managed by", "Transition status"], + rows=[ + ["1", "Tesco", "UK", "Own platform", "Owned"], + ["2", "Mercadona", "ES", "Own platform", "Owned"], + ["3", "REWE Group", "DE", "Own platform", "Owned"], + ["4", "Boots (new)", "UK", "Own platform", "Owned (live Aug 2025)"], + ["5", "Alliance Healthcare", "EU", "Own platform", "Owned"], + ["6", "Rite Aid Europe", "EU", "Own platform", "Owned"], + ["7", "Aldi Europe", "EU", "Own platform", "Owned"], + ["8", "Coop Group (new)", "EU", "Own platform", "Owned"], + ["9", "Carrefour France", "FR", "Parent (TSA)", "Target Q4 2025"], + ["10", "Boots (legacy)", "UK", "Parent (TSA)", "Target Q1 2026"], + ["11", "dm (Drogerie Markt)", "DE", "Parent (TSA)", "Target Q1 2026"], + ["12", "E.Leclerc", "FR", "Parent (TSA)", "Target Q2 2026 (provisional)"], + ["13", "Lidl Europe", "EU", "Parent (TSA)", "Target Q2 2026 (provisional)"], + ["14", "Coop Group (legacy)", "EU", "Parent (TSA)", "Target Q3 2026 (provisional)"], + ], + caption="All 14 live EDI connections: 8 on the organisation's own platform, 6 still " + "operated by the former parent under the transitional service arrangement.", + note="Migration targets for connections 12–14 are provisional pending technical " + "scoping; the governing service terms sit in a schedule not included in the " + "pack.", + sources=[SourceRef(doc_id="edi-integration-register-opella-europe")]), + DataTable( + title="Top trading accounts — credit baseline", + columns=["Account", "Country", "ERP credit limit", "Payment terms", "Status"], + rows=[ + ["Carrefour France", "FR", "€1,800,000", "NET45", "Active"], + ["Boots UK", "UK", "€1,200,000", "NET45", "Active"], + ["E.Leclerc", "FR", "€1,100,000", "NET45", "Active"], + ["Tesco UK", "UK", "€1,000,000", "NET45", "Active"], + ["dm (Drogerie Markt)", "DE", "€950,000", "NET45", "Active"], + ["Lidl Europe", "EU", "€850,000", "NET45", "Active"], + ["Coop Group", "EU", "€800,000", "NET45", "Active"], + ["Mercadona", "ES", "€750,000", "NET45", "Active"], + ], + caption="The eight accounts that trade across all channels, with their ERP credit " + "baseline.", + sources=[SourceRef(doc_id=ERP)]), + DataTable( + title="Systems in scope", + columns=["System", "Role", "Hosting"], + rows=[ + ["SAP S/4HANA", "Core ERP; authoritative for credit limits and balances", + "Enterprise"], + ["SAP CRM", "Customer relationship records (not authoritative for credit)", + "Enterprise"], + ["Own EDI platform", "Routes the 8 owned connections", + "Own private cloud, Frankfurt"], + ["Former parent's EDI platform", "Routes the 6 connections still under the " + "arrangement", "Former parent"], + ], + caption="The systems the order-to-cash process touches and who hosts them.", + sources=[SourceRef(doc_id="edi-integration-register-opella-europe"), + SourceRef(doc_id=POLICY)]), + ], + process_detail=[ + ProcessDetail( + title="Order receipt", + actor="Customer Service", system="EDI / ERP", + body="EDI orders flow automatically into order management across 14 connections (8 " + "on the organisation's own platform, 6 still operated by the former parent). " + "Telephone orders are keyed into the ERP within 30 minutes of the call; email " + "orders within one business day, against per-market cut-off times.", + sources=[SourceRef(doc_id=SOP), + SourceRef(doc_id="edi-integration-register-opella-europe")]), + ProcessDetail( + title="Order validation", + actor="Customer Service", system="SAP S/4HANA", + body="A mandatory sequence runs in the ERP: customer account status, credit-limit " + "verification against the outstanding balance, product availability at the " + "allocated distribution centre, minimum-order-quantity compliance, and pricing " + "validation (a variance under 2% is processed at the system price; 2% or more " + "is referred to the account manager).", + sources=[SourceRef(doc_id=SOP)]), + ProcessDetail( + title="Credit assessment", + actor="Credit Control", system="SAP S/4HANA", + body="The ERP places an order on credit hold automatically where it would take the " + "account past its approved limit, or where an invoice is more than 60 days " + "overdue. The agent notifies the Credit Controller within four business hours; " + "release authority follows the approval-authority bands.", + sources=[SourceRef(doc_id=POLICY), SourceRef(doc_id=SOP)]), + ProcessDetail( + title="Order confirmation & fulfilment", + actor="Customer Service / Distribution", system="ERP / order management", + body="Confirmation issues within four business hours for telephone orders and two " + "for email orders. Released orders are picked, packed and dispatched from the " + "market's distribution centre against the documented standard lead times.", + sources=[SourceRef(doc_id=SOP)]), + ProcessDetail( + title="Invoicing", + actor="Finance", system="SAP S/4HANA", + body="Invoices generate automatically on dispatch confirmation. Payment terms follow " + "the account tier: major retail on NET 45 with a 1% early-payment discount, " + "pharmacy and wholesale on NET 30, and new accounts on NET 14 or cash with " + "order for the first six months.", + sources=[SourceRef(doc_id=SOP), SourceRef(doc_id=POLICY)]), + ProcessDetail( + title="Accounts receivable & collections", + actor="Accounts Receivable", system="SAP S/4HANA", + body="Aged-debtor reporting runs monthly; balances over 60 days are reviewed " + "individually. Collections follow the overdue escalation ladder, and the team " + "completes a quarterly self-assessment of compliance.", + sources=[SourceRef(doc_id=POLICY)]), + ], ) pain_points = [ PainPoint( id="PP1", title="Two customer systems disagree on credit limits", impact_rank=1, - from_finding="F1", + from_finding="F1", category="Data Governance", severity="high", description="Your ERP and CRM hold different credit limits and payment terms for the " "same major retail accounts, with no single agreed source of truth.", root_cause="Both systems were carried over at carve-out and were never reconciled; CRM " "limits were updated by hand without an approval trail.", failure_pattern="Orders are released against whichever limit the system resolves first, " "so the same account can trade on two different limits.", + business_consequence="Credit can be extended beyond the policy-approved limit, and the " + "same account can be invoiced on two different payment terms.", quantified=[ NumberRef(value=267, unit="accounts", label="accounts with different credit limits", text="267 of the shared accounts", sources=_src(ERP, CRM)), NumberRef(value=600000, unit="eur", label="largest single gap (Carrefour France)", text="€600,000", sources=_src(ERP, CRM, AR)), ], + detail_table=DataTable( + title="Credit-limit discrepancy register — top accounts", + columns=["Account", "Country", "ERP limit", "CRM limit", "ERP terms", "CRM terms"], + rows=[ + ["Carrefour France", "FR", "€1,800,000", "€2,400,000", "NET45", "NET30"], + ["Boots UK", "UK", "€1,200,000", "€1,550,000", "NET45", "NET30"], + ["E.Leclerc", "FR", "€1,100,000", "€1,400,000", "NET45", "NET30"], + ["Tesco UK", "UK", "€1,000,000", "€1,350,000", "NET45", "NET30"], + ["dm (Drogerie Markt)", "DE", "€950,000", "€1,150,000", "NET45", "NET30"], + ], + caption="Where the two systems disagree, account by account — limits and terms.", + sources=[SourceRef(doc_id=ERP), SourceRef(doc_id=CRM)]), sources=_src(ERP, CRM, AR, POLICY)), PainPoint( id="PP2", title="Two-thirds of orders run on an undocumented channel", impact_rank=2, - from_finding="F2", + from_finding="F2", category="Process Coverage", severity="high", description="EDI carries most of your order volume, yet it is not covered by the Order " "Management SOP and has no owner in the Order-to-Cash RACI.", root_cause="The documented process was written for manual and email orders; EDI grew " "to dominate without the procedure or accountability catching up.", failure_pattern="EDI orders that fail are handled informally, with no governed process " "or named owner.", + business_consequence="The channel carrying most of the order value runs with no " + "documented procedure and no accountable owner.", quantified=[ NumberRef(value=67, unit="percent", label="EDI share of orders", text="67% of orders (5,667 of 8,420)", sources=_src(FLOW, NOTES)), ], + detail_table=DataTable( + title="Document-level evidence", + columns=["Document", "How it treats EDI"], + rows=[ + ["Order Management SOP", "Lists telephone and email as the standard channels; " + "EDI is out of scope."], + ["Order-to-Cash RACI", "Covers manual and email steps only; no EDI rows."], + ["EDI dispute working notes", "Informal notes, explicitly 'not an official " + "SOP'."], + ], + caption="Where each governing document leaves the EDI channel.", + sources=[SourceRef(doc_id=SOP), SourceRef(doc_id=RACI), SourceRef(doc_id=NOTES)]), sources=_src(FLOW, RACI, SOP, NOTES)), PainPoint( id="PP3", title="EDI order failures concentrate on the unowned channel", impact_rank=3, - from_finding="F3", + from_finding="F3", category="Operational Resilience", severity="high", description="A large block of EDI orders is not fulfilled, on the same channel that has " "no documented process or owner.", root_cause="Without a governed EDI process, failures are absorbed reactively through " "manual re-entry rather than prevented.", failure_pattern="EDI orders not processed is the single most common customer-service " "escalation.", + business_consequence="The largest single block of unfulfilled orders — and the most " + "frequent escalation — sits on the channel nobody owns.", quantified=[ NumberRef(value=1196, unit="orders", label="EDI orders not fulfilled", text="1,196 EDI orders", sources=_src(FLOW)), @@ -296,7 +513,71 @@ def fixture_o2c() -> SynthesisContent: NumberRef(value=34, unit="escalations", label="EDI-not-processed escalations", text="34 escalations", sources=_src(ESC, NOTES)), ], + detail_table=DataTable( + title="Unfulfilled orders by channel", + columns=["Channel", "Orders not fulfilled"], + rows=[["EDI", "1,196"], ["Telephone / manual", "320"], ["Email", "111"], + ["Fax", "40"]], + caption="Unfulfilled orders cluster on the EDI channel.", + sources=[SourceRef(doc_id=FLOW)]), sources=_src(FLOW, ESC, NOTES)), + PainPoint( + id="PP4", title="Six EDI connections still run on the former parent's platform", + impact_rank=2, from_finding="F2", category="Third-Party Dependency", severity="medium", + description="Six of the fourteen live EDI connections are still operated by the former " + "parent under a transitional service arrangement, with the service terms " + "held in a schedule that is not in the document pack.", + root_cause="At carve-out, eight connections moved to the organisation's own platform " + "while six remained on the parent's; the migration is partly complete.", + failure_pattern="When one of these six connections has an incident, resolution depends " + "on the parent's team and a transitional agreement the organisation does " + "not control.", + business_consequence="The organisation cannot guarantee its own response times on the " + "connections carrying several of its largest retail accounts.", + quantified=[ + NumberRef(value=6, unit="count", label="connections still under the arrangement", + text="6 of 14 connections", sources=_src(NOTES)), + ], + detail_table=DataTable( + title="Connections still under the transitional arrangement", + columns=["Trading partner", "Country", "Migration target"], + rows=[ + ["Carrefour France", "FR", "Q4 2025"], + ["Boots (legacy)", "UK", "Q1 2026"], + ["dm (Drogerie Markt)", "DE", "Q1 2026"], + ["E.Leclerc", "FR", "Q2 2026 (provisional)"], + ["Lidl Europe", "EU", "Q2 2026 (provisional)"], + ["Coop Group (legacy)", "EU", "Q3 2026 (provisional)"], + ], + caption="The six connections to bring onto the organisation's own platform.", + note="The governing service terms sit in a schedule not included in the document " + "pack; the migration dates are the register's stated targets.", + sources=[SourceRef(doc_id="edi-integration-register-opella-europe"), + SourceRef(doc_id=NOTES)]), + sources=_src("edi-integration-register-opella-europe", NOTES)), + PainPoint( + id="PP5", title="The two customer masters are not aligned on which accounts exist", + impact_rank=3, from_finding="F1", category="Data Governance", severity="medium", + description="The ERP holds more customer accounts than the CRM, so the two systems do " + "not even agree on which accounts exist, let alone their credit terms.", + root_cause="Accounts were created in the ERP without a matching record being maintained " + "in the CRM after carve-out.", + failure_pattern="Twenty-two accounts exist in the ERP with no CRM counterpart, so any " + "process that reads the CRM misses them entirely.", + business_consequence="A clean reconciliation cannot be completed until the account " + "populations themselves are aligned.", + quantified=[ + NumberRef(value=22, unit="accounts", label="accounts in the ERP but not the CRM", + text="22 accounts", sources=_src(ERP, CRM)), + ], + detail_table=DataTable( + title="Customer-master population", + columns=["System", "Active accounts"], + rows=[["ERP customer master", "340"], ["CRM customer records", "318"], + ["In the ERP only", "22"]], + caption="The two masters hold different account populations.", + sources=[SourceRef(doc_id=ERP), SourceRef(doc_id=CRM)]), + sources=_src(ERP, CRM)), ] cross = [{"pattern": "Undocumented EDI channel drives both volume and failure", @@ -483,23 +764,135 @@ def fixture_o2c() -> SynthesisContent: value_rating="high", feasibility_rating="low", value_score=5, feasibility_score=2, matrix_quadrant=MatrixQuadrant.PLAN_FOR, sources=_src(FLOW, RACI)) + REG = "edi-integration-register-opella-europe" + opp4 = Opportunity( + id="OPP4", title="EDI Connection Transition Programme", pattern=OppPattern.MODERNISATION, + overview="Bring the six EDI connections still operated by the former parent onto the " + "organisation's own platform, so it controls the service end to end.", + before_process=[ + ProcessStep(seq=1, name="Incident on a parent-run connection", actor="Customer Service", + description="An EDI incident occurs on one of the six connections operated " + "by the former parent."), + ProcessStep(seq=2, name="Resolution waits on the parent", actor="Former parent IT", + description="Resolution depends on the parent's team under the transitional " + "arrangement.", + failure_points=["No control over response times on these connections", + "Governing service terms are not in the organisation's " + "hands"]), + ], + after_process=[ + ProcessStep(seq=1, name="Connection migrated", actor="EDI integration team", + system="Own EDI platform", + description="Each connection is migrated onto the organisation's own EDI " + "platform on the published schedule."), + ProcessStep(seq=2, name="Service owned end to end", actor="EDI integration team", + description="Incidents are resolved under the organisation's own service " + "levels, with no dependency on the former parent."), + ], + business_impact=BusinessImpact( + narrative="Removes the dependency on the former parent for the connections carrying " + "several of the largest retail accounts.", + quantified=[NumberRef(value=6, unit="count", label="connections brought in-house", + text="6 of 14 connections", sources=_src(NOTES))], + derivation="Six of the fourteen live connections remain under the transitional " + "arrangement today."), + implementation_approach="A phased migration on the published schedule, one connection at a " + "time, validated against live order flow before the parent's " + "connection is decommissioned.", + required_integrations=["Own EDI platform", "Former parent's EDI platform (during cutover)"], + success_metrics=["All connections operated on the organisation's own platform", + "Incident resolution under the organisation's own service levels"], + dependencies=[], + risks=["The governing service terms are not in the document pack", + "Migration dates for the later connections are provisional"], + personas=["EDI integration team", "Customer Service", "Commercial / account managers"], + knowledge_sources=["EDI integration register", "EDI dispute working notes"], + document_formats=["Technical register", "Operational working notes"], + expected_behaviour="Each of the six connections is migrated to the organisation's own " + "platform on the schedule and validated against live order flow before " + "the parent's connection is retired. The programme touches one connection " + "at a time so order flow is never interrupted.", + escalation="Where a connection's service terms or migration date are unclear, the item is " + "held for the EDI integration lead and the transition programme office rather " + "than migrated blind.", + data_readiness="medium — the connection inventory and managing entity are documented, but " + "the governing service terms sit in a schedule not included in the pack.", + technical_complexity="high — a live cutover of production EDI connections between two " + "platforms, coordinated with an external party.", + operational_readiness="medium — the organisation owns eight connections already and has run " + "two migrations, but the remaining six depend on the former parent's " + "cooperation.", + value_rating="medium", feasibility_rating="medium", value_score=3, feasibility_score=3, + matrix_quadrant=MatrixQuadrant.PLAN_FOR, sources=_src(REG, NOTES)) + + opp5 = Opportunity( + id="OPP5", title="Customer Master Population Alignment", pattern=OppPattern.HITL, + overview="Align the two customer masters on which accounts exist, so the credit " + "reconciliation has a clean, complete population to work from.", + before_process=[ + ProcessStep(seq=1, name="Account created in the ERP", actor="Customer Service", + description="A new account is set up in the ERP."), + ProcessStep(seq=2, name="No matching CRM record", actor="—", + description="No matching record is maintained in the CRM.", + failure_points=["22 accounts exist in the ERP with no CRM counterpart", + "Any process reading the CRM misses them"]), + ], + after_process=[ + ProcessStep(seq=1, name="Populations compared", actor="Data steward", + system="ERP / CRM", + description="The two masters are compared and the missing accounts " + "surfaced for review."), + ProcessStep(seq=2, name="Records aligned & governed", actor="Data steward", + description="Missing records are created or retired under a maintained " + "onboarding rule so the populations stay aligned."), + ], + business_impact=BusinessImpact( + narrative="Aligns the account populations so the credit reconciliation works from a " + "complete, agreed set of accounts.", + quantified=[NumberRef(value=22, unit="accounts", label="accounts to align", + text="22 accounts", sources=_src(ERP, CRM))], + derivation="The ERP holds 340 active accounts to the CRM's 318 — a 22-account " + "difference."), + implementation_approach="A human-in-the-loop alignment: the platform surfaces the population " + "difference and a data steward confirms each create-or-retire " + "decision.", + required_integrations=["SAP S/4HANA customer master", "SAP CRM customer records"], + success_metrics=["The two masters hold the same active-account population", + "A maintained rule keeps them aligned at onboarding"], + dependencies=[], + prerequisite_for=[], + risks=["Some ERP-only accounts may be legitimately inactive and need retiring, not adding"], + personas=["Data steward", "Commercial / account managers", "Finance Systems"], + knowledge_sources=["SAP S/4HANA customer master", "SAP CRM customer records"], + document_formats=["Structured master-data export"], + expected_behaviour="The platform lists the accounts present in one master but not the other " + "and proposes a create-or-retire action for each; a data steward confirms " + "every decision. It never creates or retires a record on its own.", + escalation="Any account whose status is ambiguous (e.g. possibly inactive) is held for the " + "data steward and the account manager to decide rather than auto-aligned.", + data_readiness="high — both account populations are structured fields; the 22-account " + "difference is computable directly from the two extracts.", + technical_complexity="low — a compare-and-confirm over two master-data extracts.", + operational_readiness="medium — the alignment needs a named data steward and a maintained " + "onboarding rule, which do not exist today.", + value_rating="medium", feasibility_rating="high", value_score=3, feasibility_score=4, + matrix_quadrant=MatrixQuadrant.PLAN_FOR, sources=_src(ERP, CRM)) + roadmap = [ RoadmapHorizon(horizon="H1", window="0-6 months", theme="Stabilise the foundations", items=[ RoadmapItem(title="Customer Master Reconciliation", opportunity_id="OPP1", rationale="Establishes the single source of truth the rest depends on."), RoadmapItem(title="EDI Order Exception Handling", opportunity_id="OPP2", rationale="Cuts the most frequent escalation; independent, can run now."), - RoadmapItem(title="Transition the EDI connections still operated under the transitional " - "service arrangement", rationale="Brings the inherited connections " - "under the organisation's own control as part of standing up " - "independently."), + RoadmapItem(title="Customer Master Population Alignment", opportunity_id="OPP5", + rationale="Aligns the account populations so the reconciliation is clean."), ]), - RoadmapHorizon(horizon="H2", window="6-18 months", theme="Close the credit gap", items=[ + RoadmapHorizon(horizon="H2", window="6-18 months", theme="Close the gaps", items=[ RoadmapItem(title="AI Credit Decisioning", opportunity_id="OPP3", rationale="Credit-checks the dominant EDI channel; needs OPP1 first.", depends_on=["OPP1"]), - RoadmapItem(title="EDI middleware assessment", - rationale="Assess the EDI integration layer ahead of modernisation."), + RoadmapItem(title="EDI Connection Transition Programme", opportunity_id="OPP4", + rationale="Brings the six parent-run connections onto the own platform."), ]), RoadmapHorizon(horizon="H3", window="18+ months", theme="Rationalise the landscape", items=[ RoadmapItem(title="CRM consolidation", @@ -513,15 +906,18 @@ def fixture_o2c() -> SynthesisContent: return SynthesisContent( current_state=current, pain_points=pain_points, cross_process_patterns=cross, - opportunities=[opp1, opp2, opp3], + opportunities=[opp1, opp2, opp3, opp4, opp5], sequencing_rationale="Customer Master Reconciliation (OPP1) comes first because AI Credit " "Decisioning (OPP3) needs a clean, single credit limit per account to " - "work. EDI Order Exception Handling (OPP2) is independent and runs in " - "parallel from the start.", + "work. EDI Order Exception Handling (OPP2) and the population alignment " + "(OPP5) are independent and run in parallel from the start; the " + "connection transition (OPP4) follows on the published migration " + "schedule.", strategic_readiness="The current state can support the near-term moves once the customer " "master is reconciled; the credit decisioning step is the main capability " "to build toward.", - dependency_notes="OPP3 depends on OPP1. OPP1 and OPP2 are independent of each other.", + dependency_notes="OPP3 depends on OPP1. OPP1, OPP2, OPP4 and OPP5 are independent of each " + "other and can run in parallel.", roadmap=roadmap, strategy_profile={"posture": "consolidate_modernize", "notes": "Consolidate the inherited landscape while modernising for the " @@ -557,6 +953,110 @@ def fixture_o2c() -> SynthesisContent: "analyst confirms as correct on review.", target="A high confirmation rate at go-live, improving through tuning; tracked " "against analyst review."), + MetricItem( + name="EDI connection ownership", + definition="Share of the 14 live EDI connections operated on the organisation's own " + "platform rather than the former parent's (8 of 14 today).", + target="All 14 connections on the organisation's own platform on the published " + "migration schedule."), + MetricItem( + name="Customer-master population alignment", + definition="Whether the ERP and CRM hold the same active-account population (a " + "22-account difference today).", + target="A single agreed active-account population across both systems, kept aligned " + "at onboarding."), + ], + evidence_register=[ + EvidenceRow(finding="PP1", source="ERP and CRM customer exports", + evidence_type="Structured data", + data_point="267 of 318 shared accounts hold a different credit limit; " + "Carrefour France differs by €600,000.", confidence="Verified"), + EvidenceRow(finding="PP1", source="Credit Policy", + evidence_type="Policy document", + data_point="The ERP is named the sole authoritative system for credit " + "limits.", confidence="Verified"), + EvidenceRow(finding="PP1", source="Accounts Receivable review notes", + evidence_type="Review note", + data_point="'Our credit policy does not define which system is " + "authoritative.'", confidence="Amber"), + EvidenceRow(finding="PP2", source="Order flow export", + evidence_type="Structured data", + data_point="EDI carries 67.3% of orders (5,667 of 8,420).", + confidence="Verified"), + EvidenceRow(finding="PP2", source="Order Management SOP; Order-to-Cash RACI", + evidence_type="Policy documents", + data_point="Neither the SOP nor the RACI covers the EDI channel.", + confidence="Verified"), + EvidenceRow(finding="PP3", source="Order flow export; escalation log", + evidence_type="Structured data", + data_point="1,196 unfulfilled EDI orders (€12.4M); 34 'EDI not processed' " + "escalations.", confidence="Verified"), + EvidenceRow(finding="PP4", source="EDI integration register", + evidence_type="Technical register", + data_point="6 of 14 connections remain under the transitional service " + "arrangement.", confidence="Verified"), + EvidenceRow(finding="PP4", source="EDI dispute working notes", + evidence_type="Working notes", + data_point="Governing service terms and a firm transfer date are not in the " + "pack.", confidence="Gap"), + EvidenceRow(finding="PP5", source="ERP and CRM customer exports", + evidence_type="Structured data", + data_point="340 active accounts in the ERP versus 318 in the CRM — a " + "22-account difference.", confidence="Verified"), + ], + risk_register=[ + RiskItem(risk="The credit and commercial teams cannot agree the canonical credit limit " + "for a contested account.", + likelihood="Medium", impact="High", + mitigation="Material gaps (e.g. the €600,000 Carrefour France gap) are held " + "for the Credit Controller to adjudicate; nothing auto-resolves.", + owner="Credit Controller"), + RiskItem(risk="The automated EDI exception rules do not cover an edge case, so it still " + "reaches an agent.", + likelihood="Medium", impact="Medium", + mitigation="Unmatched and low-confidence exceptions route to an agent with the " + "source order attached; coverage is reviewed as new types appear.", + owner="Customer Service Lead"), + RiskItem(risk="Credit analysts do not trust the automated credit assessment and " + "override it by default.", + likelihood="Medium", impact="High", + mitigation="Every hold and low-confidence case is shown with its reasoning and " + "the limit used, so analysts can confirm rather than re-derive.", + owner="Credit Controller"), + RiskItem(risk="A TSA connection's migration slips because the former parent's " + "cooperation or service terms are unclear.", + likelihood="High", impact="Medium", + mitigation="Migrate one connection at a time on the published schedule, " + "validating against live order flow before decommissioning.", + owner="EDI integration lead"), + RiskItem(risk="ERP-only accounts are added to the CRM when some should instead be " + "retired as inactive.", + likelihood="Low", impact="Medium", + mitigation="A data steward confirms each create-or-retire decision; ambiguous " + "accounts are held for the account manager.", + owner="Data steward"), + ], + traceability=[ + TraceRow(pain_point="PP1", summary="ERP/CRM disagree on credit limits", + severity="High", recommendation="R-01", opportunity="OPP1", + expected_outcome="One agreed credit limit per account, with an approval trail.", + horizon="H1"), + TraceRow(pain_point="PP2", summary="EDI channel undocumented and unowned", + severity="High", recommendation="R-03", opportunity="OPP3", + expected_outcome="A credit gate on the channel carrying most order value.", + horizon="H2"), + TraceRow(pain_point="PP3", summary="EDI order failures concentrate here", + severity="High", recommendation="R-02", opportunity="OPP2", + expected_outcome="Routine EDI exceptions resolve without an agent.", + horizon="H1"), + TraceRow(pain_point="PP4", summary="Six connections under the former parent", + severity="Medium", recommendation="R-04", opportunity="OPP4", + expected_outcome="All connections on the organisation's own platform.", + horizon="H2"), + TraceRow(pain_point="PP5", summary="Customer masters hold different populations", + severity="Medium", recommendation="R-05", opportunity="OPP5", + expected_outcome="One agreed active-account population across both systems.", + horizon="H1"), ], executive_summary=ExecutiveSummary( headline="Order-to-Cash runs on two customer systems that disagree on credit, and a " @@ -577,9 +1077,17 @@ def fixture_o2c() -> SynthesisContent: "release, so the volume that flows through EDI is no longer a blind spot.") +def _table(t) -> DataTable: + return DataTable(title=t.get("title", ""), columns=list(t.get("columns", [])), + rows=[list(r) for r in t.get("rows", [])], caption=t.get("caption", ""), + note=t.get("note", ""), sources=[_sref(s) for s in t.get("sources", [])]) + + def _from_payload(payload: dict) -> SynthesisContent: - """Map a live emit_synthesis payload onto the dataclasses. Defensive: tolerates missing - optional keys so a partial emit degrades rather than crashes.""" + """Map a live emit_synthesis / fan-out payload onto the dataclasses. Defensive: tolerates missing + optional keys so a partial emit degrades rather than crashes. Maps the DEEP fields too (data + tables, process detail, per-PP detail tables, evidence/risk/traceability registers, baseline + stats) so the fan-out's merged payload reconstructs a reference-depth SynthesisContent.""" cs = payload.get("current_state", {}) current = CurrentState( domain_overview=cs.get("domain_overview", ""), @@ -602,12 +1110,22 @@ def _from_payload(payload: dict) -> SynthesisContent: for p in cs.get("system_profiles", [])], format_taxonomy=[FormatPattern(label=f["label"], description=f.get("description", ""), examples=f.get("examples", "")) - for f in cs.get("format_taxonomy", [])]) + for f in cs.get("format_taxonomy", [])], + baseline_stats=[KeyStat(value=str(k.get("value", "")), label=k.get("label", ""), + sublabel=k.get("sublabel", "")) for k in cs.get("baseline_stats", [])], + data_tables=[_table(t) for t in cs.get("data_tables", [])], + process_detail=[ProcessDetail(title=p.get("title", ""), body=p.get("body", ""), + actor=p.get("actor", ""), system=p.get("system", ""), + sources=[_sref(s) for s in p.get("sources", [])]) + for p in cs.get("process_detail", [])]) pain_points = [PainPoint( id=p["id"], title=p["title"], impact_rank=p.get("impact_rank", 1), from_finding=p.get("from_finding", ""), description=p.get("description", ""), root_cause=p.get("root_cause", ""), failure_pattern=p.get("failure_pattern", ""), + business_consequence=p.get("business_consequence", ""), category=p.get("category", ""), + severity=p.get("severity", ""), quantified=[_num(n) for n in p.get("quantified", [])], + detail_table=_table(p["detail_table"]) if p.get("detail_table") else None, sources=[_sref(s) for s in p.get("sources", [])]) for p in payload.get("pain_points", [])] opps = [_opp(o) for o in payload.get("opportunities", [])] tr = payload.get("transformation", {}) @@ -629,7 +1147,21 @@ def _from_payload(payload: dict) -> SynthesisContent: target=m.get("target", "")) for m in payload.get("metrics_framework", [])], executive_summary=_exec_summary(payload.get("executive_summary", {})), - target_state=payload.get("target_state", "")) + target_state=payload.get("target_state", ""), + evidence_register=[EvidenceRow(finding=e.get("finding", ""), source=e.get("source", ""), + evidence_type=e.get("evidence_type", ""), + data_point=e.get("data_point", ""), + confidence=e.get("confidence", "")) + for e in payload.get("evidence_register", [])], + risk_register=[RiskItem(risk=r.get("risk", ""), likelihood=r.get("likelihood", ""), + impact=r.get("impact", ""), mitigation=r.get("mitigation", ""), + owner=r.get("owner", "")) for r in payload.get("risk_register", [])], + traceability=[TraceRow(pain_point=t.get("pain_point", ""), summary=t.get("summary", ""), + severity=t.get("severity", ""), + recommendation=t.get("recommendation", ""), + opportunity=t.get("opportunity", ""), + expected_outcome=t.get("expected_outcome", ""), + horizon=t.get("horizon", "")) for t in payload.get("traceability", [])]) def _exec_summary(es: dict) -> ExecutiveSummary: diff --git a/v1/discovery/reportsuite/render.py b/v1/discovery/reportsuite/render.py index e4cee4d..d506e75 100644 --- a/v1/discovery/reportsuite/render.py +++ b/v1/discovery/reportsuite/render.py @@ -1,11 +1,20 @@ -"""Render a SynthesisContent into the 6-report client suite (standalone HTML + index). +"""Render a SynthesisContent into the formal discovery report suite. + +Each of the seven reports is a STANDALONE deliverable: its own branded cover, its own table of +contents, and hierarchically numbered sections — matching the reference deliverables. There is no +Document Control or Input-Documents section (dropped by request). The visual identity is formal +navy/blue corporate (see assets.py). Reads ONLY SynthesisContent — tool names, locators and filenames are unreachable by type. Each -report is leak-guarded before write; Report 01 additionally passes a factual-language lint. +report is leak-guarded before write; Report 01 additionally passes a factual-language lint. Every +number, label, node, and quote shown traces to a verified finding; components and infographics omit +themselves when their grounded data is absent (never fabricated). """ from __future__ import annotations import html +import math +import re from pathlib import Path from .. import docnames @@ -24,10 +33,10 @@ ("06-supporting-artefacts", "Supporting Artefacts"), ] _PATTERN_LABEL = {"hitl_workflow": "HITL Workflow", "automation": "Automation Pipeline", - "modernisation": "Modernisation", - "ai_agent": "AI Agent"} + "modernisation": "Modernisation", "ai_agent": "AI Agent"} _QUAD = [("do_first", "Do First"), ("plan_for", "Plan For"), ("consider", "Consider"), ("deprioritise", "Deprioritise")] +_HORIZON_CLASS = {"H1": "al-h1", "H2": "al-h2", "H3": "al-h3"} def render_suite(s: SynthesisContent, meta: dict, outdir: Path, @@ -36,7 +45,6 @@ def render_suite(s: SynthesisContent, meta: dict, outdir: Path, (outdir / "assets").mkdir(exist_ok=True) (outdir / "assets" / "report.css").write_text(CSS, encoding="utf-8") (outdir / "assets" / "report.js").write_text(JS, encoding="utf-8") - # render each source document to a readable page so citations can click through (provenance) _render_source_pages(s, outdir, suppress_names) fns = {"00-executive-summary": r00, "01-current-state": r01, "02-pain-points": r02, "03-recommendation": r03, "04-opportunity-portfolio": r04, "05-roadmap": r05, @@ -44,404 +52,844 @@ def render_suite(s: SynthesisContent, meta: dict, outdir: Path, for slug, title in REPORTS: body = _secnum_chips(_scrub_names(fns[slug](s, meta), suppress_names)) text = _strip_tags(body) - # tool/jargon leaks still hard-fail; suppressed client names are scrubbed above, so the - # guard here is a backstop that should never trip on a name post-scrub. assert_no_leaks(text, suppress_names=suppress_names) if slug == "01-current-state": assert_factual(text) - (outdir / f"{slug}.html").write_text(_page(title, body, slug, meta), encoding="utf-8") + (outdir / f"{slug}.html").write_text( + _page(slug, title, body, meta), encoding="utf-8") # index.html IS the executive summary (the natural entry point to the suite) index = outdir / "index.html" index_body = _secnum_chips(_scrub_names(fns["00-executive-summary"](s, meta), suppress_names)) - index.write_text(_page(REPORTS[0][1], index_body, "00-executive-summary", meta, is_index=True), + index.write_text(_page("00-executive-summary", REPORTS[0][1], index_body, meta), encoding="utf-8") return index -# ---- report bodies (return HTML fragments) -------------------------------- +# --------------------------------------------------------------------------- +# section builder — each report appends numbered sections; the TOC is derived +# from exactly the sections present, so it can never drift from the body. +# --------------------------------------------------------------------------- +class _Doc: + """Accumulates numbered sections for one report. h1(title) opens a section (1, 2, …); h2(title) + opens a subsection (1.1, 1.2, …). Both record a TOC entry. raw() appends arbitrary HTML to the + current section without numbering. The rendered body and the TOC come from the same source.""" + + def __init__(self, report_title: str, lede: str = ""): + self.report_title = report_title + self.lede = lede + self._parts: list[str] = [] + self._toc: list[tuple[str, str, bool]] = [] # (number, title, is_sub) + self._sec = 0 + self._sub = 0 + + def h1(self, title: str) -> "_Doc": + self._sec += 1 + self._sub = 0 + num = str(self._sec) + self._toc.append((num, title, False)) + self._parts.append(f"
{esc(text)}
") + return self + + def body(self) -> str: + head = [f"{esc(self.lede)}
") + return "\n".join(head + self._parts) + + def toc_html(self) -> str: + if not self._toc: + return "" + rows = [] + for num, title, is_sub in self._toc: + cls = "ti-sub" if is_sub else "" + rows.append(f"{esc(num)}" + f"{esc(title)}" + f"") + return f"{esc(headline)}
", - kpi_tiles(s)] - # situation / opportunity, side by side, short + d = _Doc("Executive Summary", headline) + d.h1("At a glance").raw(stat_tiles(_exec_tiles(s))) if es.situation or es.opportunity: - h.append("{esc(es.situation)}
{esc(es.situation)}
{esc(es.opportunity)}
{esc(es.opportunity)}
" + "{esc(_clip(o.overview, 150))}
" - + (f"{impact}
" if impact else "") - + "") - h.append("{esc(_clip(o.overview, 150))}
" + + (f"{impact}
" if impact else "") + "") + cards.append("{names}{more}. Every figure in this assessment is computed from these " - "sources and traces back to them.
") - h.append("Read on: the " - "Current State, the " - "issues found, and the recommended " - "opportunities.
") - return "\n".join(h) + d.h1("What we read") + d.p(f"{names}{more}. Every figure in this assessment is computed from these sources and " + "traces back to them.") + d.raw("Read on: the " + "Current State, the " + "issues found, and the recommended " + "opportunities.
") + return d.toc_html() + d.body() + + +def _tables_titled(tables, *needles): + """Return the grounded data tables whose title contains any of the given (lowercased) needles, + in declared order. Lets r01 place each table in the right numbered section without hardcoding.""" + out = [] + for t in tables or []: + low = (t.title or "").lower() + if any(n in low for n in needles): + out.append(t) + return out def r01(s: SynthesisContent, meta) -> str: cs = s.current_state dom = esc(meta.get("domain_label", "this process")) at_client = f" at {esc(meta['client'])}" if meta.get("client") else "" - h = [f"How {dom} runs today{at_client}. A factual baseline of the process, the " - "systems that support it, and how information is structured — stated as fact, no judgements.
", - "{esc(cs.domain_overview)}
", - f"{esc(cs.process_summary)}
", - "The end-to-end flow, who performs each step and on which system:
", - process_flow_svg(cs.process_flow), - "| Step | Performed by | System | " - "What happens |
|---|
| Step | Performed by | System | " + "What happens |
|---|---|---|---|
| {st.seq}. {esc(st.name)} | {esc(st.actor)} | " - f"{esc(st.system)} | {esc(st.description)} |
{esc(pd.body)}
" + + (f"Source: {_cite_links(pd.sources)}
" if pd.sources else "") + + "| Activity | Responsible | Accountable | " + "
|---|---|---|
| {esc(r.activity)} | {esc(r.responsible)} | " + f"{esc(r.accountable)} |
| System | Role | System of record for | " + "
|---|---|---|
| {esc(it.name)} | {esc(it.purpose)} | " + f"{esc(it.system_of_record_for)} |
Each system that supports this process — what it is, how it is used, who owns " - "it, and the constraints observed.
") + d.h2("System profiles") + d.p("Each system that supports this process — what it is, how it is used, who owns it, and " + "the constraints observed.") for p in cs.system_profiles: - h.append("{esc(p.role)}
") + card.append(f"{esc(p.role)}
") if p.how_used: - h.append(f"How it's used. {esc(p.how_used)}
") + card.append(f"How it's used. {esc(p.how_used)}
") if p.owners: - h.append(f"Ownership & access. {esc(p.owners)}
") + card.append(f"Ownership & access. {esc(p.owners)}
") if p.limitations: - h.append(f"Observed constraints. {esc(p.limitations)}
") - h.append("Observed constraints. {esc(p.limitations)}
") + card.append("The patterns the source information follows — this drives how it can be " - "ingested and reasoned over.
") - h.append("| Pattern | Description | Where it appears | " - "
|---|
| Pattern | Description | Where it appears | " + "
|---|---|---|
| {esc(fp.label)} | {esc(fp.description)} | " - f"{esc(fp.examples)} |
| Activity | Responsible | Accountable | " - "
|---|---|---|
| {esc(r.activity)} | {esc(r.responsible)} | " - f"{esc(r.accountable)} |
| System | Role | System of record for | " - "
|---|---|---|
| {esc(it.name)} | {esc(it.purpose)} | " - f"{esc(it.system_of_record_for)} |
The issues found in the discovery, ranked by business impact, " - "each mapped to a recommended opportunity.
", - impact_bars_svg(s.pain_points), - render_charts(s.charts, kinds={"bar"})] # magnitude bars here; share donut on Report 06 - for pp in sorted(s.pain_points, key=lambda p: p.impact_rank): - h.append("{esc(pp.description)}
") - if pp.quantified: - h.append("" + " ".join(_metric(n) for n in pp.quantified) + "
") - h.append(f"Root cause: {esc(pp.root_cause)}
") - h.append(f"Pattern: {esc(pp.failure_pattern)}
") - if pp.opportunity_signal: - h.append(f"Addressed by: {esc(pp.opportunity_signal)} " - f"(see the Opportunity Portfolio)
") - h.append(f"Where this comes from: {_cite_links(pp.sources)}
") - h.append("{esc(c.get('pattern',''))}. " - f"{esc(c.get('description',''))}
") - return "\n".join(h) + d.raw(f"{esc(c.get('pattern',''))}. " + f"{esc(c.get('description',''))}
") + d.h1("Pain points in detail") + for idx, pp in enumerate(sorted(s.pain_points, key=lambda p: p.impact_rank), start=1): + d.raw(_pain_point_card(pp, idx)) + if s.evidence_register: + d.h1("Appendix — evidence register") + d.p("Every finding traced to the source it rests on, with the confidence tier.") + er = ["| Finding | Source | Evidence type | " + "Key data point | Confidence |
|---|---|---|---|---|
| {esc(e.finding)} | {esc(e.source)} | " + f"{esc(e.evidence_type)} | {esc(e.data_point)} | " + f"{esc(e.confidence)} |
Which opportunities to pursue, in what order, by value and " - "feasibility.
", - "| Opportunity | Pattern | Value | " - "Feasibility | Sequence |
|---|---|---|---|---|
| {esc(o.title)} | {_PATTERN_LABEL.get(o.pattern.value,'')} | " - f"{esc(o.value_rating.title())} | " - f"{esc(o.feasibility_rating.title())} | {esc(seq)} |
" + esc(s.target_state) + "
Each opportunity assessed across three readiness dimensions. The rating " - "(high / medium / low) is followed by the reason, so the sequence is defensible " - "rather than asserted.
") - h.append("| Opportunity | " - "Data readiness | Technical complexity | " - "Operational readiness |
|---|
| Opportunity | Data readiness | " + "Technical complexity | Operational readiness |
|---|---|---|---|
| {esc(o.title)} | " - f"{_rating_cell(o.data_readiness)} | " - f"{_rating_cell(o.technical_complexity)} | " - f"{_rating_cell(o.operational_readiness)} |
{esc(s.sequencing_rationale)}
") + rt.append(f"Dependencies: {esc(s.dependency_notes)}
") - h.append(f"{esc(s.strategic_readiness)}
") - if s.target_state: - h.append("" + esc(s.target_state) + "
Dependencies: {esc(s.dependency_notes)}
") + d.raw(dependency_map_svg(s.opportunities)) + d.h2("Strategic readiness").p(s.strategic_readiness) + + if s.metrics_framework: + d.h1("Success metrics") + d.p("How to measure delivery once live — the baseline today and the directional target.") + mt = ["| Metric | What it measures | Target | " + "
|---|---|---|
| {esc(m.name)} | {esc(m.definition)} | " + f"{esc(m.target)} |
| Risk | Likelihood | Impact | Mitigation | " + "Owner |
|---|---|---|---|---|
| {esc(r.risk)} | {_level_badge(r.likelihood)} | " + f"{_level_badge(r.impact)} | {esc(r.mitigation)} | " + f"{esc(r.owner)} |
| Pain point | Summary | Severity | " + "Recommendation | Opportunity | Expected outcome | Horizon | " + "
|---|---|---|---|---|---|---|
| {esc(t.pain_point)} | {esc(t.summary)} | " + f"{esc(t.severity)} | {esc(t.recommendation)} | " + f"{esc(t.opportunity)} | {esc(t.expected_outcome)} | " + f"{esc(t.horizon)} |
The recommended interventions in full — what the problem is, how the " - "process changes, and what it delivers.
"] - # Summary table first (the use-case anatomy: pattern / who / sources / behaviour), then the - # deep write-ups follow. Mirrors the prior-engagement use-case summary table. + d = _Doc("AI Opportunity Portfolio", + "The recommended interventions in full — what the problem is, how the process changes, " + "and what it delivers.") if s.opportunities: - h.append("| Opportunity | Pattern | " - "Who it serves | Knowledge sources | Expected behaviour | " - "
|---|
| Opportunity | Pattern | " + "Who it serves | Knowledge sources | Expected behaviour | " + "
|---|---|---|---|---|
| {esc(o.title)} | " - f"{_PATTERN_LABEL.get(o.pattern.value,'')} | " - f"{who} | {srcs} | {beh} |
{esc(o.overview)}
") - h.append("Business impact. " + esc(bi.narrative)) - if bi.quantified: - h.append(" " + " ".join(_metric(n) for n in bi.quantified)) - h.append("
") - if bi.derivation: - h.append(f"How we get there: {esc(bi.derivation)}
") - h.append(f"How it's delivered. {esc(o.implementation_approach)}
") - # Operating model: who uses it, what it does, and when it hands back to a human. - if o.personas or o.expected_behaviour or o.escalation: - h.append("Who uses it. " + - ", ".join(esc(x) for x in o.personas) + "
") - if o.expected_behaviour: - h.append(f"Expected behaviour. {esc(o.expected_behaviour)}
") - if o.escalation: - h.append(f"Escalation & human fallback. " - f"{esc(o.escalation)}
") - if o.knowledge_sources or o.document_formats: - bits = [] - if o.knowledge_sources: - bits.append("Sources. " + - ", ".join(esc(x) for x in o.knowledge_sources)) - if o.document_formats: - bits.append("Formats. " + - ", ".join(esc(x) for x in o.document_formats)) - h.append("" + " ".join(bits) + "
") - h.append("Connects: " + - ", ".join(esc(x) for x in o.required_integrations) + "
") - if o.success_metrics: - h.append("Success looks like:
Dependencies: {esc(dep)}
") - if o.risks: - h.append("Risks:
Where this comes from: {_cite_links(o.sources)}
") - h.append("Sequenced across three horizons, aimed at a " - f"{esc(posture)} direction.
", - roadmap_timeline_svg(s.roadmap), - "Strategic direction. {esc(posture[:1].upper() + posture[1:])}
") + d.h1("Implementation roadmap") + d.raw(roadmap_timeline_svg(s.roadmap)) + d.h1("Horizon detail") for hz in s.roadmap: - h.append(f"| Type | Assumption | Basis |
|---|
Reference material behind this assessment.
", - "| Document | Type | What it is | " - "Findings it supported |
|---|---|---|---|
| {name} | {esc(d.doc_type)} | " - f"{esc(d.what_we_read)} | {esc(fnd)} |
| Source | Type | What it is | " + "Findings it supported |
|---|---|---|---|
| {name} | {esc(doc.doc_type)} | " + f"{esc(doc.what_we_read)} | {esc(fnd)} |
How the impact of the recommended interventions should be measured once live — " - "the dimension, what it means, and the target to hold delivery to.
") - h.append("| Metric | Definition | Target | " - "
|---|
| Metric | Definition | Target | " + "
|---|---|---|
| {esc(m.name)} | {esc(m.definition)} | " - f"{esc(m.target)} |
The full step-by-step process flow is in the Current State " - "Assessment; this is the systems view of the same domain.
") - h.append(data_flow_svg(s.current_state.process_flow)) - h.append("A full technical trace of every figure in this assessment is " - "available to your data team on request.
") - return "\n".join(h) - - -# ---- diagrams (self-contained SVG, no external deps) ---------------------- + d.h1("Where the failures concentrate") + d.raw(donut) + d.h1("System & data-flow map") + d.p("The systems view of the same domain — which business systems the process touches and how " + "data moves between them.") + d.raw(data_flow_svg(s.current_state.process_flow)) + d.raw("A full technical trace of every figure in this assessment is " + "available to your data team on request.
") + return d.toc_html() + d.body() + + +# --------------------------------------------------------------------------- +# grounded component renderers (HTML) +# --------------------------------------------------------------------------- +def _severity(rank: int, explicit: str = "") -> tuple[str, str]: + """Severity badge for a pain point. Uses an explicit grounded severity when set + (high|medium|lower), else falls back to the impact rank. Red/amber for high/medium; neutral blue + for lower (green would read as 'good' and clash in a pain-point context).""" + level = (explicit or "").strip().lower() + if not level: + level = "high" if rank <= 1 else "medium" if rank == 2 else "lower" + if level == "high": + return "b-high", "High Severity" + if level == "medium": + return "b-med", "Medium Severity" + return "b-pat", "Lower Severity" + + +def _is_high(pp) -> bool: + lvl = (pp.severity or "").strip().lower() + return lvl == "high" if lvl else pp.impact_rank <= 1 + + +def _pain_point_card(pp, idx: int) -> str: + sev_cls, sev_lbl = _severity(pp.impact_rank, pp.severity) + badges = [f"{sev_lbl}"] + cat = pp.category or pp.failure_pattern + if cat: + badges.append(f"{esc(_clip(cat, 28))}") + h = ["{esc(pp.description)}
"] + mini = _mini_stats(pp.quantified) + if mini: + h.append(mini) + if pp.detail_table: + h.append(_data_table(pp.detail_table)) + h.append(f"Root cause: {esc(pp.root_cause)}
") + quote = _ev_quote(pp.sources) + if quote: + h.append(quote) + if pp.business_consequence: + hi = _is_high(pp) + sev_box = "high-box" if hi else "med-box" + title_cls = "hb-title" if hi else "mb-title" + text_cls = "hb-text" if hi else "mb-text" + h.append(f"Where this comes from: {_cite_links(pp.sources)}
") + h.append("{esc(o.overview)}
"] + # phased actions: derive from before→after milestones placed on the opportunity's horizon + actions = _rec_actions(o, hz) + if actions: + h.append("{esc(o.overview)}
", + "Business impact. " + esc(bi.narrative) + if bi.quantified: + impact += " " + " ".join(_metric(n) for n in bi.quantified) + impact += "
" + h.append(impact) + if bi.derivation: + h.append(f"How we get there: {esc(bi.derivation)}
") + h.append(f"How it's delivered. {esc(o.implementation_approach)}
") + if o.personas or o.expected_behaviour or o.escalation: + om = ["Who uses it. " + ", ".join(esc(x) for x in o.personas) + + "
") + if o.expected_behaviour: + om.append(f"Expected behaviour. {esc(o.expected_behaviour)}
") + if o.escalation: + om.append(f"Escalation & human fallback. {esc(o.escalation)}
") + if o.knowledge_sources or o.document_formats: + bits = [] + if o.knowledge_sources: + bits.append("Sources. " + + ", ".join(esc(x) for x in o.knowledge_sources)) + if o.document_formats: + bits.append("Formats. " + + ", ".join(esc(x) for x in o.document_formats)) + om.append("" + " ".join(bits) + "
") + om.append("Connects: " + + ", ".join(esc(x) for x in o.required_integrations) + "
") + if o.success_metrics: + h.append("Success looks like:
Dependencies: {esc(dep)}
") + if o.risks: + h.append("Risks:
Where this comes from: {_cite_links(o.sources)}
") + h.append("No process flow available.
" - WRAP = 3 - NW, NH = 234, 104 - GAP_X, GAP_Y = 58, 52 - PAD = 16 - HDR = 30 # coloured header band height + return "" + WRAP, NW, NH, GAP_X, GAP_Y, PAD, HDR = 3, 234, 104, 58, 52, 16, 30 rows = [steps[i:i + WRAP] for i in range(0, len(steps), WRAP)] cols = min(WRAP, len(steps)) width = PAD * 2 + cols * NW + (cols - 1) * GAP_X height = PAD * 2 + len(rows) * NH + (len(rows) - 1) * GAP_Y - out = [f"") + + +# --------------------------------------------------------------------------- +# per-report cover + standalone page assembly +# --------------------------------------------------------------------------- +def _cover(slug: str, title: str, meta: dict) -> str: + """A branded per-report cover (print-only). Report tag, title, domain/client subtitle, and a + meta grid. The report number is the slug's own prefix (00 = Executive Summary, 01–06 = the + content reports), so it never drifts. Names route through scrub via meta (client blanked when + suppressed).""" client = (meta.get("client") or "").strip() domain = esc(meta.get("domain_label", "Discovery")) - title = f"{domain} Discovery Report" + num = slug.split("-")[0] + is_exec = num == "00" + tag = "Executive Summary" if is_exec else f"Report {num} of 06" sub = esc(client) if client else "Autonomous Discovery Assessment" - return (f"