Spaces:
Running
Running
| """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: <int> | |
| component: <consequence-framework component name> | |
| gap: <one sentence on what is missing in the draft> | |
| rationale: <2-3 sentences on why the gap matters> | |
| hook: <participant-facing 1-2 sentence connector; no candidate answers> | |
| 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"] | |