Spaces:
Paused
Paused
File size: 6,935 Bytes
f5f3917 7b656eb f5f3917 7b656eb f5f3917 f044ccc f5f3917 4e5ac21 f5f3917 4e5ac21 f5f3917 7b656eb f5f3917 ab7ad8b f5f3917 7b656eb f5f3917 7b656eb f5f3917 7b656eb f5f3917 7b656eb f5f3917 7b656eb ab7ad8b f5f3917 ab7ad8b 7b656eb f5f3917 ab7ad8b f5f3917 f044ccc f5f3917 7b656eb f044ccc f5f3917 f044ccc f5f3917 7b656eb f5f3917 7b656eb 4d6214e f5f3917 4d6214e f5f3917 7b656eb f5f3917 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | """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 (v4): each sub-probe has 4 labelled lines:
- id: <int>
probe: <plain language>
rationale: <2-3 sentences>
anchor: <participant quote>
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"]
|