"""Per-session generator: produces the perfect-answer essay AND the discussion plan in a single API call. Called once at the moment the participant confirms their initial-argument submission. Reads: - the participant's argument (passed in by app.py) - data/case_analysis.md (consolidated case material) - data/ethics-frameworks.md (analytical lenses) Returns a dict {"perfect_answer": str, "discussion_plan": str}. The prompt lives in `prompts/perfect_answer_prompt.md` and is loaded via `core.config_loader.PERFECT_ANSWER_PROMPT`. To change the generator's behaviour, edit that markdown file. Discussion plan schema: each sub-probe has 5 labelled lines: - id: component: gap: rationale: <2-3 sentences on why the gap matters> hook: Latency: around 15-30s with gpt-5.4-nano at medium reasoning. The host code in app.py shows a "preparing the discussion" status message during the wait. """ import re from core.config_loader import client as _default_client, MODEL, BASE_DIR, PERFECT_ANSWER_PROMPT CASE_ANALYSIS_PATH = BASE_DIR / "data" / "case_analysis.md" FRAMEWORKS_PATH = BASE_DIR / "data" / "ethics-frameworks.md" USER_TEMPLATE = """=== PARTICIPANT'S DRAFT (for internal guidance only) === {argument} === CASE CONTENT (background; do not quote, do not list) === {case_analysis} === ANALYTICAL LENS (consequence framework) === {frameworks} Now produce the three artifacts in the required format. """ # Tolerant marker matchers. The model occasionally decorates the markers # (markdown bold/heading, extra spaces, varying '=' counts, case, or # space/underscore/hyphen inside "DISCUSSION PLAN"). They must still be the # only thing on their line. A too-strict regex here was silently dropping # the discussion plan in earlier versions. def _marker_re(label): return re.compile( r"^[ \t#*>_~`-]*=*[ \t]*" + label + r"[ \t]*=*[ \t#*>_~`-]*$", re.IGNORECASE | re.MULTILINE, ) _ESSAY_MK = _marker_re(r"ESSAY") _DOMINANT_MK = _marker_re(r"DOMINANT[ \t_-]*FRAMEWORK") _PLAN_MK = _marker_re(r"DISCUSSION[ \t_-]*PLAN") _END_MK = _marker_re(r"END") # Structural fallback: the plan body always opens with a numbered family # header ("1) Family (...):") that is soon followed by a "- id:" line. _PLAN_HEADER_RE = re.compile(r"^\s*\d+\s*[\).]\s+\S.*:\s*$", re.MULTILINE) _ID_LINE_RE = re.compile(r"^\s*[-*]\s*id\s*:\s*\d+\s*$", re.IGNORECASE | re.MULTILINE) def _span(mk, text, start=0): m = mk.search(text, start) return (m.start(), m.end()) if m else None def _structural_plan_start(text, start=0): """Index of the first numbered family header that is followed (within ~600 chars) by an `id:` line, or None.""" for hm in _PLAN_HEADER_RE.finditer(text, start): if _ID_LINE_RE.search(text[hm.start(): hm.start() + 600]): return hm.start() return None def _parse_artifacts(raw): """Extract essay + dominant_framework + plan from the delimited response, tolerating decorated or missing markers. If the DISCUSSION PLAN marker is absent, the plan is recovered structurally from its numbered-family / id-line shape. The DOMINANT FRAMEWORK block (if present, between ESSAY and DISCUSSION PLAN) is extracted and STRIPPED from the essay so the participant-facing essay text does not leak the internal framework label.""" raw = raw or "" e = _span(_ESSAY_MK, raw) body_start = e[1] if e else 0 p = _span(_PLAN_MK, raw, body_start) end = _span(_END_MK, raw, p[1] if p else body_start) end_i = end[0] if end else len(raw) if p: essay = raw[body_start:p[0]].strip() plan = raw[p[1]:end_i].strip() else: split_at = _structural_plan_start(raw, body_start) if split_at is not None: essay = raw[body_start:split_at].strip() plan = raw[split_at:end_i].strip() else: essay = raw[body_start:end_i].strip() plan = "" # Extract DOMINANT FRAMEWORK from the essay region (it sits between # `=== ESSAY ===` and `=== DISCUSSION PLAN ===`) and strip it out so # the participant-facing essay text doesn't include the marker block. dominant = "" dm = _span(_DOMINANT_MK, essay) if dm: # essay text is everything before the DOMINANT marker. # dominant_framework value is everything after the marker line. dominant = essay[dm[1]:].strip() essay = essay[:dm[0]].strip() # Last-resort salvage: plan triples ended up inside the essay (marker # missed and the structural split landed too late). if not plan and _ID_LINE_RE.search(essay): s = _structural_plan_start(essay) if s is not None: plan = essay[s:].strip() essay = essay[:s].strip() return {"perfect_answer": essay, "discussion_plan": plan, "dominant_framework": dominant} def generate_session_artifacts(argument_text, *, reasoning="medium", verbosity="medium", client=None): """Single blocking API call. Returns {"perfect_answer", "discussion_plan"}. Raises on API error.""" api = client if client is not None else _default_client case = CASE_ANALYSIS_PATH.read_text(encoding="utf-8") frameworks = FRAMEWORKS_PATH.read_text(encoding="utf-8") user_msg = USER_TEMPLATE.format( argument=argument_text.strip(), case_analysis=case, frameworks=frameworks, ) def _call(): response = api.responses.create( model=MODEL, reasoning={"effort": reasoning}, text={"verbosity": verbosity}, input=[ {"role": "developer", "content": PERFECT_ANSWER_PROMPT}, {"role": "user", "content": user_msg}, ], ) return _parse_artifacts(response.output_text or "") arts = _call() # The discussion plan is mandatory: the host-side state machine is # disabled without it. The model occasionally drops the DISCUSSION # PLAN block (output budget exhaustion or model flakiness). Retry # up to 2 more times — 3 total attempts. Real production case: # session 20260601_195128_71512 had two consecutive empty attempts; # a third would almost certainly have caught it. MAX_RETRIES = 2 for attempt in range(1, MAX_RETRIES + 1): if (arts.get("discussion_plan") or "").strip(): break print( f"[perfect_answer] discussion_plan empty; retry {attempt}/{MAX_RETRIES}", flush=True, ) retry = _call() if (retry.get("discussion_plan") or "").strip(): arts = retry break return arts # Backwards-compatible alias for any external test harness still importing # the old name. Returns just the essay string. def generate_perfect_answer(argument_text, **kwargs): return generate_session_artifacts(argument_text, **kwargs)["perfect_answer"]